Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

components.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

components.tsxBlame1013 lines · 3 contributors
fc1817aClaude1import type { FC } from "hono/jsx";
06d5ffeClaude2import { html } from "hono/html";
fc1817aClaude3import type { GitCommit, GitTreeEntry, GitDiffFile } from "../git/repository";
06d5ffeClaude4import type { Repository } from "../db/schema";
82c3a38ccanty labs5import { parseDiff, pairLines } from "../lib/diff";
6import type { DiffLine, ParsedFile, SplitRow } from "../lib/diff";
fc1817aClaude7
8c790e0Claude8/**
9 * Describes the most recent push to a repo, used by RepoHeader to render
10 * the Push Watch discoverability indicator.
11 *
12 * - ageMs < 5 min \u2192 pulsing red "\u25cf Live" badge
13 * - ageMs < 24 hr \u2192 dimmer "\u25cb Watch" link
14 * - otherwise \u2192 nothing shown
15 */
16export interface RecentPush {
17 sha: string;
18 ageMs: number;
19}
20
06d5ffeClaude21export const RepoHeader: FC<{
22 owner: string;
23 repo: string;
24 starCount?: number;
25 starred?: boolean;
c81ab7aClaude26 forkCount?: number;
06d5ffeClaude27 currentUser?: string | null;
c81ab7aClaude28 forkedFrom?: string | null;
71cd5ecClaude29 archived?: boolean;
30 isTemplate?: boolean;
8c790e0Claude31 /** Most recent push info for Push Watch discoverability indicator. */
32 recentPush?: RecentPush | null;
77cf834Claude33 /** 0-100 health score badge rendered after the repo name. Optional — omit to hide. */
34 healthScore?: number;
71cd5ecClaude35}> = ({
36 owner,
37 repo,
38 starCount,
39 starred,
40 forkCount,
41 currentUser,
42 forkedFrom,
43 archived,
44 isTemplate,
8c790e0Claude45 recentPush,
77cf834Claude46 healthScore,
8c790e0Claude47}) => {
48 const FIVE_MIN = 5 * 60 * 1000;
49 const TWENTY_FOUR_HR = 24 * 60 * 60 * 1000;
50 const isLive = recentPush != null && recentPush.ageMs < FIVE_MIN;
51 const isRecent = recentPush != null && recentPush.ageMs < TWENTY_FOUR_HR;
52
77cf834Claude53 const healthColor =
54 healthScore === undefined ? null :
55 healthScore >= 80 ? "#34d399" :
56 healthScore >= 50 ? "#facc15" :
57 "#f87171";
58
8c790e0Claude59 return (
60 <div class="repo-header">
61 <div>
62 <div class="repo-header-title">
63 <a href={`/${owner}`} class="owner">
64 {owner}
65 </a>
66 <span class="separator">/</span>
67 <a href={`/${owner}/${repo}`} class="name">
68 {repo}
69 </a>
70 {archived && (
71 <span
72 class="repo-header-pill repo-header-pill-archived"
73 title="Read-only: pushes and new issues/PRs disabled"
74 >
75 Archived
76 </span>
77 )}
78 {isTemplate && (
79 <span
80 class="repo-header-pill repo-header-pill-template"
81 title="This repository can be used as a template"
82 >
83 Template
84 </span>
85 )}
77cf834Claude86 {healthScore !== undefined && healthColor && (
87 <a
88 href={`/${owner}/${repo}/health`}
89 title={`Repository health score: ${healthScore}/100 — click for breakdown`}
90 style={`display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:9999px;font-size:11px;font-weight:700;text-decoration:none;color:${healthColor};background:${healthColor}22;border:1px solid ${healthColor}44;`}
91 >
92 &#x2665; Health {healthScore}
93 </a>
94 )}
8c790e0Claude95 {isLive && recentPush && (
96 <a
97 href={`/${owner}/${repo}/push/${recentPush.sha}`}
98 class="repo-header-live-badge repo-header-live-badge--live"
99 title="Push in progress \u2014 watch live gate + deploy status"
100 aria-label="Live push \u2014 click to watch status"
101 >
102 <span class="repo-header-live-dot" aria-hidden="true">{"\u25cf"}</span>
103 Live
104 </a>
105 )}
106 {!isLive && isRecent && recentPush && (
107 <a
108 href={`/${owner}/${repo}/push/${recentPush.sha}`}
109 class="repo-header-live-badge repo-header-live-badge--recent"
110 title="Watch the most recent push's gate + deploy results"
111 aria-label="Watch most recent push"
112 >
113 <span aria-hidden="true">{"\u25cb"}</span>
114 Watch
115 </a>
116 )}
117 </div>
118 {forkedFrom && (
119 <div class="repo-header-fork">
120 forked from <a href={`/${forkedFrom}`}>{forkedFrom}</a>
121 </div>
71cd5ecClaude122 )}
c81ab7aClaude123 </div>
8c790e0Claude124 <div class="repo-header-actions">
125 {currentUser && currentUser !== owner && (
126 <form method="post" action={`/${owner}/${repo}/fork`} style="display:inline">
127 <button type="submit" class="star-btn">
128 {"\u2442"} Fork {forkCount !== undefined && forkCount > 0 ? forkCount : ""}
06d5ffeClaude129 </button>
130 </form>
8c790e0Claude131 )}
132 {starCount !== undefined && (
133 currentUser ? (
134 <form method="post" action={`/${owner}/${repo}/star`} style="display:inline">
135 <button
136 type="submit"
137 class={`star-btn${starred ? " starred" : ""}`}
138 >
139 {starred ? "\u2605" : "\u2606"} {starCount}
140 </button>
141 </form>
142 ) : (
143 <span class="star-btn">
144 {"\u2606"} {starCount}
145 </span>
146 )
147 )}
148 </div>
06d5ffeClaude149 </div>
8c790e0Claude150 );
151};
fc1817aClaude152
153export const RepoNav: FC<{
154 owner: string;
155 repo: string;
3ef4c9dClaude156 active:
157 | "code"
158 | "commits"
159 | "issues"
160 | "pulls"
161 | "releases"
eafe8c6Claude162 | "actions"
3ef4c9dClaude163 | "gates"
3cbe3d6Claude164 | "insights"
165 | "explain"
166 | "changelog"
1e162a8Claude167 | "semantic"
168 | "wiki"
0316dbbClaude169 | "projects"
ef3fd93Claude170 | "agents"
c645a86Claude171 | "discussions"
da3fc18Claude172 | "security"
bcc4020Claude173 | "settings"
7f992cdClaude174 | "debt-map"
175 | "migrate"
77cf834Claude176 | "deployments"
0224546Claude177 | "nl-search"
178 | "contributors"
179 | "pulse"
8ed88f2ccantynz-alt180 | "traffic"
181 | "pipeline"
182 | "workspace"
183 | "archaeology";
0224546Claude184 /** Current authenticated user — used for owner-only tab gating. */
185 currentUser?: string | null;
186 /** Repo owner username — used for owner-only tab gating. */
187 repoOwner?: string;
621081eccantynz-alt188}> = ({ owner, repo, active, currentUser, repoOwner }) => {
189 const base = `/${owner}/${repo}`;
190 const isOwner = !!(currentUser && repoOwner && currentUser === repoOwner);
191
192 // key typed as `string` so comparisons against the (narrower) `active`
193 // union never trip TS2367 \u2014 every key below is a legitimate destination.
194 type NavItem = { key: string; href: string; label: string };
195
196 const primary: NavItem[] = [
197 { key: "code", href: base, label: "Code" },
198 { key: "issues", href: `${base}/issues`, label: "Issues" },
199 { key: "pulls", href: `${base}/pulls`, label: "Pull Requests" },
200 { key: "actions", href: `${base}/actions`, label: "Actions" },
201 { key: "security", href: `${base}/security/vulnerabilities`, label: "Security" },
202 { key: "insights", href: `${base}/insights`, label: "Insights" },
203 { key: "settings", href: `${base}/settings`, label: "Settings" },
204 ];
205
206 const aiItems: NavItem[] = [
207 { key: "explain", href: `${base}/explain`, label: "Explain" },
208 { key: "ask", href: `${base}/ask`, label: "Ask AI" },
209 { key: "workspace", href: `${base}/workspace`, label: "Workspace" },
210 { key: "spec", href: `${base}/spec`, label: "Spec to PR" },
211 { key: "tests", href: `${base}/ai/tests`, label: "Tests" },
212 { key: "nl-search", href: `${base}/search/nl`, label: "NL Search" },
213 { key: "debt-map", href: `${base}/debt-map`, label: "Debt Map" },
214 { key: "archaeology", href: `${base}/archaeology`, label: "Archaeology" },
215 ];
216
217 const moreItems: NavItem[] = [
218 { key: "commits", href: `${base}/commits`, label: "Commits" },
219 { key: "discussions", href: `${base}/discussions`, label: "Discussions" },
220 { key: "wiki", href: `${base}/wiki`, label: "Wiki" },
221 { key: "projects", href: `${base}/projects`, label: "Projects" },
222 { key: "releases", href: `${base}/releases`, label: "Releases" },
223 { key: "contributors", href: `${base}/contributors`, label: "Contributors" },
224 { key: "pulse", href: `${base}/pulse`, label: "Pulse" },
225 { key: "gates", href: `${base}/gates`, label: "Gates" },
226 { key: "deployments", href: `${base}/cloud-deployments`, label: "Deployments" },
227 { key: "pipeline", href: `${base}/pipeline`, label: "Pipeline" },
228 { key: "agents", href: `${base}/agents`, label: "Agents" },
229 ...(isOwner ? [{ key: "traffic", href: `${base}/traffic`, label: "Traffic" }] : []),
230 ];
231
232 const aiActive = aiItems.some((i) => i.key === active);
233 const moreActive = moreItems.some((i) => i.key === active);
234
235 return (
236 <div class="repo-nav">
237 {primary.map((it) => (
238 <a href={it.href} class={active === it.key ? "active" : ""}>
239 {it.label}
240 </a>
241 ))}
242 <details
243 class={`repo-nav-menu repo-nav-ai-menu${aiActive ? " is-active" : ""}`}
244 style="margin-left:auto"
0224546Claude245 >
621081eccantynz-alt246 <summary class="repo-nav-ai" title="AI-native features \u2014 review, chat, spec-to-PR, and more">
247 {"\u2728"} AI
248 </summary>
249 <div class="repo-nav-menu-panel repo-nav-menu-panel-right">
250 {aiItems.map((it) => (
251 <a href={it.href} class={active === it.key ? "active" : ""}>
252 {it.label}
253 </a>
254 ))}
255 </div>
256 </details>
257 <details class={`repo-nav-menu${moreActive ? " is-active" : ""}`}>
258 <summary>More</summary>
259 <div class="repo-nav-menu-panel repo-nav-menu-panel-right">
260 {moreItems.map((it) => (
261 <a href={it.href} class={active === it.key ? "active" : ""}>
262 {it.label}
263 </a>
264 ))}
265 </div>
266 </details>
267 <script
268 dangerouslySetInnerHTML={{
269 __html:
270 "if(!window.__repoNavMenuInit){window.__repoNavMenuInit=1;" +
271 "document.addEventListener('click',function(e){" +
272 "document.querySelectorAll('details.repo-nav-menu[open]').forEach(function(d){" +
273 "if(!d.contains(e.target))d.removeAttribute('open');});});}",
274 }}
275 />
276 </div>
277 );
278};
fc1817aClaude279
06d5ffeClaude280export const BranchSwitcher: FC<{
281 owner: string;
282 repo: string;
283 currentRef: string;
284 branches: string[];
285 pathType: "tree" | "blob" | "commits";
286 subPath?: string;
287}> = ({ owner, repo, currentRef, branches, pathType, subPath }) => {
288 if (branches.length <= 1) {
289 return <div class="branch-selector">{currentRef}</div>;
290 }
291
292 return (
293 <div class="branch-dropdown">
294 <button class="branch-selector" type="button">
295 {currentRef} &#9662;
296 </button>
297 <div class="branch-dropdown-content">
298 {branches.map((branch) => {
299 let href: string;
300 if (pathType === "commits") {
301 href = `/${owner}/${repo}/commits/${branch}`;
302 } else if (subPath) {
303 href = `/${owner}/${repo}/${pathType}/${branch}/${subPath}`;
304 } else {
305 href = `/${owner}/${repo}/tree/${branch}`;
306 }
307 return (
308 <a
309 href={href}
310 class={branch === currentRef ? "active-branch" : ""}
311 >
312 {branch}
313 </a>
314 );
315 })}
316 </div>
317 </div>
318 );
319};
320
fc1817aClaude321export const Breadcrumb: FC<{
322 owner: string;
323 repo: string;
324 ref: string;
325 path: string;
326}> = ({ owner, repo, ref, path }) => {
327 const parts = path.split("/").filter(Boolean);
328 const crumbs: { name: string; href: string }[] = [
329 { name: repo, href: `/${owner}/${repo}/tree/${ref}` },
330 ];
331 let accumulated = "";
332 for (const part of parts) {
333 accumulated += (accumulated ? "/" : "") + part;
334 crumbs.push({
335 name: part,
336 href: `/${owner}/${repo}/tree/${ref}/${accumulated}`,
337 });
338 }
339 return (
340 <div class="breadcrumb">
341 {crumbs.map((crumb, i) => (
342 <>
343 {i > 0 && <span>/</span>}
344 {i === crumbs.length - 1 ? (
345 <strong>{crumb.name}</strong>
346 ) : (
347 <a href={crumb.href}>{crumb.name}</a>
348 )}
349 </>
350 ))}
351 </div>
352 );
353};
354
355export const FileTable: FC<{
356 entries: GitTreeEntry[];
357 owner: string;
358 repo: string;
359 ref: string;
360 path: string;
361}> = ({ entries, owner, repo, ref, path }) => (
362 <table class="file-table">
363 <tbody>
364 {entries.map((entry) => {
365 const fullPath = path ? `${path}/${entry.name}` : entry.name;
366 const href =
367 entry.type === "tree"
368 ? `/${owner}/${repo}/tree/${ref}/${fullPath}`
369 : `/${owner}/${repo}/blob/${ref}/${fullPath}`;
370 return (
371 <tr>
372 <td class="file-icon">
373 {entry.type === "tree" ? "\u{1F4C1}" : "\u{1F4C4}"}
374 </td>
375 <td class="file-name">
376 <a href={href}>{entry.name}</a>
377 </td>
378 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
379 {entry.size !== undefined ? formatSize(entry.size) : ""}
380 </td>
381 </tr>
382 );
383 })}
384 </tbody>
385 </table>
386);
387
06d5ffeClaude388export const HighlightedCode: FC<{
389 highlightedHtml: string;
390 lineCount: number;
391}> = ({ highlightedHtml, lineCount }) => {
392 const lineNums = Array.from({ length: lineCount }, (_, i) => i + 1);
393 return (
394 <div class="blob-code">
395 <table>
396 <tbody>
397 <tr>
398 <td class="line-num" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
399 <pre style="margin: 0; line-height: 1.6; font-size: 13px">
400 {lineNums.map((n) => (
401 <>
402 <span>{n}</span>
403 {"\n"}
404 </>
405 ))}
406 </pre>
407 </td>
408 <td class="line-content" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
409 <pre style="margin: 0; line-height: 1.6; font-size: 13px">{html([highlightedHtml] as unknown as TemplateStringsArray)}</pre>
410 </td>
411 </tr>
412 </tbody>
413 </table>
414 </div>
415 );
416};
417
418export const PlainCode: FC<{ lines: string[] }> = ({ lines }) => (
419 <div class="blob-code">
420 <table>
421 <tbody>
422 {lines.map((line, i) => (
423 <tr>
424 <td class="line-num">{i + 1}</td>
425 <td class="line-content">{line}</td>
426 </tr>
427 ))}
428 </tbody>
429 </table>
430 </div>
431);
432
fc1817aClaude433export const CommitList: FC<{
434 commits: GitCommit[];
435 owner: string;
436 repo: string;
3951454Claude437 verifications?: Record<string, { verified: boolean; reason: string }>;
438}> = ({ commits, owner, repo, verifications }) => (
fc1817aClaude439 <div class="commit-list">
3951454Claude440 {commits.map((commit) => {
441 const v = verifications?.[commit.sha];
442 return (
443 <div class="commit-item">
444 <div>
445 <div class="commit-message">
446 <a href={`/${owner}/${repo}/commit/${commit.sha}`}>
447 {commit.message}
448 </a>
449 {v?.verified && (
ae2a071ccanty labs450 <a
451 href="/settings/signing-keys"
452 title="Signed with a registered key — manage your signing keys"
453 style="margin-left:8px;font-size:10px;padding:1px 6px;border-radius:3px;background:var(--green,#2ea043);color:#fff;text-transform:uppercase;letter-spacing:.4px;text-decoration:none"
3951454Claude454 >
455 Verified
ae2a071ccanty labs456 </a>
3951454Claude457 )}
458 </div>
459 <div class="commit-meta">
460 {commit.author} committed {formatRelativeDate(commit.date)}
461 </div>
fc1817aClaude462 </div>
3951454Claude463 <a
464 href={`/${owner}/${repo}/commit/${commit.sha}`}
465 class="commit-sha"
466 >
467 {commit.sha.slice(0, 7)}
468 </a>
fc1817aClaude469 </div>
3951454Claude470 );
471 })}
fc1817aClaude472 </div>
473);
474
82c3a38ccanty labs475// ---- Diff viewer ----------------------------------------------------------
476
477const _diffScript = `
478function diffToggleView(id,mode){
479 var v=document.getElementById(id);if(!v)return;
480 v.querySelectorAll('.diff-table-inline').forEach(function(t){t.hidden=(mode==='split');});
481 v.querySelectorAll('.diff-table-split').forEach(function(t){t.hidden=(mode!=='split');});
482 v.querySelectorAll('.diff-view-btn').forEach(function(b){b.classList.remove('active');});
483 var btn=v.querySelector('.diff-view-btn-'+mode);if(btn)btn.classList.add('active');
484}
485function diffToggleFile(id){
486 var body=document.getElementById(id);if(!body)return;
487 body.hidden=!body.hidden;
488 var hdr=body.previousElementSibling;if(hdr)hdr.classList.toggle('collapsed',body.hidden);
489}
490`;
491
fc1817aClaude492export const DiffView: FC<{ raw: string; files: GitDiffFile[] }> = ({
493 raw,
494 files,
495}) => {
82c3a38ccanty labs496 const parsed = parseDiff(raw);
497 const totalAdd = files.reduce((s, f) => s + f.additions, 0);
498 const totalDel = files.reduce((s, f) => s + f.deletions, 0);
499
500 if (parsed.length === 0 && files.length === 0) {
501 return (
502 <div class="diff-view">
503 <p style="color:var(--text-muted);font-size:14px">No changes.</p>
504 </div>
505 );
506 }
fc1817aClaude507
508 return (
82c3a38ccanty labs509 <div class="diff-view" id="diff-view-main">
510 <script dangerouslySetInnerHTML={{ __html: _diffScript }} />
511
512 {/* Toolbar */}
513 <div class="diff-toolbar">
514 <div class="diff-summary">
515 <strong>{files.length}</strong> file{files.length !== 1 ? "s" : ""} changed,{" "}
516 <span class="stat-add">+{totalAdd}</span>{" "}
517 <span class="stat-del">-{totalDel}</span>
518 </div>
519 <div class="diff-view-toggle">
520 <button class="diff-view-btn diff-view-btn-inline active" onclick="diffToggleView('diff-view-main','inline')" type="button">Inline</button>
521 <button class="diff-view-btn diff-view-btn-split" onclick="diffToggleView('diff-view-main','split')" type="button">Split</button>
522 </div>
fc1817aClaude523 </div>
82c3a38ccanty labs524
525 {/* File jump list (shown when > 1 file) */}
526 {parsed.length > 1 && (
527 <div class="diff-jump-list">
528 {parsed.map((f, i) => {
529 const isAdd = f.additions > 0 && f.deletions === 0;
530 const isDel = f.additions === 0 && f.deletions > 0;
531 return (
532 <a class="diff-jump-item" href={`#diff-file-${i}`}>
533 <span class={isAdd ? "stat-add" : isDel ? "stat-del" : "diff-jump-mod"}>
534 {isAdd ? "+" : isDel ? "−" : "~"}
535 </span>{" "}
536 {f.path.split("/").pop() || f.path}
537 </a>
538 );
539 })}
fc1817aClaude540 </div>
82c3a38ccanty labs541 )}
542
543 {/* Per-file blocks */}
544 {parsed.map((file, idx) => {
545 const bodyId = `diff-body-${idx}`;
546 const splitRows = pairLines(file.lines);
547 return (
548 <div class="diff-file" id={`diff-file-${idx}`}>
549 <div class="diff-file-header" onclick={`diffToggleFile('${bodyId}')`}>
550 <span class="diff-file-path" title={file.path}>{file.path}</span>
551 <span class="diff-file-meta">
552 {file.isBinary
553 ? <span style="color:var(--text-muted)">binary</span>
554 : <><span class="stat-add">+{file.additions}</span>{" "}<span class="stat-del">-{file.deletions}</span></>
555 }
556 <span class="diff-file-chevron">▾</span>
557 </span>
558 </div>
559 <div id={bodyId}>
560 {file.isBinary ? (
561 <div class="diff-binary">Binary file changed</div>
562 ) : (
563 <>
564 {/* Inline table (default) */}
565 <table class="diff-table diff-table-inline">
566 <tbody>
567 {file.lines.map((line) => {
568 if (line.type === "hunk") {
569 return (
570 <tr class="diff-row diff-row-hunk">
571 <td class="diff-ln diff-ln-old"></td>
572 <td class="diff-ln diff-ln-new"></td>
573 <td class="diff-cell">{line.content}</td>
574 </tr>
575 );
576 }
577 const sigil = line.type === "add" ? "+" : line.type === "del" ? "-" : " ";
578 return (
579 <tr class={`diff-row diff-row-${line.type}${line.wsOnly ? " diff-row-ws" : ""}`}>
580 <td class="diff-ln diff-ln-old">{line.oldLine ?? ""}</td>
581 <td class="diff-ln diff-ln-new">{line.newLine ?? ""}</td>
582 <td class="diff-cell">
583 <span class="diff-sigil">{sigil}</span>
584 {line.content}
585 {line.wsOnly && <span class="diff-ws-badge" title="whitespace-only change">ws</span>}
586 </td>
587 </tr>
588 );
589 })}
590 </tbody>
591 </table>
592 {/* Split table (hidden until toggled) */}
593 <table class="diff-table diff-table-split" hidden>
594 <tbody>
595 {splitRows.map((row) => {
596 const isHunk = row.left?.type === "hunk" || row.right?.type === "hunk";
597 if (isHunk) {
598 const hunk = row.left ?? row.right;
599 return (
600 <tr class="diff-row diff-row-hunk">
601 <td class="diff-ln diff-ln-old"></td>
602 <td class="diff-cell">{hunk?.content ?? ""}</td>
603 <td class="diff-ln diff-ln-new"></td>
604 <td class="diff-cell"></td>
605 </tr>
606 );
607 }
608 const isCtx = row.left?.type === "ctx";
609 const L = row.left;
610 const R = row.right;
611 const leftCls = `diff-cell${L ? (isCtx ? " diff-cell-ctx" : " diff-cell-del") : " diff-cell-empty"}${L?.wsOnly ? " diff-cell-ws" : ""}`;
612 const rightCls = `diff-cell${R ? (isCtx ? " diff-cell-ctx" : " diff-cell-add") : " diff-cell-empty"}${R?.wsOnly ? " diff-cell-ws" : ""}`;
613 return (
614 <tr class="diff-row diff-split-row">
615 <td class="diff-ln diff-ln-old">{L?.oldLine ?? ""}</td>
616 <td class={leftCls}>
617 {L && <span class="diff-sigil">{isCtx ? " " : "-"}</span>}
618 {L?.content ?? ""}
619 {L?.wsOnly && <span class="diff-ws-badge" title="whitespace-only change">ws</span>}
620 </td>
621 <td class="diff-ln diff-ln-new">{R?.newLine ?? ""}</td>
622 <td class={rightCls}>
623 {R && <span class="diff-sigil">{isCtx ? " " : "+"}</span>}
624 {isCtx ? L?.content ?? "" : R?.content ?? ""}
625 {R?.wsOnly && !isCtx && <span class="diff-ws-badge" title="whitespace-only change">ws</span>}
626 </td>
627 </tr>
628 );
629 })}
630 </tbody>
631 </table>
632 </>
633 )}
634 </div>
635 </div>
636 );
637 })}
fc1817aClaude638 </div>
639 );
640};
641
06d5ffeClaude642export const RepoCard: FC<{ repo: Repository; ownerName: string }> = ({
643 repo,
644 ownerName,
645}) => (
646 <div class="card">
647 <h3>
648 <a href={`/${ownerName}/${repo.name}`}>{repo.name}</a>
649 </h3>
650 {repo.description && <p>{repo.description}</p>}
651 <div class="card-meta">
652 {repo.isPrivate && <span class="badge">Private</span>}
653 <span>{"\u2606"} {repo.starCount}</span>
654 {repo.pushedAt && (
655 <span>Updated {formatRelativeDate(repo.pushedAt.toString())}</span>
656 )}
657 </div>
658 </div>
659);
660
fc1817aClaude661function formatSize(bytes: number): string {
662 if (bytes < 1024) return `${bytes} B`;
663 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
664 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
665}
666
667function formatRelativeDate(dateStr: string): string {
668 const date = new Date(dateStr);
669 const now = new Date();
670 const diffMs = now.getTime() - date.getTime();
671 const diffMins = Math.floor(diffMs / 60000);
672 if (diffMins < 1) return "just now";
673 if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
674 const diffHours = Math.floor(diffMins / 60);
675 if (diffHours < 24)
676 return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
677 const diffDays = Math.floor(diffHours / 24);
678 if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
679 return date.toLocaleDateString("en-US", {
680 month: "short",
681 day: "numeric",
682 year: "numeric",
683 });
684}
6682dbeClaude685
686// ---------------------------------------------------------------------------
687// CiFailureProfileCard — Phase 2 Human-Agent Canvas
688// ---------------------------------------------------------------------------
689
690export interface CiTraceIteration {
691 iteration: number;
692 modelRoute: string;
693 errorDelta: string;
694 tokenCost: number;
695}
696
697export interface CiFailureProfileProps {
698 gateRunId: string;
699 failureTarget: string;
700 errorDelta: string;
701 mitigationHint: string;
702 traceLog: CiTraceIteration[];
703}
704
705export const CiFailureProfileCard: FC<CiFailureProfileProps> = ({
706 gateRunId,
707 failureTarget,
708 errorDelta,
709 mitigationHint,
710 traceLog,
711}) => (
712 <div class="ci-failure-profile-card">
713 <style>{`
714 .ci-failure-profile-card {
715 background: #1a1a2e;
716 border: 1px solid #2d2d4a;
717 border-radius: 8px;
718 padding: 16px;
719 margin: 12px 0;
720 font-family: monospace;
721 color: #e0e0ff;
722 }
723 .ci-failure-profile-card h3 {
724 margin: 0 0 12px;
725 color: #ff6b6b;
726 font-size: 14px;
727 text-transform: uppercase;
728 letter-spacing: 0.5px;
729 }
730 .ci-failure-profile-card .field-label {
731 color: #888;
732 font-size: 11px;
733 text-transform: uppercase;
734 margin-top: 10px;
735 }
736 .ci-failure-profile-card .field-value {
737 color: #e0e0ff;
738 font-size: 13px;
739 margin: 2px 0 6px;
740 white-space: pre-wrap;
741 word-break: break-word;
742 }
743 .ci-failure-profile-card .trace-row {
744 display: grid;
745 grid-template-columns: 40px 1fr 80px;
746 gap: 8px;
747 padding: 4px 0;
748 border-bottom: 1px solid #2d2d4a;
749 font-size: 12px;
750 }
751 .ci-failure-profile-card .feedback-row {
752 margin-top: 14px;
753 display: flex;
754 gap: 8px;
755 }
756 .ci-failure-profile-card .btn-feedback {
757 padding: 6px 12px;
758 border: none;
759 border-radius: 6px;
760 cursor: pointer;
761 font-size: 12px;
762 font-weight: bold;
763 }
764 .ci-failure-profile-card .btn-helpful {
765 background: #1a6b3c;
766 color: #7fff9e;
767 }
768 .ci-failure-profile-card .btn-hallucination {
769 background: #6b1a1a;
770 color: #ff9e9e;
771 }
772 `}</style>
773 <h3>🔍 CI Failure Profile</h3>
774 <div class="field-label">Failure Target</div>
775 <div class="field-value">{failureTarget}</div>
776 <div class="field-label">Error Delta</div>
777 <div class="field-value">{errorDelta}</div>
778 <div class="field-label">Mitigation Hint</div>
779 <div class="field-value">{mitigationHint}</div>
780 {traceLog.length > 0 && (
781 <>
782 <div class="field-label" style="margin-top:12px">Model Trace</div>
783 {traceLog.map((t) => (
784 <div class="trace-row" key={t.iteration}>
785 <span>#{t.iteration}</span>
786 <span>{t.modelRoute}</span>
787 <span>{t.tokenCost}¢</span>
788 </div>
789 ))}
790 </>
791 )}
792 <div class="feedback-row">
793 <form method="post" action="/api/flywheel-telemetry/feedback" style="display:inline">
794 <input type="hidden" name="gateRunId" value={gateRunId} />
795 <input type="hidden" name="verdict" value="helpful" />
796 <button type="submit" class="btn-feedback btn-helpful">
797 👍 Diagnostic Helpful
798 </button>
799 </form>
800 <form method="post" action="/api/flywheel-telemetry/feedback" style="display:inline">
801 <input type="hidden" name="gateRunId" value={gateRunId} />
802 <input type="hidden" name="verdict" value="hallucination" />
803 <button type="submit" class="btn-feedback btn-hallucination">
804 👎 Hallucination Flagged
805 </button>
806 </form>
807 </div>
808 </div>
809);
9b776daccantynz-alt810
811// ---------------------------------------------------------------------------
812// Settings navigation — shared across every /settings/* page.
813//
814// Previously each settings page either duplicated a flat "pill row" nav
815// (styled only on the two pages settings.tsx itself renders — every other
816// importer got unstyled raw links, since the CSS lived inline in that one
817// file) or, for most settings pages (tokens, billing, notifications, audit,
818// saved replies, sponsors, OAuth apps/authorizations, incident hooks...),
819// rendered no navigation at all. Landing on /settings/tokens was a dead end:
820// no way to reach any other settings page except backing out to /settings.
821//
822// This is the single source of truth for the settings IA: a grouped
823// sidebar (profile / access & security / applications / automation /
824// account / activity), matching the standard every developer already
825// knows from GitHub's own settings sidebar, covering every real
826// /settings/* page in the app.
827// ---------------------------------------------------------------------------
828
829export type SettingsNavKey =
830 | "profile"
831 | "notifications"
832 | "replies"
833 | "billing"
834 | "sponsors"
835 | "keys"
836 | "signing-keys"
837 | "2fa"
838 | "passkeys"
839 | "sessions"
840 | "tokens"
841 | "applications"
842 | "apps"
843 | "authorizations"
844 | "agents"
845 | "deploy-targets"
846 | "integrations"
847 | "incident-hooks"
848 | "audit";
849
850interface SettingsNavItem {
851 key: SettingsNavKey;
852 href: string;
853 label: string;
854}
855
856interface SettingsNavGroup {
857 label: string;
858 items: SettingsNavItem[];
859}
860
861const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
862 {
863 label: "Account",
864 items: [
865 { key: "profile", href: "/settings", label: "Public profile" },
866 { key: "notifications", href: "/settings/notifications", label: "Notifications" },
867 { key: "replies", href: "/settings/replies", label: "Saved replies" },
868 { key: "billing", href: "/settings/billing", label: "Billing" },
869 { key: "sponsors", href: "/settings/sponsors", label: "Sponsorship" },
870 ],
871 },
872 {
873 label: "Access & security",
874 items: [
875 { key: "keys", href: "/settings/keys", label: "SSH keys" },
876 { key: "signing-keys", href: "/settings/signing-keys", label: "Signing keys" },
877 { key: "2fa", href: "/settings/2fa", label: "Two-factor auth" },
878 { key: "passkeys", href: "/settings/passkeys", label: "Passkeys" },
879 { key: "sessions", href: "/settings/sessions", label: "Sessions" },
880 { key: "tokens", href: "/settings/tokens", label: "Personal access tokens" },
881 ],
882 },
883 {
884 label: "Applications",
885 items: [
886 { key: "applications", href: "/settings/applications", label: "Developer applications" },
887 { key: "apps", href: "/settings/apps", label: "Installed apps" },
888 { key: "authorizations", href: "/settings/authorizations", label: "Authorized applications" },
889 ],
890 },
891 {
892 label: "Automation",
893 items: [
894 { key: "agents", href: "/settings/agents", label: "Agent sessions" },
895 { key: "deploy-targets", href: "/settings/deploy-targets", label: "Deploy targets" },
896 { key: "integrations", href: "/settings/integrations", label: "Chat integrations" },
897 { key: "incident-hooks", href: "/settings/incident-hooks", label: "Incident hooks" },
898 ],
899 },
900 {
901 label: "Activity",
902 items: [{ key: "audit", href: "/settings/audit", label: "Audit log" }],
903 },
904];
905
906export const SettingsNav: FC<{ active: SettingsNavKey }> = ({ active }) => (
907 <nav class="stgnav" aria-label="Settings sections">
908 {SETTINGS_NAV_GROUPS.map((group) => (
909 <div class="stgnav-group">
910 <div class="stgnav-label">{group.label}</div>
911 {group.items.map((it) => (
912 <a
913 href={it.href}
914 class={it.key === active ? "is-active" : ""}
915 aria-current={it.key === active ? "page" : undefined}
916 >
917 {it.label}
918 </a>
919 ))}
920 </div>
921 ))}
922 </nav>
923);
924
925/**
926 * Shared shell CSS. Every /settings/* page includes this once (it's cheap,
927 * inline, and idempotent — duplicate <style> blocks with the same rules
928 * are harmless) and wraps its content in `.settings-shell` /
929 * `.settings-content` alongside a `<SettingsNav active="...">`.
930 */
931export const settingsNavStyles = `
932 .settings-shell {
933 display: grid;
934 grid-template-columns: 220px minmax(0, 1fr);
935 gap: var(--space-6);
936 align-items: start;
937 }
938 .stgnav {
939 position: sticky;
940 top: var(--space-4);
941 display: flex;
942 flex-direction: column;
943 gap: var(--space-5);
944 }
945 .stgnav-group {
946 display: flex;
947 flex-direction: column;
948 gap: 1px;
949 }
950 .stgnav-label {
951 font-size: 11px;
952 font-weight: 700;
953 letter-spacing: 0.06em;
954 text-transform: uppercase;
955 color: var(--text-muted);
956 padding: 4px 10px;
957 margin-bottom: 2px;
958 }
959 .stgnav a {
960 display: block;
961 padding: 6px 10px;
962 border-radius: 6px;
963 font-size: 13.5px;
964 font-weight: 500;
965 color: var(--text-muted);
966 text-decoration: none;
967 white-space: nowrap;
968 overflow: hidden;
969 text-overflow: ellipsis;
970 transition: background 120ms ease, color 120ms ease;
971 }
972 .stgnav a:hover {
973 color: var(--text-strong);
974 background: var(--bg-hover);
975 }
976 .stgnav a.is-active {
977 color: var(--text-strong);
978 background: rgba(91,110,232,0.14);
979 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.3);
980 font-weight: 600;
981 }
982 .settings-content {
983 min-width: 0;
984 }
985 @media (max-width: 860px) {
986 .settings-shell {
987 display: block;
988 }
989 .stgnav {
990 position: static;
991 flex-direction: row;
992 flex-wrap: wrap;
993 gap: var(--space-3);
994 margin-bottom: var(--space-5);
995 padding: 4px;
996 background: var(--bg-elevated);
997 border: 1px solid var(--border);
998 border-radius: var(--radius);
999 }
1000 .stgnav-group {
1001 flex-direction: row;
1002 flex-wrap: wrap;
1003 gap: 2px;
1004 }
1005 .stgnav-label {
1006 display: none;
1007 }
1008 .stgnav a {
1009 padding: 6px 12px;
1010 border-radius: 9999px;
1011 }
1012 }
1013`;