CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat(nav): IA restructure — every page findable, one deployments URL, an honest AI menu #5558

MergedXSccantynz wants to mergefeat/nav-iamainopened 5d ago
19 changed files+629−288
Modifiedscripts/nav-audit.ts+1−1View fileUnifiedSplit
181181 ["More:Contributors", "/contributors"],
182182 ["More:Pulse", "/pulse"],
183183 ["More:Gates", "/gates"],
184 ["More:Deployments", "/cloud-deployments"],
184 ["More:Deployments", "/deployments"],
185185 ["More:Pipeline", "/pipeline"],
186186 ["More:Agents", "/agents"],
187187 ["More:Traffic", "/traffic"],
Modifiedsrc/__tests__/repo-nav-coherence.test.ts+226−1View fileUnifiedSplit
2121 REPO_NAV_PRIMARY,
2222 REPO_NAV_AI_GROUPS,
2323 REPO_NAV_MORE,
24 REPO_NAV_MORE_GROUPS,
2425 REPO_NAV_SETTINGS,
2526 INSIGHTS_SUBNAV_ITEMS,
2627 type RepoNavKey,
172173 });
173174 }
174175
175 it("deployments.tsx mounts RepoNav on BOTH the /deployments and /cloud-deployments pages", async () => {
176 it("deployments.tsx mounts RepoNav on both the list and detail pages", async () => {
177 // /cloud-deployments no longer renders a page at all — it 301s to
178 // /deployments (see the unification suite below) — so the two mounts
179 // are the deployments list and the per-deploy detail page.
176180 const src = await Bun.file("src/routes/deployments.tsx").text();
177181 const n = (src.match(/<RepoNav [^>]*active="deployments"/g) ?? []).length;
178182 expect(n).toBeGreaterThanOrEqual(2);
201205 });
202206 }
203207});
208
209// ═══ 2026-08-27 IA restructure — the seven flow-audit findings ══════════════
210
211// ─── (d) formerly orphaned pages live in the grouped More menu ──────────────
212
213describe("More menu grouping and formerly orphaned pages", () => {
214 it("REPO_NAV_MORE_GROUPS carries the four groups, in order", () => {
215 expect(REPO_NAV_MORE_GROUPS.map((g) => g.heading)).toEqual([
216 "Repository",
217 "Planning",
218 "Delivery",
219 "Activity",
220 ]);
221 });
222
223 it("REPO_NAV_MORE is exactly the flattened groups (derived, not a second list)", () => {
224 expect(REPO_NAV_MORE).toEqual(
225 REPO_NAV_MORE_GROUPS.flatMap((g) => [...g.items])
226 );
227 });
228
229 // The sixteen pages the flow audit found reachable only by typed URL.
230 // Eleven gained nav entries; the other five are deliberate exclusions
231 // (asserted further down) — /coupling and /dependencies live on the
232 // Insights hub, /timeline/:ref is file-scoped, /rollback is POST-only,
233 // /memory is demo-gated and 404s in prod (retirement candidate).
234 const RESTORED: Array<{ key: RepoNavKey; path: string }> = [
235 { key: "branches", path: "/branches" },
236 { key: "stale-branches", path: "/branches/stale" },
237 { key: "tags", path: "/tags" },
238 { key: "symbols", path: "/symbols" },
239 { key: "milestones", path: "/milestones" },
240 { key: "queue", path: "/queue" },
241 { key: "merge-verify", path: "/merges/verify" },
242 { key: "pushes", path: "/pushes" },
243 { key: "health", path: "/health" },
244 { key: "moderation", path: "/comments/pending" },
245 { key: "claude", path: "/claude" },
246 ];
247
248 it("every restored page has a canonical item with its real path", () => {
249 for (const r of RESTORED) {
250 const item = allNavItems.find((it_) => it_.key === r.key);
251 expect(`${r.key}:${item?.path}`).toBe(`${r.key}:${r.path}`);
252 }
253 });
254
255 it("the rendered More panel shows the group headings", () => {
256 const html = renderRepoNav("code");
257 for (const g of REPO_NAV_MORE_GROUPS) {
258 expect(html).toContain(`<div class="repo-nav-menu-heading">${g.heading}</div>`);
259 }
260 });
261
262 it("each restored page passes its own active key at its JSX call site", async () => {
263 const CASES: Array<[string, RepoNavKey, number]> = [
264 ["src/routes/web.tsx", "branches", 1],
265 ["src/routes/web.tsx", "tags", 1],
266 ["src/routes/stale-branches.tsx", "stale-branches", 1],
267 ["src/routes/symbols.tsx", "symbols", 3],
268 ["src/routes/milestones.tsx", "milestones", 4],
269 ["src/routes/merge-queue.tsx", "queue", 1],
270 ["src/routes/merge-verify.tsx", "merge-verify", 1],
271 ["src/routes/dashboard.tsx", "pushes", 1],
272 ["src/routes/health.tsx", "health", 1],
273 ["src/routes/comment-moderation.tsx", "moderation", 1],
274 ["src/routes/claude-web.tsx", "claude", 1],
275 ];
276 for (const [file, key, min] of CASES) {
277 const src = await Bun.file(file).text();
278 const n = (src.match(new RegExp(`<RepoNav[\\s\\S]{0,200}?active="${key}"`, "g")) ?? [])
279 .length;
280 expect(`${file}:${key}:${n >= min}`).toBe(`${file}:${key}:true`);
281 }
282 });
283
284 it("health.tsx no longer carries the hand-rolled HealthNav fork", async () => {
285 const src = await Bun.file("src/routes/health.tsx").text();
286 expect(src).not.toContain("const HealthNav");
287 });
288
289 it("the deliberate exclusions stay reachable where they belong", async () => {
290 // /coupling and /dependencies are Insights sub-reports; the hub's card
291 // grid is their entry point. If these links go, they are orphaned again.
292 const src = await Bun.file("src/routes/insights.tsx").text();
293 expect(src).toContain("${base}/coupling");
294 expect(src).toContain("${base}/dependencies");
295 // …and they must NOT gain competing top-nav entries.
296 const paths = allNavItems.map((it_) => it_.path);
297 expect(paths).not.toContain("/coupling");
298 expect(paths).not.toContain("/dependencies");
299 expect(paths).not.toContain("/memory");
300 });
301});
302
303// ─── (e) one deployments page ───────────────────────────────────────────────
304
305describe("deployments unification", () => {
306 it("the nav's Deployments item points at /deployments", () => {
307 const item = allNavItems.find((it_) => it_.key === "deployments");
308 expect(item?.path).toBe("/deployments");
309 });
310
311 it("/cloud-deployments is a permanent (301) redirect to /deployments", async () => {
312 const src = await Bun.file("src/routes/deployments.tsx").text();
313 const idx = src.indexOf('dep.get("/:owner/:repo/cloud-deployments"');
314 expect(idx).toBeGreaterThan(-1);
315 const handler = src.slice(idx, idx + 400);
316 expect(handler).toContain(
317 "c.redirect(`/${owner}/${repo}/deployments`, 301)"
318 );
319 });
320
321 it("/deployments absorbed the cloud runs section", async () => {
322 const src = await Bun.file("src/routes/deployments.tsx").text();
323 expect(src).toContain("listCloudRuns(repoRow.id)");
324 // The standalone cloud page's headline is gone for good.
325 expect(src).not.toContain("Cloud Deployments</h2>");
326 });
327
328 it("no route or view links to /cloud-deployments any more", async () => {
329 for (const file of [
330 "src/views/components.tsx",
331 "src/routes/pipeline.tsx",
332 "src/lib/surface-monitor.ts",
333 ]) {
334 const src = await Bun.file(file).text();
335 expect(`${file}:${src.includes("/cloud-deployments\",") || src.includes("cloud-deployments`")}`).toBe(
336 `${file}:false`
337 );
338 }
339 });
340});
341
342// ─── (f) exactly one /workspace registration ────────────────────────────────
343
344describe("single /workspace registration", () => {
345 it("only workspace-hub.tsx registers /:owner/:repo/workspace", async () => {
346 const glob = new Bun.Glob("src/routes/*.tsx");
347 const registrars: string[] = [];
348 for await (const file of glob.scan(".")) {
349 const src = await Bun.file(file).text();
350 if (/\.get\(\s*"\/:owner\/:repo\/workspace"/.test(src)) {
351 registrars.push(file.replace(/\\/g, "/"));
352 }
353 }
354 expect(registrars).toEqual(["src/routes/workspace-hub.tsx"]);
355 });
356});
357
358// ─── (g) AI menu honesty when no API key is configured ──────────────────────
359
360describe("AI menu honesty", () => {
361 const navWith = (aiAvailable: boolean) =>
362 render(
363 RepoNav({
364 owner: "o",
365 repo: "r",
366 active: "code",
367 currentUser: "o",
368 repoOwner: "o",
369 aiAvailable,
370 })
371 );
372
373 it("with AI unavailable: one banner, items muted but still rendered", () => {
374 const html = navWith(false);
375 expect(html).toContain("AI features are not enabled on this instance");
376 expect(html).toContain("/admin/integrations");
377 expect(html).toContain("is-muted");
378 // Every AI destination is still a real link — muted, never hidden.
379 for (const g of REPO_NAV_AI_GROUPS) {
380 for (const it_ of g.items) {
381 expect(html).toContain(`href="/o/r${it_.path.replace(/&/g, "&amp;")}"`);
382 }
383 }
384 });
385
386 it("with AI available: no banner, nothing muted", () => {
387 const html = navWith(true);
388 expect(html).not.toContain("AI features are not enabled");
389 expect(html).not.toContain("is-muted");
390 });
391});
392
393// ─── (h) sub-pages keep the nav; the row stays scrollable on phones ─────────
394
395describe("sub-page chrome and responsive behaviour", () => {
396 it("every wiki page pairs RepoHeader with RepoNav (7 of 7)", async () => {
397 const src = await Bun.file("src/routes/wikis.tsx").text();
398 const headers = (src.match(/<RepoHeader /g) ?? []).length;
399 const navs = (src.match(/<RepoNav [^>]*active="wiki"/g) ?? []).length;
400 expect(headers).toBe(7);
401 expect(navs).toBe(7);
402 });
403
404 it("the tab row scrolls horizontally; the grouped panels wrap", async () => {
405 const layout = await Bun.file("src/views/layout.tsx").text();
406 const nav = layout.slice(
407 layout.indexOf(".repo-nav {"),
408 layout.indexOf(".repo-nav::-webkit-scrollbar")
409 );
410 expect(nav).toContain("overflow-x: auto");
411 expect(nav).not.toContain("flex-wrap");
412 const anchor = layout.slice(
413 layout.indexOf(".repo-nav a {"),
414 layout.indexOf(".repo-nav a:hover")
415 );
416 expect(anchor).toContain("white-space: nowrap");
417 // The grouped dropdown panel wraps so four More groups + the AI banner
418 // fold instead of overflowing narrow viewports.
419 const grouped = layout.slice(
420 layout.indexOf(".repo-nav-menu-panel-grouped {"),
421 layout.indexOf(".repo-nav-menu-group {")
422 );
423 expect(grouped).toContain("flex-wrap: wrap");
424 expect(grouped).toContain("max-width");
425 expect(layout).toContain(".repo-nav-ai-banner");
426 expect(layout).toContain(".repo-nav-menu-panel a.is-muted");
427 });
428});
Modifiedsrc/__tests__/ship-path-ai-independence.test.ts+3−1View fileUnifiedSplit
137137 expect(src).toContain("disabled={!aiRepairPossible}");
138138 expect(src).toContain("AI unavailable on this host — see manual steps");
139139 // The manual block lists file:line per rule — reads the finding, no AI.
140 const block = src.slice(src.indexOf("const ManualFixBlock"), src.indexOf("const HealthNav"));
140 // Anchor: HealthNav (the old hand-rolled nav fork) was replaced by the
141 // canonical RepoNav on 2026-08-27; SecurityGroup is the next declaration.
142 const block = src.slice(src.indexOf("const ManualFixBlock"), src.indexOf("interface SecurityGroup"));
141143 expect(block).toContain("issues.filter((i) => i.rule === imp.rule)");
142144 expect(block).not.toContain("getAnthropic");
143145 // The POST handler refuses honestly instead of 500-ing deep in createSpecPR.
Modifiedsrc/lib/surface-monitor.ts+1−1View fileUnifiedSplit
107107 "/contributors",
108108 "/pulse",
109109 "/gates",
110 "/cloud-deployments",
110 "/deployments",
111111 "/pipeline",
112112 "/agents",
113113 "/traffic",
Modifiedsrc/routes/claude-web.tsx+1−1View fileUnifiedSplit
107107 return c.html(
108108 <Layout title={`Claude — ${g.ownerName}/${g.repoName}`} user={user}>
109109 <RepoHeader owner={g.ownerName} repo={g.repoName} />
110 <RepoNav owner={g.ownerName} repo={g.repoName} active="chat" />
110 <RepoNav owner={g.ownerName} repo={g.repoName} active="claude" />
111111 <main style={wrap}>
112112 <h1 style="margin:0 0 4px;font-size:22px">✨ Claude Code Sessions</h1>
113113 <p style="margin:0 0 20px;color:#9ca3af;font-size:14px">
Modifiedsrc/routes/comment-moderation.tsx+8−1View fileUnifiedSplit
2222import { db } from "../db";
2323import { repositories, users } from "../db/schema";
2424import { Layout } from "../views/layout";
25import { RepoHeader } from "../views/components";
25import { RepoHeader, RepoNav } from "../views/components";
2626import { softAuth, requireAuth } from "../middleware/auth";
2727import type { AuthEnv } from "../middleware/auth";
2828import { requireRepoAccess } from "../middleware/repo-access";
217217 return c.html(
218218 <Layout title={`Pending comments — ${ownerName}/${repoName}`} user={user}>
219219 <RepoHeader owner={ownerName} repo={repoName} />
220 <RepoNav
221 owner={ownerName}
222 repo={repoName}
223 active="moderation"
224 currentUser={user.username}
225 repoOwner={ownerName}
226 />
220227 <style dangerouslySetInnerHTML={{ __html: QUEUE_STYLES }} />
221228 <div class="modq-shell">
222229 <div class="modq-head">
Modifiedsrc/routes/dashboard.tsx+9−0View fileUnifiedSplit
2828 pullRequests,
2929} from "../db/schema";
3030import { Layout } from "../views/layout";
31import { RepoHeader, RepoNav } from "../views/components";
3132import { LiveFeed } from "../views/live-feed";
3233import { softAuth, requireAuth } from "../middleware/auth";
3334import type { AuthEnv } from "../middleware/auth";
11541155
11551156 return c.html(
11561157 <Layout title={`Push Log — ${owner}/${repo}`} user={user}>
1158 <RepoHeader owner={owner} repo={repo} />
1159 <RepoNav
1160 owner={owner}
1161 repo={repo}
1162 active="pushes"
1163 currentUser={user?.username || null}
1164 repoOwner={owner}
1165 />
11571166 <div style="max-width: 900px">
11581167 <h2 style="margin-bottom: 4px">Push Log</h2>
11591168 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
Modifiedsrc/routes/deployments.tsx+204−189View fileUnifiedSplit
33 *
44 * Routes:
55 * GET /:owner/:repo/deployments full deploy history per env
6 * + cloud provider deploy runs
67 * GET /:owner/:repo/deployments/:id single deployment detail
8 * GET /:owner/:repo/cloud-deployments 301 → /deployments
79 *
810 * Data comes from the `deployments` table populated by Crontech / gate
9 * logic on successful push to the default branch.
11 * logic on successful push to the default branch, plus `cloud_deployments`
12 * (provider-triggered runs, migration 0077).
13 *
14 * 2026-08-27 unification: /deployments and /cloud-deployments were two
15 * competing pages in this file — the nav pointed at cloud-deployments while
16 * ten in-code references pointed at /deployments, so neither read as
17 * authoritative. /deployments is now the single deployments surface (it
18 * owns the detail pages and every deep link); the cloud runs render as a
19 * section on it and /cloud-deployments 301s here permanently.
1020 *
1121 * 2026 polish:
1222 * - Page-level eyebrow + display headline + subtitle.
483493 return { last, successRate: rate };
484494}
485495
496/** One provider-triggered cloud deploy run, joined with its config. */
497type CloudRun = {
498 id: string;
499 configId: string;
500 commitSha: string;
501 status: string;
502 providerDeployId: string | null;
503 logUrl: string | null;
504 deployUrl: string | null;
505 errorMessage: string | null;
506 startedAt: Date | null;
507 completedAt: Date | null;
508 durationMs: number | null;
509 provider: string;
510 providerAppId: string;
511 triggerBranch: string;
512};
513
514async function listCloudRuns(repoId: string): Promise<CloudRun[]> {
515 try {
516 return await db
517 .select({
518 id: cloudDeployments.id,
519 configId: cloudDeployments.configId,
520 commitSha: cloudDeployments.commitSha,
521 status: cloudDeployments.status,
522 providerDeployId: cloudDeployments.providerDeployId,
523 logUrl: cloudDeployments.logUrl,
524 deployUrl: cloudDeployments.deployUrl,
525 errorMessage: cloudDeployments.errorMessage,
526 startedAt: cloudDeployments.startedAt,
527 completedAt: cloudDeployments.completedAt,
528 durationMs: cloudDeployments.durationMs,
529 provider: cloudDeployConfigs.provider,
530 providerAppId: cloudDeployConfigs.providerAppId,
531 triggerBranch: cloudDeployConfigs.triggerBranch,
532 })
533 .from(cloudDeployments)
534 .innerJoin(
535 cloudDeployConfigs,
536 eq(cloudDeployments.configId, cloudDeployConfigs.id)
537 )
538 .where(eq(cloudDeployments.repoId, repoId))
539 .orderBy(desc(cloudDeployments.startedAt))
540 .limit(100);
541 } catch (err) {
542 console.error("[cloud-deployments] list:", err);
543 return [];
544 }
545}
546
486547dep.get("/:owner/:repo/deployments", async (c) => {
487548 const { owner, repo } = c.req.param();
488549 const user = c.get("user");
518579 const envs = groupByEnv(rows);
519580 const envNames = Object.keys(envs).sort();
520581
582 // Cloud provider runs — previously a competing standalone page at
583 // /cloud-deployments; now a section of the one deployments surface.
584 const cloudRuns = await listCloudRuns(repoRow.id);
585 const isRepoOwner = user?.id === repoRow.ownerId;
586
521587 return c.html(
522588 <Layout title={`${owner}/${repo} — deployments`} user={user}>
523589 <RepoHeader owner={owner} repo={repo} />
616682 })}
617683 </div>
618684 )}
685
686 {/* ─── Cloud provider deploys (Fly / Railway / Render / …) ─── */}
687 <div
688 class="cds-head"
689 style="display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-top:var(--space-6);"
690 >
691 <div>
692 <h3 class="cds-card-title" style="font-size:16px;">Cloud deploys</h3>
693 <p class="cds-sub" style="margin:2px 0 0;">
694 Push-triggered deploys to configured cloud providers.
695 </p>
696 </div>
697 {isRepoOwner && (
698 <a
699 href={`/${owner}/${repo}/settings/deployments`}
700 class="cds-btn cds-btn-ghost"
701 style="text-decoration:none;"
702 >
703 Configure integrations
704 </a>
705 )}
706 </div>
707 <div class="cds-card">
708 {cloudRuns.length === 0 ? (
709 <div class="cds-empty">
710 No cloud deployments yet.{" "}
711 {isRepoOwner ? (
712 <a
713 href={`/${owner}/${repo}/settings/deployments`}
714 class="cds-link"
715 >
716 Add an integration
717 </a>
718 ) : (
719 "Ask the repo owner to configure a cloud deploy integration."
720 )}
721 </div>
722 ) : (
723 <div class="cds-runs">
724 {cloudRuns.map((run) => {
725 const durMs = run.durationMs;
726 const dur =
727 durMs != null
728 ? durMs >= 60_000
729 ? `${Math.round(durMs / 60_000)}m ${Math.round((durMs % 60_000) / 1000)}s`
730 : `${Math.round(durMs / 1000)}s`
731 : null;
732 const isRunning =
733 run.status === "running" || run.status === "pending";
734 return (
735 <div class="cds-run-row">
736 <span class={cdStatusClass(run.status)}>
737 {isRunning ? (
738 <span class="cds-spinner" aria-label="deploying" />
739 ) : null}
740 {run.status}
741 </span>
742 <code class="cds-sha">{run.commitSha.slice(0, 7)}</code>
743 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;">
744 <span class="cds-badge">
745 {PROVIDER_LABELS[run.provider] ?? run.provider}
746 </span>
747 <span
748 class="cds-mono"
749 style="font-size:12px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
750 >
751 {run.providerAppId}
752 </span>
753 {run.deployUrl && run.status === "success" && (
754 <a
755 href={run.deployUrl}
756 target="_blank"
757 rel="noopener noreferrer"
758 class="cds-link"
759 >
760 {run.deployUrl
761 .replace(/^https?:\/\//, "")
762 .slice(0, 40)}
763 </a>
764 )}
765 {run.status === "failed" && run.errorMessage && (
766 <span
767 style="font-size:11.5px;color:var(--red);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
768 title={run.errorMessage}
769 >
770 {run.errorMessage.slice(0, 60)}
771 </span>
772 )}
773 </div>
774 <span style="font-size:12px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums;">
775 {dur
776 ? run.status === "success"
777 ? `Deployed in ${dur}`
778 : run.status === "failed"
779 ? `Failed after ${dur}`
780 : dur
781 : dkRelativeTime(run.startedAt)}
782 </span>
783 <div style="display:flex;gap:6px;align-items:center;">
784 {run.logUrl && (
785 <a
786 href={run.logUrl}
787 target="_blank"
788 rel="noopener noreferrer"
789 class="cds-link"
790 >
791 Logs
792 </a>
793 )}
794 </div>
795 </div>
796 );
797 })}
798 </div>
799 )}
800 </div>
619801 </div>
620802 <style dangerouslySetInnerHTML={{ __html: deployStyles }} />
803 <style dangerouslySetInnerHTML={{ __html: cloudDeploySettingsStyles }} />
621804 </Layout>
622805 );
623806});
666849 user={user}
667850 >
668851 <RepoHeader owner={owner} repo={repo} />
852 <RepoNav owner={owner} repo={repo} active="deployments" />
669853 <div class="dk-detail-wrap">
670854 <div class="dk-bread">
671855 <a href={`/${owner}/${repo}/deployments`}>deployments</a>
9311115 return c.html(
9321116 <Layout title={`${owner}/${repo} — cloud deploy settings`} user={user}>
9331117 <RepoHeader owner={owner} repo={repo} />
1118 <RepoNav
1119 owner={owner}
1120 repo={repo}
1121 active="settings"
1122 currentUser={user.username}
1123 repoOwner={owner}
1124 />
9341125 <div class="cds-wrap">
9351126 <header class="cds-head">
9361127 <h2 class="cds-title">Cloud Deploy Integrations</h2>
10731264
10741265 <div style="text-align:center;margin-top:var(--space-3);">
10751266 <a
1076 href={`/${owner}/${repo}/cloud-deployments`}
1267 href={`/${owner}/${repo}/deployments`}
10771268 class="cds-link"
10781269 >
10791270 View deployment history &rarr;
11741365 }
11751366);
11761367
1177/** GET /:owner/:repo/cloud-deployments — list recent cloud deploy runs */
1368/**
1369 * GET /:owner/:repo/cloud-deployments — permanent redirect.
1370 *
1371 * This was a second, competing deployments page. The cloud runs it showed
1372 * now live as a section on /deployments (the page that owns the detail
1373 * views and every in-code deep link), so this URL 301s there to keep old
1374 * bookmarks and external links working. The app-level private-repo gate
1375 * runs before this handler, so private repos still 404 to strangers.
1376 */
11781377dep.get("/:owner/:repo/cloud-deployments", softAuth, async (c) => {
11791378 const { owner, repo } = c.req.param();
1180 const user = c.get("user");
1181 const repoRow = await resolveRepo(owner, repo);
1182 if (!repoRow) return c.notFound();
1183
1184 let runs: Array<{
1185 id: string;
1186 configId: string;
1187 commitSha: string;
1188 status: string;
1189 providerDeployId: string | null;
1190 logUrl: string | null;
1191 deployUrl: string | null;
1192 errorMessage: string | null;
1193 startedAt: Date | null;
1194 completedAt: Date | null;
1195 durationMs: number | null;
1196 provider: string;
1197 providerAppId: string;
1198 triggerBranch: string;
1199 }> = [];
1200 try {
1201 runs = await db
1202 .select({
1203 id: cloudDeployments.id,
1204 configId: cloudDeployments.configId,
1205 commitSha: cloudDeployments.commitSha,
1206 status: cloudDeployments.status,
1207 providerDeployId: cloudDeployments.providerDeployId,
1208 logUrl: cloudDeployments.logUrl,
1209 deployUrl: cloudDeployments.deployUrl,
1210 errorMessage: cloudDeployments.errorMessage,
1211 startedAt: cloudDeployments.startedAt,
1212 completedAt: cloudDeployments.completedAt,
1213 durationMs: cloudDeployments.durationMs,
1214 provider: cloudDeployConfigs.provider,
1215 providerAppId: cloudDeployConfigs.providerAppId,
1216 triggerBranch: cloudDeployConfigs.triggerBranch,
1217 })
1218 .from(cloudDeployments)
1219 .innerJoin(
1220 cloudDeployConfigs,
1221 eq(cloudDeployments.configId, cloudDeployConfigs.id)
1222 )
1223 .where(eq(cloudDeployments.repoId, repoRow.id))
1224 .orderBy(desc(cloudDeployments.startedAt))
1225 .limit(100);
1226 } catch (err) {
1227 console.error("[cloud-deployments] list:", err);
1228 }
1229
1230 return c.html(
1231 <Layout title={`${owner}/${repo} — cloud deployments`} user={user}>
1232 <RepoHeader owner={owner} repo={repo} />
1233 <RepoNav owner={owner} repo={repo} active="deployments" />
1234 <div class="cds-wrap">
1235 <header
1236 class="cds-head"
1237 style="display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;"
1238 >
1239 <div>
1240 <h2 class="cds-title">Cloud Deployments</h2>
1241 <p class="cds-sub">
1242 Recent push-triggered deploys across all configured integrations.
1243 </p>
1244 </div>
1245 {user?.id === repoRow.ownerId && (
1246 <a
1247 href={`/${owner}/${repo}/settings/deployments`}
1248 class="cds-btn cds-btn-ghost"
1249 style="text-decoration:none;"
1250 >
1251 Configure integrations
1252 </a>
1253 )}
1254 </header>
1255
1256 <div class="cds-card">
1257 {runs.length === 0 ? (
1258 <div class="cds-empty">
1259 No cloud deployments yet.{" "}
1260 {user?.id === repoRow.ownerId ? (
1261 <a
1262 href={`/${owner}/${repo}/settings/deployments`}
1263 class="cds-link"
1264 >
1265 Add an integration
1266 </a>
1267 ) : (
1268 "Ask the repo owner to configure a cloud deploy integration."
1269 )}
1270 </div>
1271 ) : (
1272 <div class="cds-runs">
1273 {runs.map((run) => {
1274 const durMs = run.durationMs;
1275 const dur =
1276 durMs != null
1277 ? durMs >= 60_000
1278 ? `${Math.round(durMs / 60_000)}m ${Math.round((durMs % 60_000) / 1000)}s`
1279 : `${Math.round(durMs / 1000)}s`
1280 : null;
1281 const isRunning =
1282 run.status === "running" || run.status === "pending";
1283 return (
1284 <div class="cds-run-row">
1285 <span class={cdStatusClass(run.status)}>
1286 {isRunning ? (
1287 <span
1288 class="cds-spinner"
1289 aria-label="deploying"
1290 />
1291 ) : null}
1292 {run.status}
1293 </span>
1294 <code class="cds-sha">
1295 {run.commitSha.slice(0, 7)}
1296 </code>
1297 <div
1298 style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;"
1299 >
1300 <span class="cds-badge">
1301 {PROVIDER_LABELS[run.provider] ?? run.provider}
1302 </span>
1303 <span
1304 class="cds-mono"
1305 style="font-size:12px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
1306 >
1307 {run.providerAppId}
1308 </span>
1309 {run.deployUrl && run.status === "success" && (
1310 <a
1311 href={run.deployUrl}
1312 target="_blank"
1313 rel="noopener noreferrer"
1314 class="cds-link"
1315 >
1316 {run.deployUrl
1317 .replace(/^https?:\/\//, "")
1318 .slice(0, 40)}
1319 </a>
1320 )}
1321 {run.status === "failed" && run.errorMessage && (
1322 <span
1323 style="font-size:11.5px;color:var(--red);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
1324 title={run.errorMessage}
1325 >
1326 {run.errorMessage.slice(0, 60)}
1327 </span>
1328 )}
1329 </div>
1330 <span
1331 style="font-size:12px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums;"
1332 >
1333 {dur
1334 ? run.status === "success"
1335 ? `Deployed in ${dur}`
1336 : run.status === "failed"
1337 ? `Failed after ${dur}`
1338 : dur
1339 : dkRelativeTime(run.startedAt)}
1340 </span>
1341 <div style="display:flex;gap:6px;align-items:center;">
1342 {run.logUrl && (
1343 <a
1344 href={run.logUrl}
1345 target="_blank"
1346 rel="noopener noreferrer"
1347 class="cds-link"
1348 >
1349 Logs
1350 </a>
1351 )}
1352 </div>
1353 </div>
1354 );
1355 })}
1356 </div>
1357 )}
1358 </div>
1359 </div>
1360 <style
1361 dangerouslySetInnerHTML={{ __html: cloudDeploySettingsStyles }}
1362 />
1363 </Layout>
1364 );
1379 return c.redirect(`/${owner}/${repo}/deployments`, 301);
13651380});
13661381
13671382/** POST /:owner/:repo/deployments/:deployId/cancel — cancel a running cloud deployment */
13721387 const { owner, repo, deployId } = c.req.param();
13731388 const user = c.get("user")!;
13741389 const repoRow = await resolveRepo(owner, repo);
1375 const back = `/${owner}/${repo}/cloud-deployments`;
1390 const back = `/${owner}/${repo}/deployments`;
13761391 if (!repoRow) return c.notFound();
13771392 if (repoRow.ownerId !== user.id) return c.redirect(back);
13781393
Modifiedsrc/routes/health.tsx+11−40View fileUnifiedSplit
279279 return c.html(
280280 <Layout title={`Health — ${owner}/${repo}`} user={user}>
281281 <RepoHeader owner={owner} repo={repo} />
282 <HealthNav owner={owner} repo={repo} active="health" />
282 <RepoNav
283 owner={owner}
284 repo={repo}
285 active="health"
286 currentUser={user?.username || null}
287 repoOwner={owner}
288 />
283289
284290 <div class="hlth-wrap">
285291 <div class="hlth-hero">
712718 );
713719};
714720
715const HealthNav = ({
716 owner,
717 repo,
718 active,
719}: {
720 owner: string;
721 repo: string;
722 active: string;
723}) => (
724 <div class="repo-nav">
725 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
726 Code
727 </a>
728 <a
729 href={`/${owner}/${repo}/issues`}
730 class={active === "issues" ? "active" : ""}
731 >
732 Issues
733 </a>
734 <a
735 href={`/${owner}/${repo}/pulls`}
736 class={active === "pulls" ? "active" : ""}
737 >
738 Pull Requests
739 </a>
740 <a
741 href={`/${owner}/${repo}/health`}
742 class={active === "health" ? "active" : ""}
743 >
744 Health
745 </a>
746 <a
747 href={`/${owner}/${repo}/commits`}
748 class={active === "commits" ? "active" : ""}
749 >
750 Commits
751 </a>
752 </div>
753);
721// The hand-rolled HealthNav that lived here (a 5-tab fork of RepoNav with
722// its own idea of the IA) was replaced by the canonical RepoNav on
723// 2026-08-27 — two nav sources of truth meant this page's tabs drifted
724// from every other repo page's. RepoNav now has a real "health" key.
754725
755726interface SecurityGroup {
756727 rule: string;
Modifiedsrc/routes/merge-queue.tsx+1−1View fileUnifiedSplit
624624 forkCount={repoRow.forkCount}
625625 currentUser={user?.username || null}
626626 />
627 <RepoNav owner={owner} repo={repo} active="pulls" />
627 <RepoNav owner={owner} repo={repo} active="queue" />
628628
629629 <div class="mq-wrap">
630630 <div class="mq-crumbs">
Modifiedsrc/routes/merge-verify.tsx+1−1View fileUnifiedSplit
397397 forkCount={repoRow.forkCount}
398398 currentUser={user?.username || null}
399399 />
400 <RepoNav owner={ownerName} repo={repoName} active="pulls" />
400 <RepoNav owner={ownerName} repo={repoName} active="merge-verify" />
401401
402402 <div class="mv-wrap">
403403 <div class="mv-crumbs">
Modifiedsrc/routes/milestones.tsx+4−4View fileUnifiedSplit
600600 <Layout title={`Milestones — ${ownerName}/${repoName}`} user={user}>
601601 <MilestonesStyle />
602602 <RepoHeader owner={ownerName} repo={repoName} />
603 <RepoNav owner={ownerName} repo={repoName} active="issues" />
603 <RepoNav owner={ownerName} repo={repoName} active="milestones" />
604604
605605 <section class="ms-hero">
606606 <div class="ms-hero-inner">
767767 <Layout title={`New milestone — ${ownerName}/${repoName}`} user={user}>
768768 <MilestonesStyle />
769769 <RepoHeader owner={ownerName} repo={repoName} />
770 <RepoNav owner={ownerName} repo={repoName} active="issues" />
770 <RepoNav owner={ownerName} repo={repoName} active="milestones" />
771771 <div class="ms-form-section" style="margin-top:24px">
772772 <div class="ms-form-card">
773773 <h1 class="ms-form-title">New milestone</h1>
935935 <Layout title={`${ms.title} — ${ownerName}/${repoName}`} user={user}>
936936 <MilestonesStyle />
937937 <RepoHeader owner={ownerName} repo={repoName} />
938 <RepoNav owner={ownerName} repo={repoName} active="issues" />
938 <RepoNav owner={ownerName} repo={repoName} active="milestones" />
939939
940940 {success && (
941941 <div class="ms-alert ms-alert-success" style="margin:12px 0">
11171117 <Layout title={`Edit milestone — ${ownerName}/${repoName}`} user={user}>
11181118 <MilestonesStyle />
11191119 <RepoHeader owner={ownerName} repo={repoName} />
1120 <RepoNav owner={ownerName} repo={repoName} active="issues" />
1120 <RepoNav owner={ownerName} repo={repoName} active="milestones" />
11211121 <div class="ms-form-section" style="margin-top:24px">
11221122 <div class="ms-form-card">
11231123 <h1 class="ms-form-title">Edit milestone</h1>
Modifiedsrc/routes/pipeline.tsx+1−1View fileUnifiedSplit
475475 <span style="color:var(--warning)">{e.blockedReason.slice(0, 80)}</span>
476476 )}
477477 {e.kind === "deploy" && (
478 <a href={`/${owner}/${repo}/cloud-deployments`}>
478 <a href={`/${owner}/${repo}/deployments`}>
479479 Details →
480480 </a>
481481 )}
Modifiedsrc/routes/stale-branches.tsx+1−1View fileUnifiedSplit
435435 forkCount={repo.forkCount}
436436 currentUser={user?.username ?? null}
437437 />
438 <RepoNav owner={ownerName} repo={repoName} active="code" />
438 <RepoNav owner={ownerName} repo={repoName} active="stale-branches" />
439439
440440 {/* Flash message */}
441441 {(deleted !== undefined || failed !== undefined) && (
Modifiedsrc/routes/symbols.tsx+3−3View fileUnifiedSplit
560560 return c.html(
561561 <Layout title={`Symbols — ${ownerName}/${repoName}`} user={user}>
562562 <RepoHeader owner={ownerName} repo={repoName} />
563 <RepoNav owner={ownerName} repo={repoName} active="code" />
563 <RepoNav owner={ownerName} repo={repoName} active="symbols" />
564564 <div class="sym-wrap">
565565 <header class="sym-head">
566566 <div class="sym-head-text">
708708 return c.html(
709709 <Layout title={`Symbol search — ${ownerName}/${repoName}`} user={user}>
710710 <RepoHeader owner={ownerName} repo={repoName} />
711 <RepoNav owner={ownerName} repo={repoName} active="code" />
711 <RepoNav owner={ownerName} repo={repoName} active="symbols" />
712712 <div class="sym-wrap">
713713 <div class="sym-crumbs">
714714 <a href={`/${ownerName}/${repoName}/symbols`}>← Back to symbols</a>
817817 return c.html(
818818 <Layout title={`${name} — ${ownerName}/${repoName}`} user={user}>
819819 <RepoHeader owner={ownerName} repo={repoName} />
820 <RepoNav owner={ownerName} repo={repoName} active="code" />
820 <RepoNav owner={ownerName} repo={repoName} active="symbols" />
821821 <div class="sym-wrap">
822822 <div class="sym-crumbs">
823823 <a href={`/${ownerName}/${repoName}/symbols`}>← Back to symbols</a>
Modifiedsrc/routes/web.tsx+7−11View fileUnifiedSplit
8787 countAiReviewsSince,
8888 listDemoActivityFeed,
8989} from "../lib/demo-activity";
90import { AgentWorkspace } from "../views/agent-workspace";
9190import { DailyBrief } from "../views/daily-brief";
9291import { TrustReport } from "../views/trust-report";
9392import { ProductionLayers } from "../views/production-layers";
31213120 return c.redirect(`/${owner}/${repo}`);
31223121});
31233122
3124// Agent workspace — GET /:owner/:repo/workspace
3125web.get("/:owner/:repo/workspace", async (c) => {
3126 const { owner, repo } = c.req.param();
3127 const user = c.get("user");
3128 const gate = await assertRepoReadable(c, owner, repo);
3129 if (gate) return gate;
3130 return c.html(<AgentWorkspace owner={owner} repo={repo} user={user} />);
3131});
3123// NOTE: /:owner/:repo/workspace is served by routes/workspace-hub.tsx (the
3124// AI Workspace Hub), mounted in app.tsx long before this router. A second
3125// registration used to live here rendering views/agent-workspace — first
3126// mount wins in Hono, so it was unreachable dead code that made "which
3127// workspace page is real?" unanswerable from the source. Do not re-add it.
31323128
31333129// Org memory — GET /:owner/:repo/memory
31343130//
49434939 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
49444940 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
49454941 <RepoHeader owner={owner} repo={repo} />
4946 <RepoNav owner={owner} repo={repo} active="code" />
4942 <RepoNav owner={owner} repo={repo} active="branches" />
49474943 <div
49484944 class="branches-wrap"
49494945 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
52705266 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
52715267 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
52725268 <RepoHeader owner={owner} repo={repo} />
5273 <RepoNav owner={owner} repo={repo} active="code" />
5269 <RepoNav owner={owner} repo={repo} active="tags" />
52745270 <div
52755271 class="tags-wrap"
52765272 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
Modifiedsrc/routes/wikis.tsx+6−0View fileUnifiedSplit
743743 return c.html(
744744 <Layout title={`Wiki pages — ${ownerName}/${repoName}`} user={user}>
745745 <RepoHeader owner={ownerName} repo={repoName} />
746 <RepoNav owner={ownerName} repo={repoName} active="wiki" />
746747 <div class="wiki-wrap">
747748 <header class="wiki-head">
748749 <div class="wiki-head-text">
831832 return c.html(
832833 <Layout title="New wiki page" user={user}>
833834 <RepoHeader owner={ownerName} repo={repoName} />
835 <RepoNav owner={ownerName} repo={repoName} active="wiki" />
834836 <div class="wiki-wrap">
835837 <header class="wiki-head">
836838 <div class="wiki-head-text">
977979 return c.html(
978980 <Layout title={`${page.title} — wiki`} user={user}>
979981 <RepoHeader owner={ownerName} repo={repoName} />
982 <RepoNav owner={ownerName} repo={repoName} active="wiki" />
980983 <div class="wiki-wrap">
981984 <header class="wiki-head">
982985 <div class="wiki-head-text">
10851088 return c.html(
10861089 <Layout title={`Edit ${page.title}`} user={user}>
10871090 <RepoHeader owner={ownerName} repo={repoName} />
1091 <RepoNav owner={ownerName} repo={repoName} active="wiki" />
10881092 <div class="wiki-wrap">
10891093 <header class="wiki-head">
10901094 <div class="wiki-head-text">
13021306 return c.html(
13031307 <Layout title={`${page.title} — history`} user={user}>
13041308 <RepoHeader owner={ownerName} repo={repoName} />
1309 <RepoNav owner={ownerName} repo={repoName} active="wiki" />
13051310 <div class="wiki-wrap">
13061311 <header class="wiki-head">
13071312 <div class="wiki-head-text">
14081413 return c.html(
14091414 <Layout title={`${rv.title} @ r${rev}`} user={user}>
14101415 <RepoHeader owner={ownerName} repo={repoName} />
1416 <RepoNav owner={ownerName} repo={repoName} active="wiki" />
14111417 <div class="wiki-wrap">
14121418 <header class="wiki-head">
14131419 <div class="wiki-head-text">
Modifiedsrc/views/components.tsx+112−31View fileUnifiedSplit
44import type { Repository } from "../db/schema";
55import { parseDiff, pairLines } from "../lib/diff";
66import type { DiffLine, ParsedFile, SplitRow } from "../lib/diff";
7import { isAiAvailable } from "../lib/ai-client";
78
89/**
910 * Describes the most recent push to a repo, used by RepoHeader to render
202203 { key: "explain", path: "/explain", label: "Explain codebase" },
203204 { key: "ask", path: "/ask", label: "Ask AI" },
204205 { key: "chat", path: "/chat", label: "Chat with repo" },
206 { key: "claude", path: "/claude", label: "Claude sessions" },
205207 { key: "nl-search", path: "/search/nl", label: "NL search" },
206208 { key: "ai-search", path: "/search?mode=semantic", label: "Semantic search" },
207209 { key: "archaeology", path: "/archaeology", label: "Archaeology" },
230232 },
231233] as const satisfies readonly RepoNavGroupDef[];
232234
233export const REPO_NAV_MORE = [
234 { key: "commits", path: "/commits", label: "Commits" },
235 { key: "discussions", path: "/discussions", label: "Discussions" },
236 { key: "wiki", path: "/wiki", label: "Wiki" },
237 { key: "projects", path: "/projects", label: "Projects" },
238 { key: "releases", path: "/releases", label: "Releases" },
239 { key: "packages", path: "/packages", label: "Packages" },
240 { key: "contributors", path: "/contributors", label: "Contributors" },
241 { key: "pulse", path: "/pulse", label: "Pulse" },
242 { key: "gates", path: "/gates", label: "Gates" },
243 { key: "deployments", path: "/cloud-deployments", label: "Deployments" },
244 { key: "pipeline", path: "/pipeline", label: "Pipeline" },
245 { key: "agents", path: "/agents", label: "Agents" },
246 { key: "traffic", path: "/traffic", label: "Traffic", ownerOnly: true },
247] as const satisfies readonly RepoNavItemDef[];
235// The More menu, grouped like the AI menu. It used to be a flat list of
236// 13, and sixteen registered repo pages (/branches, /tags, /milestones,
237// /symbols, /queue, /merges/verify, /pushes, /health, /comments/pending,
238// /branches/stale, …) weren't in the nav AT ALL — reachable only by typed
239// URL (2026-08-27 flow audit). Flat + incomplete is the worst combination:
240// the menu can't answer "where is X?" and doesn't even contain X. Four
241// labelled groups, each ordered by how often you reach for it.
242//
243// Deployments points at /deployments — /cloud-deployments was a second,
244// competing page and is now a 301 to it (see routes/deployments.tsx).
245export const REPO_NAV_MORE_GROUPS = [
246 {
247 heading: "Repository",
248 items: [
249 { key: "commits", path: "/commits", label: "Commits" },
250 { key: "branches", path: "/branches", label: "Branches" },
251 { key: "tags", path: "/tags", label: "Tags" },
252 { key: "releases", path: "/releases", label: "Releases" },
253 { key: "packages", path: "/packages", label: "Packages" },
254 { key: "symbols", path: "/symbols", label: "Symbols" },
255 { key: "stale-branches", path: "/branches/stale", label: "Stale branches" },
256 ],
257 },
258 {
259 heading: "Planning",
260 items: [
261 { key: "milestones", path: "/milestones", label: "Milestones" },
262 { key: "projects", path: "/projects", label: "Projects" },
263 { key: "discussions", path: "/discussions", label: "Discussions" },
264 { key: "wiki", path: "/wiki", label: "Wiki" },
265 { key: "contributors", path: "/contributors", label: "Contributors" },
266 { key: "moderation", path: "/comments/pending", label: "Moderation queue", ownerOnly: true },
267 ],
268 },
269 {
270 heading: "Delivery",
271 items: [
272 { key: "gates", path: "/gates", label: "Gates" },
273 { key: "queue", path: "/queue", label: "Merge queue" },
274 { key: "merge-verify", path: "/merges/verify", label: "Merge checks" },
275 { key: "deployments", path: "/deployments", label: "Deployments" },
276 { key: "pipeline", path: "/pipeline", label: "Pipeline" },
277 { key: "agents", path: "/agents", label: "Agents" },
278 ],
279 },
280 {
281 heading: "Activity",
282 items: [
283 { key: "health", path: "/health", label: "Health report" },
284 { key: "pulse", path: "/pulse", label: "Pulse" },
285 { key: "pushes", path: "/pushes", label: "Push log" },
286 { key: "traffic", path: "/traffic", label: "Traffic", ownerOnly: true },
287 ],
288 },
289] as const satisfies readonly RepoNavGroupDef[];
290
291/**
292 * Flat view of the More menu, kept for callers that only need the item
293 * list (coherence tests, link audits). Derived — never edit this side.
294 */
295export const REPO_NAV_MORE: readonly RepoNavItemDef[] = (
296 REPO_NAV_MORE_GROUPS as readonly RepoNavGroupDef[]
297).flatMap((g) => g.items);
248298
249299export const REPO_NAV_SETTINGS = {
250300 key: "settings",
262312export type RepoNavKey =
263313 | (typeof REPO_NAV_PRIMARY)[number]["key"]
264314 | (typeof REPO_NAV_AI_GROUPS)[number]["items"][number]["key"]
265 | (typeof REPO_NAV_MORE)[number]["key"]
315 | (typeof REPO_NAV_MORE_GROUPS)[number]["items"][number]["key"]
266316 | (typeof REPO_NAV_SETTINGS)["key"];
267317
268318export const RepoNav: FC<{
273323 currentUser?: string | null;
274324 /** Repo owner username — used for owner-only tab gating. */
275325 repoOwner?: string;
276}> = ({ owner, repo, active, currentUser, repoOwner }) => {
326 /**
327 * Whether AI features are usable on this instance. Defaults to the live
328 * `isAiAvailable()` env check (a sync read of ANTHROPIC_API_KEY) so every
329 * caller gets honesty for free; tests can override.
330 */
331 aiAvailable?: boolean;
332}> = ({ owner, repo, active, currentUser, repoOwner, aiAvailable }) => {
277333 const base = `/${owner}/${repo}`;
278334 const isOwner = !!(currentUser && repoOwner && currentUser === repoOwner);
335 const ai = aiAvailable ?? isAiAvailable();
279336 const href = (it: RepoNavItemDef) => `${base}${it.path}`;
280337
281338 // Widen the `as const` tuples back to the interface so ownerOnly (absent
282339 // from most literals) is accessible and flatMap unifies across groups.
283 const moreItems = (REPO_NAV_MORE as readonly RepoNavItemDef[]).filter(
284 (it) => !it.ownerOnly || isOwner
285 );
340 const moreItems = REPO_NAV_MORE.filter((it) => !it.ownerOnly || isOwner);
286341 const aiItems = (REPO_NAV_AI_GROUPS as readonly RepoNavGroupDef[]).flatMap(
287342 (g) => g.items
288343 );
311366 {"✨"} AI
312367 </summary>
313368 <div class="repo-nav-menu-panel repo-nav-menu-panel-grouped">
369 {/* Honest-degradation rule: when ANTHROPIC_API_KEY is not set,
370 every one of these destinations lands on an "AI unavailable"
371 fallback page. Advertising 18 working features that all fail
372 is the "emptiness on display" bug. Items stay visible and
373 clickable (the fallback pages explain each feature well) but
374 are visually muted, and one banner tells the truth up front. */}
375 {!ai && (
376 <div class="repo-nav-ai-banner" role="note">
377 <strong>AI features are not enabled on this instance.</strong>
378 <span>
379 They need an Anthropic API key — instance admins can set one
380 in <a href="/admin/integrations">Admin &rarr; Integrations</a>.
381 The pages below describe each feature.
382 </span>
383 </div>
384 )}
314385 {REPO_NAV_AI_GROUPS.map((g) => (
315386 <div class="repo-nav-menu-group">
316387 <div class="repo-nav-menu-heading">{g.heading}</div>
317388 {g.items.map((it) => (
318389 <a
319390 href={href(it)}
320 class={active === it.key ? "active" : ""}
391 class={`${active === it.key ? "active" : ""}${ai ? "" : " is-muted"}`.trim()}
321392 aria-current={active === it.key ? "page" : undefined}
322393 >
323394 {it.label}
329400 </details>
330401 <details class={`repo-nav-menu${moreActive ? " is-active" : ""}`}>
331402 <summary>More</summary>
332 <div class="repo-nav-menu-panel">
333 {moreItems.map((it) => (
334 <a
335 href={href(it)}
336 class={active === it.key ? "active" : ""}
337 aria-current={active === it.key ? "page" : undefined}
338 >
339 {it.label}
340 </a>
341 ))}
403 <div class="repo-nav-menu-panel repo-nav-menu-panel-grouped repo-nav-menu-panel-right">
404 {REPO_NAV_MORE_GROUPS.map((g) => {
405 const items = (g.items as readonly RepoNavItemDef[]).filter(
406 (it) => !it.ownerOnly || isOwner
407 );
408 return (
409 <div class="repo-nav-menu-group">
410 <div class="repo-nav-menu-heading">{g.heading}</div>
411 {items.map((it) => (
412 <a
413 href={href(it)}
414 class={active === it.key ? "active" : ""}
415 aria-current={active === it.key ? "page" : undefined}
416 >
417 {it.label}
418 </a>
419 ))}
420 </div>
421 );
422 })}
342423 </div>
343424 </details>
344425 <a
Modifiedsrc/views/layout.tsx+29−0View fileUnifiedSplit
24482448 panel stays shorter than the fold. */
24492449 .repo-nav-menu-panel-grouped {
24502450 min-width: 520px;
2451 max-width: min(92vw, 760px);
24512452 flex-direction: row;
2453 /* wrap so (a) the AI-unavailable banner can take a full row of its own
2454 and (b) four More groups fold to two columns instead of overflowing
2455 the viewport on narrow desktops. */
2456 flex-wrap: wrap;
24522457 align-items: flex-start;
24532458 gap: var(--space-2);
24542459 padding: var(--space-3);
24912496 font-weight: 600;
24922497 background: color-mix(in srgb, var(--accent) 14%, transparent);
24932498 }
2499 /* AI items when no API key is configured — still real links (their
2500 fallback pages explain each feature), but visibly not-live. */
2501 .repo-nav-menu-panel a.is-muted { opacity: 0.55; }
2502 .repo-nav-menu-panel a.is-muted:hover { opacity: 0.85; }
2503 /* One honest line at the top of the AI menu when AI is unavailable. */
2504 .repo-nav-ai-banner {
2505 flex-basis: 100%;
2506 width: 100%;
2507 margin-bottom: var(--space-2);
2508 padding: 8px 10px;
2509 border-radius: 8px;
2510 background: color-mix(in srgb, var(--yellow) 10%, transparent);
2511 border: 1px solid color-mix(in srgb, var(--yellow) 30%, transparent);
2512 font-size: 12px;
2513 line-height: 1.5;
2514 color: var(--text-muted);
2515 white-space: normal;
2516 }
2517 .repo-nav-ai-banner strong {
2518 display: block;
2519 color: var(--text-strong);
2520 font-weight: 600;
2521 }
2522 .repo-nav-ai-banner a { color: var(--accent); }
24942523
24952524 .breadcrumb {
24962525 display: flex;
24972526
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts