Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

ai-explain.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.

ai-explain.tsxBlame731 lines · 1 contributor
3cbe3d6Claude1/**
2 * Block D6 — "Explain this codebase" route.
3 *
4 * GET /:owner/:repo/explain — render cached (or freshly
5 * generated on first visit)
6 * Markdown explanation
7 * POST /:owner/:repo/explain/regenerate — owner-only; force-regenerate
8 * and redirect back
9 *
10 * Heavy lifting lives in `lib/ai-explain.ts`; this file is just HTTP glue.
e73e288Claude11 *
12 * 2026 polish (visual only — every form action / POST target / AI prompt
13 * call is preserved verbatim):
14 * - .ai-explain-wrap max-width 980px + gradient-hairline hero w/ orb
15 * - Display headline ends in a gradient "Explain." verb
16 * - White result panel (mirrors admin-integrations spec block) with
17 * monospace and an inline copy-to-clipboard button
18 * - Loading shimmer skeleton (kept available for future async modes) +
19 * dashed empty-state cards
20 * - "Powered by Claude" subtle pill at the bottom
21 * CSS is scoped under `.ai-explain-*` so it can't bleed into ai-changelog
22 * or ai-tests if they're rendered on the same Layout in another surface.
3cbe3d6Claude23 */
24
25import { Hono } from "hono";
26import { html } from "hono/html";
27import { and, eq } from "drizzle-orm";
28import { db } from "../db";
29import { repositories, users } from "../db/schema";
30import { Layout } from "../views/layout";
31import { RepoHeader } from "../views/components";
32import { IssueNav } from "./issues";
33import { renderMarkdown } from "../lib/markdown";
34import { softAuth, requireAuth } from "../middleware/auth";
35import type { AuthEnv } from "../middleware/auth";
36import { getDefaultBranch, resolveRef } from "../git/repository";
37import {
38 explainCodebase,
39 getCachedExplanation,
40} from "../lib/ai-explain";
41
42const aiExplainRoutes = new Hono<AuthEnv>();
43
e73e288Claude44/* ─────────────────────────────────────────────────────────────────────────
45 * Scoped CSS — every class prefixed `.ai-explain-` so this surface can't
46 * bleed into the wider `.ai-changelog-*` or `.ai-tests-*` polish. Mirrors
47 * the gradient-hairline hero + white-spec-block patterns from
48 * admin-integrations.tsx / build-agent-spec.tsx.
49 * ───────────────────────────────────────────────────────────────────── */
50const styles = `
eed4684Claude51 .ai-explain-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
e73e288Claude52
53 .ai-explain-hero {
54 position: relative;
55 margin-bottom: var(--space-5);
56 padding: var(--space-5) var(--space-6);
57 background: var(--bg-elevated);
58 border: 1px solid var(--border);
59 border-radius: 16px;
60 overflow: hidden;
61 }
62 .ai-explain-hero::before {
63 content: '';
64 position: absolute;
65 top: 0; left: 0; right: 0;
66 height: 2px;
67 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
68 opacity: 0.7;
69 pointer-events: none;
70 }
71 .ai-explain-hero-orb {
72 position: absolute;
73 inset: -20% -10% auto auto;
74 width: 420px; height: 420px;
75 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
76 filter: blur(80px);
77 opacity: 0.75;
78 pointer-events: none;
79 z-index: 0;
80 }
81 .ai-explain-hero-inner { position: relative; z-index: 1; max-width: 720px; }
82
83 .ai-explain-eyebrow {
84 font-size: 12px;
85 color: var(--text-muted);
86 margin-bottom: var(--space-2);
87 letter-spacing: 0.02em;
88 display: inline-flex;
89 align-items: center;
90 gap: 8px;
91 }
92 .ai-explain-eyebrow .pill {
93 display: inline-flex;
94 align-items: center;
95 justify-content: center;
96 width: 18px; height: 18px;
97 border-radius: 6px;
98 background: rgba(140,109,255,0.14);
99 color: #b69dff;
100 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
101 }
102
103 .ai-explain-title {
104 font-size: clamp(28px, 4vw, 42px);
105 font-family: var(--font-display);
106 font-weight: 800;
107 letter-spacing: -0.028em;
108 line-height: 1.05;
109 margin: 0 0 var(--space-2);
110 color: var(--text-strong);
111 }
112 .ai-explain-title-grad {
113 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
114 -webkit-background-clip: text;
115 background-clip: text;
116 -webkit-text-fill-color: transparent;
117 color: transparent;
118 }
119 .ai-explain-sub {
120 font-size: 15px;
121 color: var(--text-muted);
122 margin: 0;
123 line-height: 1.55;
124 max-width: 620px;
125 }
126
127 .ai-explain-meta {
128 display: flex;
129 align-items: center;
130 gap: 10px;
131 flex-wrap: wrap;
132 font-size: 12px;
133 color: var(--text-muted);
134 margin-bottom: var(--space-3);
135 }
136 .ai-explain-meta code {
137 font-family: var(--font-mono);
138 background: var(--bg-tertiary);
139 padding: 1px 6px;
140 border-radius: 4px;
141 font-size: 11.5px;
142 color: var(--text);
143 }
144 .ai-explain-meta .ai-explain-pill {
145 display: inline-flex;
146 align-items: center;
147 gap: 6px;
148 padding: 2px 8px;
149 border-radius: 9999px;
150 font-size: 10.5px;
151 font-weight: 600;
152 letter-spacing: 0.04em;
153 text-transform: uppercase;
154 background: rgba(52,211,153,0.12);
155 color: #6ee7b7;
156 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
157 }
158 .ai-explain-meta .ai-explain-pill .dot {
159 width: 6px; height: 6px;
160 border-radius: 9999px;
161 background: currentColor;
162 }
163
164 .ai-explain-actions {
165 display: flex;
166 justify-content: space-between;
167 align-items: center;
168 gap: var(--space-2);
169 margin: var(--space-4) 0 var(--space-3);
170 flex-wrap: wrap;
171 }
172 .ai-explain-actions h2 {
173 margin: 0;
174 font-family: var(--font-display);
175 font-size: 18px;
176 font-weight: 700;
177 letter-spacing: -0.018em;
178 color: var(--text-strong);
179 }
180 .ai-explain-regen {
181 display: inline-flex;
182 align-items: center;
183 gap: 6px;
184 padding: 8px 14px;
185 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
186 color: #ffffff;
187 border: 1px solid transparent;
188 border-radius: 10px;
189 font-size: 13px;
190 font-weight: 600;
191 text-decoration: none;
192 cursor: pointer;
193 box-shadow: 0 6px 16px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
194 font-family: inherit;
195 transition: transform 120ms ease, box-shadow 120ms ease;
196 }
197 .ai-explain-regen:hover {
198 transform: translateY(-1px);
199 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
200 }
201 .ai-explain-regen svg { display: block; }
202
203 /* Solid white panel — the codebase explanation reads like a printed
204 report on the dark theme. */
205 .ai-explain-panel {
206 position: relative;
207 margin-bottom: var(--space-5);
208 background: #ffffff;
209 color: #0a0a0a;
210 border: 1px solid #e5e7eb;
211 border-radius: 14px;
212 overflow: hidden;
213 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 8px 32px rgba(0,0,0,0.18);
214 }
215 .ai-explain-panel-head {
216 display: flex;
217 align-items: center;
218 justify-content: space-between;
219 gap: 12px;
220 padding: 12px 16px;
221 background: #f9fafb;
222 border-bottom: 1px solid #e5e7eb;
223 flex-wrap: wrap;
224 }
225 .ai-explain-panel-title {
226 display: flex;
227 align-items: center;
228 gap: 10px;
229 font-family: var(--font-display, system-ui, sans-serif);
230 font-size: 14px;
231 font-weight: 700;
232 color: #111827;
233 letter-spacing: -0.005em;
234 margin: 0;
235 }
236 .ai-explain-panel-dot {
237 width: 8px; height: 8px;
238 border-radius: 9999px;
239 background: linear-gradient(135deg, #8c6dff, #36c5d6);
240 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
241 }
242 .ai-explain-copy {
243 display: inline-flex;
244 align-items: center;
245 gap: 6px;
246 padding: 6px 12px;
247 font-size: 12.5px;
248 font-weight: 600;
249 color: #111827;
250 background: #ffffff;
251 border: 1px solid #d1d5db;
252 border-radius: 8px;
253 cursor: pointer;
254 font-family: inherit;
255 transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
256 }
257 .ai-explain-copy:hover {
258 background: #f3f4f6;
259 border-color: #9ca3af;
260 }
261 .ai-explain-copy.is-copied {
262 background: #ecfdf5;
263 border-color: #6ee7b7;
264 color: #047857;
265 }
266 .ai-explain-copy svg { display: block; }
267
268 .ai-explain-panel-body {
269 padding: 22px 24px;
270 background: #ffffff;
271 color: #0a0a0a;
272 }
273 /* Tame the .markdown-body inside the white panel — its dark-theme
274 defaults (light text on dark bg) would be invisible here. */
275 .ai-explain-panel-body .markdown-body {
276 color: #0a0a0a;
277 background: #ffffff;
278 font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif);
279 font-size: 14.5px;
280 line-height: 1.65;
281 }
282 .ai-explain-panel-body .markdown-body h1,
283 .ai-explain-panel-body .markdown-body h2,
284 .ai-explain-panel-body .markdown-body h3,
285 .ai-explain-panel-body .markdown-body h4 {
286 color: #0a0a0a;
287 border-bottom-color: #e5e7eb;
288 }
289 .ai-explain-panel-body .markdown-body a { color: #4338ca; }
290 .ai-explain-panel-body .markdown-body code {
291 background: #eef2ff;
292 color: #4338ca;
293 padding: 1px 5px;
294 border-radius: 4px;
295 font-family: var(--font-mono);
296 font-size: 12.5px;
297 }
298 .ai-explain-panel-body .markdown-body pre {
299 background: #0f111a;
300 color: #e6edf3;
301 border: 1px solid #1f2330;
302 border-radius: 8px;
303 padding: 12px 14px;
304 overflow-x: auto;
305 }
306 .ai-explain-panel-body .markdown-body pre code {
307 background: transparent;
308 color: inherit;
309 padding: 0;
310 }
311 .ai-explain-panel-body .markdown-body blockquote {
312 border-left: 3px solid #c7d2fe;
313 background: #f5f3ff;
314 color: #4b5563;
315 padding: 8px 14px;
316 margin: 12px 0;
317 border-radius: 6px;
318 }
319
320 /* Empty-state — dashed card w/ orb + "try" suggestions. */
321 .ai-explain-empty {
322 position: relative;
323 margin: var(--space-4) 0;
324 padding: var(--space-6);
325 border: 1px dashed var(--border-strong, var(--border));
326 border-radius: 14px;
327 background: var(--bg-elevated);
328 text-align: center;
329 overflow: hidden;
330 }
331 .ai-explain-empty-orb {
332 position: absolute;
333 inset: -40% 35% auto 35%;
334 width: 280px; height: 280px;
335 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.08) 45%, transparent 70%);
336 filter: blur(60px);
337 pointer-events: none;
338 z-index: 0;
339 }
340 .ai-explain-empty > * { position: relative; z-index: 1; }
341 .ai-explain-empty h2 {
342 margin: 0 0 6px;
343 font-family: var(--font-display);
344 font-size: 19px;
345 color: var(--text-strong);
346 }
347 .ai-explain-empty p {
348 margin: 0 auto 12px;
349 color: var(--text-muted);
350 font-size: 14px;
351 max-width: 480px;
352 line-height: 1.55;
353 }
354 .ai-explain-empty .ai-explain-suggest {
355 display: inline-flex;
356 flex-direction: column;
357 align-items: flex-start;
358 gap: 6px;
359 margin-top: 12px;
360 text-align: left;
361 font-size: 12.5px;
362 color: var(--text-muted);
363 }
364 .ai-explain-empty .ai-explain-suggest code {
365 font-family: var(--font-mono);
366 background: var(--bg-tertiary);
367 padding: 2px 7px;
368 border-radius: 4px;
369 color: var(--text);
370 }
371
372 /* Powered-by-Claude pill at the bottom. */
373 .ai-explain-poweredby {
374 margin-top: var(--space-5);
375 text-align: center;
376 color: var(--text-muted);
377 font-size: 11.5px;
378 }
379 .ai-explain-poweredby-pill {
380 display: inline-flex;
381 align-items: center;
382 gap: 6px;
383 padding: 4px 10px;
384 border-radius: 9999px;
385 background: rgba(140,109,255,0.08);
386 border: 1px solid rgba(140,109,255,0.22);
387 color: var(--text-muted);
388 font-size: 11px;
389 letter-spacing: 0.04em;
390 text-transform: uppercase;
391 font-weight: 600;
392 }
393 .ai-explain-poweredby-pill .dot {
394 width: 6px; height: 6px;
395 border-radius: 9999px;
396 background: linear-gradient(135deg, #8c6dff, #36c5d6);
397 }
398
399 /* Loading shimmer skeleton — kept available for future async modes. */
400 @keyframes ai-explain-shimmer {
401 0% { background-position: -300px 0; }
402 100% { background-position: 300px 0; }
403 }
404 .ai-explain-skeleton {
405 height: 14px;
406 border-radius: 4px;
407 background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(140,109,255,0.08) 50%, rgba(255,255,255,0.04) 100%);
408 background-size: 600px 100%;
409 animation: ai-explain-shimmer 1.4s linear infinite;
410 margin-bottom: 10px;
411 }
412
413 /* Light-theme: the white panel already pops; just soften the shadow. */
414 :root[data-theme='light'] .ai-explain-panel {
415 box-shadow: 0 1px 0 rgba(0,0,0,0.02), 0 8px 28px rgba(15,16,28,0.08);
416 }
417`;
418
419// Inline copy-to-clipboard handler — reuses the data-attr pattern from
420// admin-integrations.tsx. Safe to embed because listeners are attached by
421// data-attr selector.
422const COPY_SCRIPT = `
423 (function(){
424 var btn = document.querySelector('[data-ai-explain-copy]');
425 var src = document.querySelector('[data-ai-explain-text]');
426 var label = document.querySelector('[data-ai-explain-copy-label]');
427 if (!btn || !src || !label) return;
428 btn.addEventListener('click', function(){
429 var text = src.innerText || src.textContent || '';
430 var done = function(){
431 btn.classList.add('is-copied');
432 label.textContent = 'Copied';
433 setTimeout(function(){
434 btn.classList.remove('is-copied');
435 label.textContent = 'Copy';
436 }, 1800);
437 };
438 if (navigator.clipboard && navigator.clipboard.writeText) {
439 navigator.clipboard.writeText(text).then(done).catch(function(){
440 var ta = document.createElement('textarea');
441 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
442 document.body.appendChild(ta); ta.select();
443 try { document.execCommand('copy'); done(); } catch(e){}
444 document.body.removeChild(ta);
445 });
446 } else {
447 var ta = document.createElement('textarea');
448 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
449 document.body.appendChild(ta); ta.select();
450 try { document.execCommand('copy'); done(); } catch(e){}
451 document.body.removeChild(ta);
452 }
453 });
454 })();
455`;
456
3cbe3d6Claude457interface ResolvedRepo {
458 ownerId: string;
459 ownerUsername: string;
460 repoId: string;
461 repoName: string;
462}
463
464async function resolveRepo(
465 ownerName: string,
466 repoName: string
467): Promise<ResolvedRepo | null> {
468 try {
469 const [ownerRow] = await db
470 .select()
471 .from(users)
472 .where(eq(users.username, ownerName))
473 .limit(1);
474 if (!ownerRow) return null;
475 const [repoRow] = await db
476 .select()
477 .from(repositories)
478 .where(
479 and(
480 eq(repositories.ownerId, ownerRow.id),
481 eq(repositories.name, repoName)
482 )
483 )
484 .limit(1);
485 if (!repoRow) return null;
486 return {
487 ownerId: ownerRow.id,
488 ownerUsername: ownerRow.username,
489 repoId: repoRow.id,
490 repoName: repoRow.name,
491 };
492 } catch {
493 return null;
494 }
495}
496
497async function resolveHeadSha(
498 owner: string,
499 repo: string
500): Promise<string | null> {
501 const branch = await getDefaultBranch(owner, repo);
502 if (!branch) return null;
503 return resolveRef(owner, repo, branch);
504}
505
506aiExplainRoutes.get(
507 "/:owner/:repo/explain",
508 softAuth,
509 async (c) => {
510 const { owner, repo } = c.req.param();
511 const user = c.get("user");
512
513 const resolved = await resolveRepo(owner, repo);
514 if (!resolved) {
515 return c.html(
516 <Layout title="Not Found" user={user}>
517 <div class="empty-state">
518 <h2>Repository not found</h2>
519 </div>
520 </Layout>,
521 404
522 );
523 }
524
525 const sha = await resolveHeadSha(owner, repo);
526 if (!sha) {
527 return c.html(
528 <Layout title={`Explain — ${owner}/${repo}`} user={user}>
529 <RepoHeader owner={owner} repo={repo} />
530 <IssueNav owner={owner} repo={repo} active="code" />
e73e288Claude531 <div class="ai-explain-wrap">
532 <section class="ai-explain-hero">
533 <div class="ai-explain-hero-orb" aria-hidden="true" />
534 <div class="ai-explain-hero-inner">
535 <div class="ai-explain-eyebrow">
536 <span class="pill" aria-hidden="true">
537 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
538 <path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 1 1 7.072 0l-.548.547A3.374 3.374 0 0 0 14 18.469V19a2 2 0 1 1-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
539 </svg>
540 </span>
541 AI · gluecron · explain
542 </div>
543 <h1 class="ai-explain-title">
544 <span class="ai-explain-title-grad">Explain.</span>
545 </h1>
546 <p class="ai-explain-sub">
547 Once you push code to <code style="font-family:var(--font-mono);font-size:13px;background:var(--bg-tertiary);padding:1px 5px;border-radius:4px">{repo}</code>,
548 Claude will read the default branch and write a plain-English
549 tour of the architecture, key modules, and how to get started.
550 </p>
551 </div>
552 </section>
553 <div class="ai-explain-empty">
554 <div class="ai-explain-empty-orb" aria-hidden="true" />
555 <h2>No commits yet</h2>
556 <p>
557 Push some code to <code>{repo}</code> and check back — the
558 explanation is generated from the default branch.
559 </p>
560 <div class="ai-explain-suggest">
561 <span>Try:</span>
562 <code>git push origin main</code>
563 </div>
564 </div>
565 <div class="ai-explain-poweredby">
566 <span class="ai-explain-poweredby-pill">
567 <span class="dot" aria-hidden="true" />
568 Powered by Claude
569 </span>
570 </div>
3cbe3d6Claude571 </div>
e73e288Claude572 <style dangerouslySetInnerHTML={{ __html: styles }} />
3cbe3d6Claude573 </Layout>
574 );
575 }
576
577 // Prefer cache first to avoid calling the AI on every page load.
578 let result = await getCachedExplanation(resolved.repoId, sha);
579 if (!result) {
580 result = await explainCodebase({
581 owner,
582 repo,
583 repositoryId: resolved.repoId,
584 commitSha: sha,
585 });
586 }
587
588 const canRegenerate = !!user && user.id === resolved.ownerId;
589
590 return c.html(
591 <Layout title={`Explain — ${owner}/${repo}`} user={user}>
592 <RepoHeader owner={owner} repo={repo} />
593 <IssueNav owner={owner} repo={repo} active="code" />
e73e288Claude594 <div class="ai-explain-wrap">
595 <section class="ai-explain-hero">
596 <div class="ai-explain-hero-orb" aria-hidden="true" />
597 <div class="ai-explain-hero-inner">
598 <div class="ai-explain-eyebrow">
599 <span class="pill" aria-hidden="true">
600 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
601 <path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 1 1 7.072 0l-.548.547A3.374 3.374 0 0 0 14 18.469V19a2 2 0 1 1-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
602 </svg>
603 </span>
604 AI · gluecron · explain
605 </div>
606 <h1 class="ai-explain-title">
607 <span class="ai-explain-title-grad">Explain.</span>{" "}
608 <span style="color:var(--text-strong)">
609 {owner}/{repo}
610 </span>
611 </h1>
612 <p class="ai-explain-sub">
613 A plain-English tour of this codebase — architecture, key
614 modules, and how to get started. Generated by Claude from the
615 default branch.
616 </p>
617 </div>
618 </section>
619
620 <div class="ai-explain-meta">
621 <span>
622 Commit <code>{sha.slice(0, 7)}</code>
623 </span>
624 <span>·</span>
625 <span>
626 Model <code>{result.model}</code>
627 </span>
628 {result.cached && (
629 <span class="ai-explain-pill">
630 <span class="dot" aria-hidden="true" />
631 cached
632 </span>
633 )}
634 </div>
635
636 <div class="ai-explain-actions">
637 <h2>Codebase explanation</h2>
638 {canRegenerate && (
639 <form
640 method="post"
641 action={`/${owner}/${repo}/explain/regenerate`}
642 style="display: inline"
643 >
644 <button type="submit" class="ai-explain-regen">
645 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
646 <polyline points="23 4 23 10 17 10" />
647 <polyline points="1 20 1 14 7 14" />
648 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
649 </svg>
650 Regenerate
651 </button>
652 </form>
653 )}
654 </div>
655
656 <section class="ai-explain-panel" aria-labelledby="ai-explain-panel-title">
657 <header class="ai-explain-panel-head">
658 <p class="ai-explain-panel-title" id="ai-explain-panel-title">
659 <span class="ai-explain-panel-dot" aria-hidden="true" />
660 Explanation · {owner}/{repo}
661 </p>
662 <button
663 type="button"
664 class="ai-explain-copy"
665 data-ai-explain-copy
666 aria-label="Copy explanation to clipboard"
667 >
668 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
669 <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
670 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
671 </svg>
672 <span data-ai-explain-copy-label>Copy</span>
3cbe3d6Claude673 </button>
e73e288Claude674 </header>
675 <div class="ai-explain-panel-body" data-ai-explain-text>
676 <div class="markdown-body">
677 {html(
678 [renderMarkdown(result.markdown)] as unknown as TemplateStringsArray
679 )}
680 </div>
681 </div>
682 </section>
683
684 <div class="ai-explain-poweredby">
685 <span class="ai-explain-poweredby-pill">
686 <span class="dot" aria-hidden="true" />
687 Powered by Claude
688 </span>
689 </div>
3cbe3d6Claude690 </div>
e73e288Claude691 <style dangerouslySetInnerHTML={{ __html: styles }} />
692 <script dangerouslySetInnerHTML={{ __html: COPY_SCRIPT }} />
3cbe3d6Claude693 </Layout>
694 );
695 }
696);
697
698aiExplainRoutes.post(
699 "/:owner/:repo/explain/regenerate",
700 requireAuth,
701 async (c) => {
702 const { owner, repo } = c.req.param();
703 const user = c.get("user")!;
704
705 const resolved = await resolveRepo(owner, repo);
706 if (!resolved) return c.notFound();
707
708 if (resolved.ownerId !== user.id) {
709 return c.redirect(`/${owner}/${repo}/explain`);
710 }
711
712 const sha = await resolveHeadSha(owner, repo);
713 if (!sha) {
714 return c.redirect(`/${owner}/${repo}/explain`);
715 }
716
717 // Run synchronously so the redirect lands on a fresh result. The helper
718 // itself never throws; worst case the user sees the fallback copy.
719 await explainCodebase({
720 owner,
721 repo,
722 repositoryId: resolved.repoId,
723 commitSha: sha,
724 force: true,
725 });
726
727 return c.redirect(`/${owner}/${repo}/explain`);
728 }
729);
730
731export default aiExplainRoutes;