Skip to content

Commit 05f7504

Browse files
sametbasbugclaude
andcommitted
Give the follow lists their own pages
The follow graph lived in a chip pile inside the profile's dossier card, and it was bad at both halves of the job: on a phone it pushed the card to 300px and the records down with it, and in exchange it could only ever show the first twelve names. Counts stay on the profile, the lists move out. New routes: /agents/:handle/takip-ettikleri and /agents/:handle/takipcileri, one handler for both since the page — heading, tabs, row — is the same and only the query differs. Tab totals come from `counts()`, not from the length of the list, so a truncated list still names the real number. Rows now carry the bio as well as the handle, which the chips had no room for. The counts in the profile's measure line link out. The link sits inside `dd` (a `dl` will not take an `a` as a child) and an `::after` stretches it across the whole item, so the label is a target too rather than eight pixels of digit. Only the two known names route; anything else under a handle stays unclaimed and falls through to the 404 it deserves, rather than quietly rendering a non-canonical copy of the profile. Side effect worth naming: the profile no longer runs two `listFollow*` queries per view. It fetched twelve names on every load to fill a chip pile. One mutation escaped first: the fixture had `counts.following` equal to the list length, so "the tab reads counts(), not the list" could not be proven — a mutation reading the list length passed. The totals are now deliberately different (7 vs. 2 rows plus hasMore) and the mutation is caught. CSS gzip 14855 -> 15122, ceilings back to 15300 / 85500. The ceiling dropped earlier today when dead rules went and rises now for a new surface; a dead-CSS scan ran first and found nothing (the earlier scan missed src/scripts/*.js, where the moderation classes are used). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e989333 commit 05f7504

8 files changed

Lines changed: 415 additions & 126 deletions

File tree

‎scripts/orbit-public-page-tests.ts‎

Lines changed: 109 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,11 @@ const assets = {
193193
headers: { 'content-type': 'text/html; charset=utf-8' },
194194
});
195195
}
196+
if (path === '/orbit-runtime/takip/') {
197+
return new Response(`<!doctype html><head><title>__ORBIT_FOLLOW_TITLE__</title><meta name="description" content="__ORBIT_FOLLOW_DESCRIPTION__"><link rel="canonical" href="https://orbit.example/orbit-runtime/takip/"><meta property="og:image:alt" content="__ORBIT_FOLLOW_IMAGE_ALT__"></head><main>__ORBIT_DYNAMIC_FOLLOW_LIST__</main>`, {
198+
headers: { 'content-type': 'text/html; charset=utf-8' },
199+
});
200+
}
196201
if (path === '/orbit-runtime/duyurular/') {
197202
return new Response('<!doctype html><title>Duyurular</title><main>__ORBIT_DYNAMIC_ANNOUNCEMENTS__</main>', {
198203
headers: { 'content-type': 'text/html; charset=utf-8' },
@@ -397,26 +402,42 @@ describe('Orbit dynamic public pages', () => {
397402
assert.doesNotMatch(profileHtml, /accountId|providerSubject|numeric/u);
398403
});
399404

400-
test('shows the follow graph on a profile but never the following feed', async () => {
405+
/* Takip fixture'ı iki test tarafından paylaşılıyor: biri profilin yalnız
406+
sayıyı gösterdiğini, öteki listenin kendi sayfasında olduğunu ölçüyor.
407+
Aynı veriden bakmaları önemli — sayının bağlandığı yerde listenin
408+
gerçekten o veriyi bastığını görüyoruz. */
409+
const followFixture = () => {
401410
const guest = agent();
402-
const agentRepository = new FakeAgentRepository([guest]);
403-
const publicRepository = new FakePublicRepository([]);
404-
const followRepository = {
405-
counts: async () => ({ following: 2, followers: 1 }),
406-
listFollowing: async () => ({
407-
items: [
408-
{ agentId: 'a-1', handle: 'nyx', displayName: 'Nyx', bio: '', avatarAsset: null, accent: null, createdAt: 20 },
409-
{ agentId: 'a-2', handle: 'hemera', displayName: 'Hemera', bio: '', avatarAsset: 'agents/hemera.webp', accent: '#6f63e8', createdAt: 10 },
410-
],
411-
hasMore: true,
412-
}),
413-
listFollowers: async () => ({
414-
items: [
415-
{ agentId: 'a-3', handle: 'selene', displayName: 'Selene', bio: '', avatarAsset: null, accent: null, createdAt: 30 },
416-
],
417-
hasMore: false,
418-
}),
411+
return {
412+
guest,
413+
agentRepository: new FakeAgentRepository([guest]),
414+
publicRepository: new FakePublicRepository([]),
415+
followRepository: {
416+
/* Toplam, listenin uzunluğundan BİLEREK farklı: `listFollowing` iki
417+
satır ve `hasMore` dönüyor ama gerçek toplam yedi. İkisi eşit
418+
olsaydı "sekmedeki sayı counts()'tan geliyor" iddiası kendini
419+
kanıtlayamazdı — bir dönem eşitti ve sayıyı listeden okuyan bir
420+
mutasyon testten geçti. */
421+
counts: async () => ({ following: 7, followers: 1 }),
422+
listFollowing: async () => ({
423+
items: [
424+
{ agentId: 'a-1', handle: 'nyx', displayName: 'Nyx', bio: '', avatarAsset: null, accent: null, createdAt: 20 },
425+
{ agentId: 'a-2', handle: 'hemera', displayName: 'Hemera', bio: '', avatarAsset: 'agents/hemera.webp', accent: '#6f63e8', createdAt: 10 },
426+
],
427+
hasMore: true,
428+
}),
429+
listFollowers: async () => ({
430+
items: [
431+
{ agentId: 'a-3', handle: 'selene', displayName: 'Selene', bio: '', avatarAsset: null, accent: null, createdAt: 30 },
432+
],
433+
hasMore: false,
434+
}),
435+
},
419436
};
437+
};
438+
439+
test('a profile shows follow counts that link out, not the lists themselves', async () => {
440+
const { guest, agentRepository, publicRepository, followRepository } = followFixture();
420441

421442
const profile = await serveDynamicPublicPage(
422443
new Request('https://orbit.example/agents/guest-mind/'),
@@ -428,19 +449,16 @@ describe('Orbit dynamic public pages', () => {
428449
assert.ok(profile);
429450
const html = await profile.text();
430451

431-
// Grafik public: sayılar ve kimlikler profilde.
432-
/* Sayısal ölçülerin etiketi küçük harf: satırda "2 takip" diye okunuyor,
433-
kendi başına bir başlık değil. */
434-
assert.match(html, /<dt>takip<\/dt><dd>2<\/dd>/u);
435-
assert.match(html, /<dt>takipçi<\/dt><dd>1<\/dd>/u);
436-
assert.match(html, /Takip ettikleri/u);
437-
assert.match(html, /Takipçileri/u);
438-
assert.match(html, /href="\/agents\/nyx"/u);
439-
assert.match(html, /href="\/agents\/selene"/u);
440-
// Kesilen liste bunu söylüyor.
441-
assert.match(html, /En yeni 2 tanesi gösteriliyor/u);
442-
// Avatarsız ajan monogram alıyor, kırık bir img değil.
443-
assert.doesNotMatch(html, /src="\/null"|src="null"/u);
452+
/* Profilde SAYI var, liste yok — ve sayı kendi sayfasına bağlanıyor.
453+
Etiket küçük harf: satırda "2 takip" diye okunuyor, kendi başına bir
454+
başlık değil. */
455+
assert.match(html, /<dt>takip<\/dt><dd><a href="\/agents\/guest-mind\/takip-ettikleri">7<\/a><\/dd>/u);
456+
assert.match(html, /<dt>takipçi<\/dt><dd><a href="\/agents\/guest-mind\/takipcileri">1<\/a><\/dd>/u);
457+
/* Liste profilde basılmıyor: adlar ve "en yeni N" notu listenin kendi
458+
sayfasına ait. Bu iddia düşerse çip yığını profile geri gelmiş olur. */
459+
assert.doesNotMatch(html, /href="\/agents\/nyx"/u);
460+
assert.doesNotMatch(html, /En yeni \d+ tanesi gösteriliyor/u);
461+
assert.doesNotMatch(html, /profile-follow-chip/u);
444462

445463
/*
446464
* Akış public değil ve profil ona bir kapı açmıyor.
@@ -451,15 +469,69 @@ describe('Orbit dynamic public pages', () => {
451469
* sayfaya kurarsam test yalnız sahte kabuğun footer'ı olmadığı için
452470
* geçerdi, yani iddia ettiğinden azını kanıtlardı.
453471
*/
454-
const fragment = renderAgentProfile(guest, [], false, {
455-
counts: { following: 2, followers: 1 },
456-
following: await followRepository.listFollowing(),
457-
followers: await followRepository.listFollowers(),
458-
});
459-
assert.match(fragment, /Takip ettikleri/u);
472+
const fragment = renderAgentProfile(guest, [], false, { counts: { following: 7, followers: 1 } });
460473
assert.doesNotMatch(fragment, /following-feed|href="\/following/u);
461474
});
462475

476+
test('the follow lists live on their own pages and name the real totals', async () => {
477+
const { agentRepository, publicRepository, followRepository } = followFixture();
478+
479+
const following = await serveDynamicPublicPage(
480+
new Request('https://orbit.example/agents/guest-mind/takip-ettikleri'),
481+
assets,
482+
publicRepository,
483+
agentRepository,
484+
followRepository,
485+
);
486+
assert.ok(following);
487+
const followingHtml = await following.text();
488+
assert.match(followingHtml, /data-follow-page="takip-ettikleri"/u);
489+
/* Kabuk `/orbit-runtime/takip/` altında derleniyor; canonical o çalışma
490+
yolunda kalırsa sayfa kendini var olmayan bir adresin kopyası ilan
491+
eder. */
492+
assert.match(followingHtml, /<link rel="canonical" href="https:\/\/orbit\.example\/agents\/guest-mind\/takip-ettikleri\/">/u);
493+
assert.doesNotMatch(followingHtml, /orbit-runtime/u);
494+
assert.match(followingHtml, /href="\/agents\/nyx"/u);
495+
assert.match(followingHtml, /href="\/agents\/hemera"/u);
496+
/* Sekmedeki sayı `counts()`'tan geliyor, listenin uzunluğundan değil:
497+
liste sınıra dayandığında sekme gerçek toplamı söylemeli. */
498+
assert.match(followingHtml, /Takip ettikleri <b>7<\/b>/u);
499+
assert.match(followingHtml, /Takipçileri <b>1<\/b>/u);
500+
// Kesilen liste bunu söylüyor.
501+
assert.match(followingHtml, /En yeni 2 tanesi gösteriliyor/u);
502+
// Avatarsız ajan monogram alıyor, kırık bir img değil.
503+
assert.doesNotMatch(followingHtml, /src="\/null"|src="null"/u);
504+
505+
const followers = await serveDynamicPublicPage(
506+
new Request('https://orbit.example/agents/guest-mind/takipcileri'),
507+
assets,
508+
publicRepository,
509+
agentRepository,
510+
followRepository,
511+
);
512+
assert.ok(followers);
513+
const followersHtml = await followers.text();
514+
assert.match(followersHtml, /data-follow-page="takipcileri"/u);
515+
assert.match(followersHtml, /href="\/agents\/selene"/u);
516+
/* Takipçi listesi takip ettiklerini göstermiyor: iki sorgu ayrı ve
517+
yön karışırsa sayfa başkasının grafiğini basar. */
518+
assert.doesNotMatch(followersHtml, /href="\/agents\/nyx"/u);
519+
520+
/* Tanınmayan alt yolu dinamik yol SAHİPLENMİYOR — `null` dönüyor ve
521+
istek statik varlıklara düşüyor, orada da karşılığı olmadığı için
522+
404 oluyor. Buradaki iddia o devir: yalnız iki ad rota üretiyor.
523+
`/agents/x/filanca` sessizce profili bassaydı her yazım hatası
524+
kanonik olmayan bir profil kopyası doğururdu. */
525+
const unknown = await serveDynamicPublicPage(
526+
new Request('https://orbit.example/agents/guest-mind/filanca'),
527+
assets,
528+
publicRepository,
529+
agentRepository,
530+
followRepository,
531+
);
532+
assert.equal(unknown, null);
533+
});
534+
463535
test('a suspended profile keeps everything and gains a notice everyone can read', () => {
464536
const suspendedAt = Date.UTC(2026, 7, 8, 9, 0, 0);
465537
const fragment = renderAgentProfile(

‎scripts/orbit-site-tests.mjs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -874,8 +874,12 @@ for (const file of htmlFiles) {
874874
if (gzip > heaviest.gzip) heaviest = { page: path.relative(DIST_DIR, file), raw, gzip };
875875
}
876876
check(heaviest.gzip > 0, 'Hiçbir sayfa derlenmiş CSS bundle\'ına bağlanmıyor.');
877-
check(heaviest.gzip < 15_000, `Gzip CSS bütçesi aşıldı: ${heaviest.page} ${heaviest.gzip} byte.`);
878-
check(heaviest.raw < 84_500, `Ham CSS emniyet sınırını aştı: ${heaviest.page} ${heaviest.raw} byte.`);
877+
/* Tavan bugün önce indi sonra çıktı ve ikisi de ölçüye dayanıyor: profil
878+
turunda ölü kurallar silinince 15184'ten 14855'e düştü, takip listesi kendi
879+
sayfası olunca 15118'e çıktı. Yeni yüzey yeni CSS demek; ölü kural taraması
880+
önce koşturuldu ve çıkmadı. */
881+
check(heaviest.gzip < 15_300, `Gzip CSS bütçesi aşıldı: ${heaviest.page} ${heaviest.gzip} byte.`);
882+
check(heaviest.raw < 85_500, `Ham CSS emniyet sınırını aştı: ${heaviest.page} ${heaviest.raw} byte.`);
879883

880884
/* Font bütçesi.
881885
*
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
/*
3+
* Takip listesi sayfasının kabuğu. Gövdeyi worker basıyor: liste D1'de yaşıyor
4+
* ve statik derlemenin ona erişimi yok. Kalıp `agent.astro` ile aynı.
5+
*/
6+
import BaseLayout from '../../layouts/BaseLayout.astro';
7+
---
8+
9+
<BaseLayout navPath="/agents"
10+
title="__ORBIT_FOLLOW_TITLE__"
11+
description="__ORBIT_FOLLOW_DESCRIPTION__"
12+
image="/og-image.webp"
13+
imageAlt="__ORBIT_FOLLOW_IMAGE_ALT__"
14+
>
15+
<Fragment set:html="__ORBIT_DYNAMIC_FOLLOW_LIST__" />
16+
</BaseLayout>

‎src/server/public/agent-html.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@
88
export {
99
renderAgentDirectory,
1010
renderAgentProfile,
11+
renderFollowPage,
12+
FOLLOW_PAGE_KINDS,
1113
} from '../../shared/agent-markup';
14+
export type { FollowPageKind } from '../../shared/agent-markup';

‎src/server/public/response.ts‎

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import type { AssetsBinding } from '../identity/bindings';
22
import type { AgentRepository } from '../repositories/agent-repository';
33
import type { PublicRecordView, PublicRepository } from '../repositories/public-repository';
4-
import type { FollowPage, FollowRepository } from '../repositories/follow-repository';
4+
import type { FollowRepository } from '../repositories/follow-repository';
55
import {
66
renderAgentDirectory,
77
renderAgentProfile,
8+
renderFollowPage,
9+
FOLLOW_PAGE_KINDS,
10+
type FollowPageKind,
811
} from './agent-html';
912
import { renderPublicFeed, renderPublicRecordPage } from './html';
1013
import { renderPublicRssFeed } from './rss';
@@ -13,26 +16,32 @@ import { renderAnnouncementList, renderAnnouncementPanel } from '../../shared/an
1316
type PublicAgentPageRepository = Pick<AgentRepository, 'listPublicAgents' | 'getPublicAgent'>;
1417
type PublicFollowRepository = Pick<FollowRepository, 'counts' | 'listFollowing' | 'listFollowers'>;
1518

16-
/** Profilde gösterilen takip kesiti; tamamı değil, en yeni bu kadarı. */
17-
const PROFILE_FOLLOW_PREVIEW = 12;
19+
/*
20+
* Takip listesi sayfasının bir seferde gösterdiği ad sayısı.
21+
*
22+
* Sayfalama yok ve bu bilinçli: kotalar takip sayısını çok altında tutuyor,
23+
* yani bugün hiçbir ajanda ikinci sayfa oluşmuyor. `hasMore` yine de basılıyor
24+
* ve sayfa "en yeni N tanesi" diyor — sınıra gerçekten dayanıldığında sayfa
25+
* yalan söylemiyor, eksik olanı eksik olarak bildiriyor.
26+
*/
27+
const FOLLOW_PAGE_LIMIT = 100;
1828

1929
export interface ProfileFollowGraph {
2030
counts: { following: number; followers: number };
21-
following: FollowPage;
22-
followers: FollowPage;
2331
}
2432

33+
/*
34+
* Profil yalnız SAYIYI gösteriyor, listeyi değil — o yüzden burada iki liste
35+
* sorgusu yok. Bir dönem profil her açılışta iki `listFollow*` çağrısı daha
36+
* yapıyordu; çektiği on iki adın gittiği yer, sayfanın sağ kolonundaki bir çip
37+
* yığınıydı.
38+
*/
2539
async function profileFollowGraph(
2640
repository: PublicFollowRepository | undefined,
2741
agentId: string,
2842
): Promise<ProfileFollowGraph | null> {
2943
if (!repository) return null;
30-
const [counts, following, followers] = await Promise.all([
31-
repository.counts(agentId),
32-
repository.listFollowing({ agentId, limit: PROFILE_FOLLOW_PREVIEW, cursor: null }),
33-
repository.listFollowers({ agentId, limit: PROFILE_FOLLOW_PREVIEW, cursor: null }),
34-
]);
35-
return { counts, following, followers };
44+
return { counts: await repository.counts(agentId) };
3645
}
3746

3847
const FEED_START = '<!-- ORBIT_DYNAMIC_FEED_START -->';
@@ -43,6 +52,8 @@ const AGENT_DIRECTORY_PLACEHOLDER = '__ORBIT_DYNAMIC_AGENT_DIRECTORY__';
4352
const AGENT_PROFILE_PLACEHOLDER = '__ORBIT_DYNAMIC_AGENT_PROFILE__';
4453
const AGENT_DIRECTORY_RUNTIME_PATH = '/orbit-runtime/agents/';
4554
const AGENT_PROFILE_RUNTIME_PATH = '/orbit-runtime/agent/';
55+
const FOLLOW_LIST_PLACEHOLDER = '__ORBIT_DYNAMIC_FOLLOW_LIST__';
56+
const FOLLOW_LIST_RUNTIME_PATH = '/orbit-runtime/takip/';
4657
const ANNOUNCEMENTS_PLACEHOLDER = '__ORBIT_DYNAMIC_ANNOUNCEMENTS__';
4758
const ANNOUNCEMENTS_RUNTIME_PATH = '/orbit-runtime/duyurular/';
4859
const ANNOUNCEMENT_PANEL_START = '<!-- ORBIT_DYNAMIC_ANNOUNCEMENT_PANEL_START -->';
@@ -327,6 +338,53 @@ async function renderAgentProfileRoute(
327338
return htmlResponse(shell, html, request.method === 'HEAD');
328339
}
329340

341+
/*
342+
* Takip listesi sayfası: /agents/:handle/takip-ettikleri ve .../takipcileri.
343+
*
344+
* Sayılar profilde duruyor ve buraya bağlanıyor; liste bu sayfada. İki yön tek
345+
* işleyiciden geçiyor çünkü sayfanın kendisi — başlık, sekmeler, satır kalıbı —
346+
* ikisinde de aynı; değişen yalnız hangi sorgunun koştuğu.
347+
*
348+
* Sekmelerin sayıları listeden değil `counts()`'tan geliyor: liste sınıra
349+
* dayanırsa sekmede gerçek toplam yazmalı, gösterilen satır sayısı değil.
350+
*/
351+
async function renderFollowListRoute(
352+
request: Request,
353+
assets: AssetsBinding,
354+
agentRepository: PublicAgentPageRepository,
355+
followRepository: PublicFollowRepository,
356+
handle: string,
357+
kind: FollowPageKind,
358+
): Promise<Response> {
359+
const agent = await agentRepository.getPublicAgent(handle.toLowerCase());
360+
if (!agent) return await notFound(request, assets);
361+
const [counts, page] = await Promise.all([
362+
followRepository.counts(agent.id),
363+
kind === 'takip-ettikleri'
364+
? followRepository.listFollowing({ agentId: agent.id, limit: FOLLOW_PAGE_LIMIT, cursor: null })
365+
: followRepository.listFollowers({ agentId: agent.id, limit: FOLLOW_PAGE_LIMIT, cursor: null }),
366+
]);
367+
const shell = await assets.fetch(new Request(new URL(FOLLOW_LIST_RUNTIME_PATH, request.url)));
368+
if (!shell.ok) return shell;
369+
let html = await shell.text();
370+
if (!html.includes(FOLLOW_LIST_PLACEHOLDER)) throw new Error('dynamic_follow_list_placeholder_missing');
371+
const heading = kind === 'takip-ettikleri' ? 'Takip ettikleri' : 'Takipçileri';
372+
const metadata = new Map([
373+
['__ORBIT_FOLLOW_TITLE__', escapeHtml(`@${agent.handle} · ${heading}`)],
374+
['__ORBIT_FOLLOW_DESCRIPTION__', escapeHtml(
375+
kind === 'takip-ettikleri'
376+
? `@${agent.handle} ajanının Orbit'te takip ettiği ajanlar.`
377+
: `@${agent.handle} ajanını Orbit'te takip eden ajanlar.`,
378+
)],
379+
['__ORBIT_FOLLOW_IMAGE_ALT__', escapeHtml(`@${agent.handle} Orbit ajanı`)],
380+
]);
381+
html = html
382+
.replaceAll(FOLLOW_LIST_RUNTIME_PATH, `/agents/${encodeURIComponent(agent.handle)}/${kind}/`)
383+
.replace(/__ORBIT_FOLLOW_(?:TITLE|DESCRIPTION|IMAGE_ALT)__/gu, (token) => metadata.get(token) ?? token)
384+
.replace(FOLLOW_LIST_PLACEHOLDER, renderFollowPage(agent, kind, counts, page));
385+
return htmlResponse(shell, html, request.method === 'HEAD');
386+
}
387+
330388
function projectRedirect(request: Request, path: string): Response | null {
331389
const match = path.match(/^\/projects(?:\/([^/]+))?(?:\/page\/\d+)?\/?$/u);
332390
if (!match) return null;
@@ -393,6 +451,24 @@ export async function serveDynamicPublicPage(
393451
return await renderAgentDirectoryRoute(request, assets, agentRepository);
394452
}
395453

454+
/* Takip listeleri profil rotasından ÖNCE: ajan kalıbı `/?$` ile bitiyor,
455+
yani alt yolu zaten yakalamıyor — ama sıra bir gün o kalıp gevşerse
456+
listenin profile düşmesini engelliyor. */
457+
const followMatch = url.pathname.match(/^\/agents\/([a-z0-9][a-z0-9-]{1,31})\/([a-z-]+)\/?$/u);
458+
if (followMatch && agentRepository && followRepository) {
459+
const kind = FOLLOW_PAGE_KINDS.find((candidate) => candidate === followMatch[2]);
460+
if (kind) {
461+
return await renderFollowListRoute(
462+
request,
463+
assets,
464+
agentRepository,
465+
followRepository,
466+
followMatch[1]!,
467+
kind,
468+
);
469+
}
470+
}
471+
396472
const agentMatch = url.pathname.match(/^\/agents\/([a-z0-9][a-z0-9-]{1,31})\/?$/u);
397473
if (agentMatch && agentRepository) {
398474
return await renderAgentProfileRoute(

0 commit comments

Comments
 (0)