Commit6f2809funknown_key
feat: AI-powered web code editor with inline suggestions (Copilot built-in)
feat: AI-powered web code editor with inline suggestions (Copilot built-in) Adds Claude-driven ghost-text completions (debounced 800ms, Tab to accept / Esc to dismiss), a code-explain panel, and a GateTest-error fix button directly into the web file editor — no extension required. New API routes POST /api/ai/suggest, /api/ai/explain, /api/ai/fix with 30-req/hr in-memory rate limiting. Feature-flagged behind ANTHROPIC_API_KEY; shows "AI-powered editor" badge when active. https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
3 files changed+799−106f2809fa2ed7c2e476b2f556ace4df059eb90343
3 changed files+799−10
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -41,6 +41,7 @@ import pullRoutes from "./routes/pulls";
4141import prSandboxRoutes from "./routes/pr-sandbox";
4242import devEnvRoutes from "./routes/dev-env";
4343import editorRoutes from "./routes/editor";
44import aiEditorRoutes from "./routes/ai-editor";
4445import forkRoutes from "./routes/fork";
4546import webhookRoutes from "./routes/webhooks";
4647import exploreRoutes from "./routes/explore";
@@ -511,6 +512,9 @@ app.route("/", forkRoutes);
511512// Web file editor
512513app.route("/", editorRoutes);
513514
515// AI editor API routes (inline suggestions, explain, fix)
516app.route("/", aiEditorRoutes);
517
514518// Contributors
515519app.route("/", contributorRoutes);
516520
Addedsrc/routes/ai-editor.ts+230−0View fileUnifiedSplit
@@ -0,0 +1,230 @@
1/**
2 * AI editor API routes — inline suggestions, code explanation, and fix generation.
3 *
4 * All endpoints require an authenticated session and `ANTHROPIC_API_KEY`.
5 * Rate-limited to 30 suggestion requests per user per hour (in-memory).
6 *
7 * Routes:
8 * POST /api/ai/suggest — code completion (ghost text)
9 * POST /api/ai/explain — explain selected code
10 * POST /api/ai/fix — fix code given a GateTest / error message
11 */
12
13import { Hono } from "hono";
14import { requireAuth } from "../middleware/auth";
15import { isAiAvailable, getAnthropic, MODEL_HAIKU, MODEL_SONNET, extractText } from "../lib/ai-client";
16import type { AuthEnv } from "../middleware/auth";
17
18const aiEditor = new Hono<AuthEnv>();
19
20// ─── Rate-limit store ────────────────────────────────────────────────────────
21// Simple in-memory map: userId → array of request timestamps (ms).
22// Cleaned lazily to avoid unbounded growth on high-traffic servers.
23const QUOTA_WINDOW_MS = 60 * 60 * 1_000; // 1 hour
24const QUOTA_MAX = 30;
25
26const suggestQuota = new Map<number, number[]>();
27
28function checkQuota(userId: number): { allowed: boolean; resetInMinutes: number } {
29 const now = Date.now();
30 const cutoff = now - QUOTA_WINDOW_MS;
31 let timestamps = suggestQuota.get(userId) ?? [];
32 // Purge expired entries
33 timestamps = timestamps.filter((t) => t > cutoff);
34
35 if (timestamps.length >= QUOTA_MAX) {
36 const oldest = timestamps[0]!;
37 const resetInMs = oldest + QUOTA_WINDOW_MS - now;
38 const resetInMinutes = Math.ceil(resetInMs / 60_000);
39 suggestQuota.set(userId, timestamps);
40 return { allowed: false, resetInMinutes };
41 }
42
43 timestamps.push(now);
44 suggestQuota.set(userId, timestamps);
45 return { allowed: true, resetInMinutes: 0 };
46}
47
48// ─── POST /api/ai/suggest ────────────────────────────────────────────────────
49aiEditor.post("/api/ai/suggest", requireAuth, async (c) => {
50 if (!isAiAvailable()) {
51 return c.json(
52 { error: "AI suggestions unavailable — ANTHROPIC_API_KEY not configured." },
53 503
54 );
55 }
56
57 const user = c.get("user")!;
58 const quota = checkQuota(user.id);
59 if (!quota.allowed) {
60 return c.json(
61 {
62 error: `AI suggestion quota reached. Resets in ${quota.resetInMinutes} minute${quota.resetInMinutes === 1 ? "" : "s"}.`,
63 },
64 429
65 );
66 }
67
68 let body: { code?: string; language?: string; cursor?: number };
69 try {
70 body = await c.req.json();
71 } catch {
72 return c.json({ error: "Invalid JSON body." }, 400);
73 }
74
75 const code = String(body.code ?? "").slice(0, 2000);
76 const language = String(body.language ?? "plaintext");
77 const cursor = Number(body.cursor ?? code.length);
78
79 const beforeCursor = code.slice(0, cursor);
80 const afterCursor = code.slice(cursor);
81
82 const prompt =
83 `Language: ${language}\n\n` +
84 `Code before cursor:\n${beforeCursor}\n\n` +
85 (afterCursor.trim()
86 ? `Code after cursor (context only — do NOT repeat it):\n${afterCursor}\n\n`
87 : "") +
88 "Complete the code at the cursor position. Return ONLY the completion text to insert, no explanation, no markdown, no code fences.";
89
90 try {
91 const anthropic = getAnthropic();
92 const message = await anthropic.messages.create({
93 model: MODEL_HAIKU,
94 max_tokens: 256,
95 system:
96 "You are a code completion AI. Complete the code snippet at the cursor. " +
97 "Return ONLY the completion text — no explanation, no markdown, no code fences. " +
98 "Keep completions concise (prefer single-line or a few lines). " +
99 "If you have nothing useful to add, return an empty string.",
100 messages: [{ role: "user", content: prompt }],
101 });
102
103 const suggestion = extractText(message).trimEnd();
104 return c.json({ suggestion });
105 } catch (err) {
106 const msg = err instanceof Error ? err.message : "AI request failed.";
107 return c.json({ error: msg }, 500);
108 }
109});
110
111// ─── POST /api/ai/explain ────────────────────────────────────────────────────
112aiEditor.post("/api/ai/explain", requireAuth, async (c) => {
113 if (!isAiAvailable()) {
114 return c.json(
115 { error: "AI explanations unavailable — ANTHROPIC_API_KEY not configured." },
116 503
117 );
118 }
119
120 let body: { code?: string; language?: string };
121 try {
122 body = await c.req.json();
123 } catch {
124 return c.json({ error: "Invalid JSON body." }, 400);
125 }
126
127 const code = String(body.code ?? "").slice(0, 4000);
128 const language = String(body.language ?? "plaintext");
129
130 if (!code.trim()) {
131 return c.json({ error: "No code provided." }, 400);
132 }
133
134 try {
135 const anthropic = getAnthropic();
136 const message = await anthropic.messages.create({
137 model: MODEL_SONNET,
138 max_tokens: 512,
139 system:
140 "You are a senior engineer explaining code to a fellow developer. " +
141 "Be concise and precise. Use plain text (no markdown headers). " +
142 "One or two short paragraphs maximum.",
143 messages: [
144 {
145 role: "user",
146 content: `Language: ${language}\n\nExplain this code:\n\`\`\`\n${code}\n\`\`\``,
147 },
148 ],
149 });
150
151 const explanation = extractText(message).trim();
152 return c.json({ explanation });
153 } catch (err) {
154 const msg = err instanceof Error ? err.message : "AI request failed.";
155 return c.json({ error: msg }, 500);
156 }
157});
158
159// ─── POST /api/ai/fix ────────────────────────────────────────────────────────
160aiEditor.post("/api/ai/fix", requireAuth, async (c) => {
161 if (!isAiAvailable()) {
162 return c.json(
163 { error: "AI fix unavailable — ANTHROPIC_API_KEY not configured." },
164 503
165 );
166 }
167
168 let body: { code?: string; error?: string; language?: string };
169 try {
170 body = await c.req.json();
171 } catch {
172 return c.json({ error: "Invalid JSON body." }, 400);
173 }
174
175 const code = String(body.code ?? "").slice(0, 4000);
176 const errorMsg = String(body.error ?? "").slice(0, 1000);
177 const language = String(body.language ?? "plaintext");
178
179 if (!code.trim()) {
180 return c.json({ error: "No code provided." }, 400);
181 }
182
183 const prompt =
184 `Language: ${language}\n\n` +
185 `Error message:\n${errorMsg}\n\n` +
186 `Code with the problem:\n\`\`\`\n${code}\n\`\`\`\n\n` +
187 "Return a JSON object with two fields: " +
188 '"fix" (the corrected code, complete file content) and ' +
189 '"explanation" (one short sentence describing what was wrong).';
190
191 try {
192 const anthropic = getAnthropic();
193 const message = await anthropic.messages.create({
194 model: MODEL_SONNET,
195 max_tokens: 1024,
196 system:
197 "You are an expert code fixer. Given an error and code, return valid JSON with " +
198 '"fix" (corrected code, no markdown fences) and "explanation" (one-sentence reason). ' +
199 "Return ONLY the JSON object, nothing else.",
200 messages: [{ role: "user", content: prompt }],
201 });
202
203 const raw = extractText(message).trim();
204
205 // Try to parse the JSON response
206 let fix = "";
207 let explanation = "";
208 try {
209 // Strip possible ```json fences
210 const cleaned = raw
211 .replace(/^```(?:json)?\s*/i, "")
212 .replace(/\s*```$/i, "")
213 .trim();
214 const parsed = JSON.parse(cleaned);
215 fix = String(parsed.fix ?? "");
216 explanation = String(parsed.explanation ?? "");
217 } catch {
218 // Fallback: return raw as fix text
219 fix = raw;
220 explanation = "AI returned a fix but could not parse structured response.";
221 }
222
223 return c.json({ fix, explanation });
224 } catch (err) {
225 const msg = err instanceof Error ? err.message : "AI request failed.";
226 return c.json({ error: msg }, 500);
227 }
228});
229
230export default aiEditor;
Modifiedsrc/routes/editor.tsx+565−10View fileUnifiedSplit
@@ -355,6 +355,332 @@ function CODEMIRROR_INIT_SCRIPT(args: {
355355`;
356356}
357357
358/**
359 * AI inline suggestion + explain + fix script.
360 *
361 * Ghost-text suggestions: debounced 800ms after typing stops, sends
362 * the content before the cursor to /api/ai/suggest, shows the result
363 * as dimmed italic text after the cursor inside the textarea.
364 * Tab = accept, Escape = dismiss.
365 *
366 * Explain: sends selected text (or full content) to /api/ai/explain
367 * and shows a panel below the editor.
368 *
369 * Fix: sends error from ?error= query param + code to /api/ai/fix and
370 * shows a panel with an Apply button.
371 *
372 * Only wires up if window.__aiEditorEnabled === true (set by the server
373 * when ANTHROPIC_API_KEY is configured).
374 */
375function AI_EDITOR_SCRIPT(args: {
376 lang: string;
377 gateError: string; // JSON-safe error string, may be empty
378}): string {
379 const safe = (v: string) =>
380 JSON.stringify(v)
381 .split("<").join("\\u003C")
382 .split(">").join("\\u003E")
383 .split("&").join("\\u0026");
384
385 const lang = safe(args.lang);
386 const gateError = safe(args.gateError);
387
388 return `
389(function() {
390 try {
391 if (!window.__aiEditorEnabled) return;
392
393 var lang = ${lang};
394 var gateError = ${gateError};
395
396 // ── Find DOM elements ──────────────────────────────────────────────
397 // Works for both new-file (...-new) and edit (...-edit) pages.
398 var ta = document.querySelector('textarea[name="content"]');
399 if (!ta) return;
400
401 var taField = ta.closest('.editor-field');
402 if (!taField) return;
403
404 // Ensure the textarea is wrapped in .editor-wrapper for positioning
405 if (!ta.parentElement || !ta.parentElement.classList.contains('editor-wrapper')) {
406 var wrapper = document.createElement('div');
407 wrapper.className = 'editor-wrapper';
408 wrapper.style.position = 'relative';
409 ta.parentNode.insertBefore(wrapper, ta);
410 wrapper.appendChild(ta);
411 }
412
413 var editorWrapper = ta.parentElement;
414
415 // Loading spinner
416 var spinner = document.createElement('div');
417 spinner.className = 'ai-loading';
418 spinner.innerHTML = '<div class="ai-spinner"></div><span>AI</span>';
419 editorWrapper.appendChild(spinner);
420
421 // Suggestion hint line
422 var hint = document.createElement('div');
423 hint.className = 'ai-suggestion-hint';
424 hint.textContent = 'Tab to accept suggestion · Esc to dismiss';
425 taField.appendChild(hint);
426
427 // Explain panel
428 var explainPanel = document.createElement('div');
429 explainPanel.className = 'ai-explain-panel';
430 explainPanel.innerHTML = '<div class="ai-explain-panel-header"><span class="ai-explain-panel-title">✨ Code Explanation</span><button class="ai-explain-panel-close" type="button" title="Close">✕</button></div><div class="ai-explain-panel-body"></div>';
431 taField.appendChild(explainPanel);
432
433 // Fix panel (only when there's a gate error)
434 var fixPanel = null;
435 var fixCode = '';
436 if (gateError) {
437 fixPanel = document.createElement('div');
438 fixPanel.className = 'ai-fix-panel';
439 fixPanel.innerHTML = '<div class="ai-fix-panel-header"><span class="ai-fix-panel-title">⚡ AI Fix Suggestion</span></div><div class="ai-fix-panel-explanation"></div><button class="ai-fix-panel-apply" type="button">Apply fix</button>';
440 taField.appendChild(fixPanel);
441 }
442
443 // ── State ──────────────────────────────────────────────────────────
444 var currentSuggestion = '';
445 var suggestionPos = 0; // cursor position when suggestion was fetched
446 var debounceTimer = null;
447 var abortController = null;
448
449 // ── Ghost text rendering via textarea mirror technique ─────────────
450 // We render ghost text as a ::after-like element via a positioned div
451 // that mirrors the textarea's scroll position and font metrics.
452 // This is simpler than a full overlay mirror.
453 var ghost = document.createElement('div');
454 ghost.style.cssText = [
455 'position:absolute',
456 'left:0','top:0','right:0','bottom:0',
457 'pointer-events:none',
458 'overflow:hidden',
459 'white-space:pre-wrap',
460 'word-wrap:break-word',
461 'box-sizing:border-box',
462 'padding:12px 14px',
463 'font-family:var(--font-mono)',
464 'font-size:13px',
465 'line-height:1.55',
466 'color:transparent',
467 'z-index:2',
468 ].join(';');
469 editorWrapper.appendChild(ghost);
470
471 function renderGhost() {
472 if (!currentSuggestion) {
473 ghost.innerHTML = '';
474 hint.classList.remove('visible');
475 return;
476 }
477 var before = ta.value.slice(0, suggestionPos);
478 // Escape HTML
479 var esc = function(s) {
480 return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
481 };
482 ghost.innerHTML = esc(before) +
483 '<span style="color:#666;font-style:italic">' + esc(currentSuggestion) + '</span>';
484 hint.classList.add('visible');
485 }
486
487 function clearSuggestion() {
488 currentSuggestion = '';
489 suggestionPos = 0;
490 renderGhost();
491 }
492
493 // ── Sync ghost scroll with textarea ───────────────────────────────
494 ta.addEventListener('scroll', function() {
495 ghost.scrollTop = ta.scrollTop;
496 ghost.scrollLeft = ta.scrollLeft;
497 });
498
499 // ── Fetch suggestion ──────────────────────────────────────────────
500 function fetchSuggestion() {
501 if (abortController) abortController.abort();
502 abortController = new AbortController();
503
504 var cursor = ta.selectionStart;
505 var code = ta.value;
506 var before = code.slice(0, cursor);
507
508 // Skip if the before-cursor content is too short or ends in whitespace-only
509 if (before.trim().length < 3) return;
510
511 spinner.classList.add('visible');
512
513 fetch('/api/ai/suggest', {
514 method: 'POST',
515 headers: { 'content-type': 'application/json' },
516 credentials: 'same-origin',
517 signal: abortController.signal,
518 body: JSON.stringify({ code: before, language: lang, cursor: before.length }),
519 })
520 .then(function(r) { return r.json(); })
521 .then(function(j) {
522 spinner.classList.remove('visible');
523 if (j && typeof j.suggestion === 'string' && j.suggestion.trim()) {
524 currentSuggestion = j.suggestion;
525 suggestionPos = ta.selectionStart;
526 renderGhost();
527 }
528 if (j && j.error && j.error.includes('quota')) {
529 // Show quota message briefly in hint
530 hint.textContent = j.error;
531 hint.classList.add('visible');
532 setTimeout(function() {
533 hint.textContent = 'Tab to accept suggestion · Esc to dismiss';
534 hint.classList.remove('visible');
535 }, 4000);
536 }
537 })
538 .catch(function(e) {
539 spinner.classList.remove('visible');
540 if (e && e.name !== 'AbortError') {
541 console.debug('[ai-editor] suggest error', e);
542 }
543 });
544 }
545
546 // ── Debounce typing ───────────────────────────────────────────────
547 ta.addEventListener('input', function() {
548 clearSuggestion();
549 if (debounceTimer) clearTimeout(debounceTimer);
550 debounceTimer = setTimeout(fetchSuggestion, 800);
551 });
552
553 // ── Key handling: Tab = accept, Escape = dismiss ──────────────────
554 ta.addEventListener('keydown', function(ev) {
555 if (currentSuggestion) {
556 if (ev.key === 'Tab') {
557 ev.preventDefault();
558 // Insert suggestion at cursor
559 var start = ta.selectionStart;
560 var end = ta.selectionEnd;
561 var before = ta.value.slice(0, start);
562 var after = ta.value.slice(end);
563 ta.value = before + currentSuggestion + after;
564 var newPos = start + currentSuggestion.length;
565 ta.selectionStart = ta.selectionEnd = newPos;
566 // Trigger input event so the form stays in sync
567 ta.dispatchEvent(new Event('input', { bubbles: true }));
568 clearSuggestion();
569 return;
570 }
571 if (ev.key === 'Escape') {
572 ev.preventDefault();
573 clearSuggestion();
574 return;
575 }
576 // Any other key: discard suggestion
577 clearSuggestion();
578 }
579 });
580
581 // Dismiss on click (cursor moved)
582 ta.addEventListener('click', clearSuggestion);
583
584 // ── Explain panel ──────────────────────────────────────────────────
585 var explainBody = explainPanel.querySelector('.ai-explain-panel-body');
586 var explainClose = explainPanel.querySelector('.ai-explain-panel-close');
587 if (explainClose) {
588 explainClose.addEventListener('click', function() {
589 explainPanel.classList.remove('visible');
590 });
591 }
592
593 var explainBtn = document.getElementById('ai-explain-btn');
594 if (explainBtn) {
595 explainBtn.addEventListener('click', function(ev) {
596 ev.preventDefault();
597 var selected = ta.value.slice(ta.selectionStart, ta.selectionEnd);
598 var code = selected.trim() || ta.value;
599 if (!code.trim()) return;
600 explainBtn.disabled = true;
601 explainBody.textContent = 'Asking Claude…';
602 explainPanel.classList.add('visible');
603 fetch('/api/ai/explain', {
604 method: 'POST',
605 headers: { 'content-type': 'application/json' },
606 credentials: 'same-origin',
607 body: JSON.stringify({ code: code.slice(0, 4000), language: lang }),
608 })
609 .then(function(r) { return r.json(); })
610 .then(function(j) {
611 explainBtn.disabled = false;
612 if (j && j.explanation) {
613 explainBody.textContent = j.explanation;
614 } else {
615 explainBody.textContent = (j && j.error) || 'No explanation returned.';
616 }
617 })
618 .catch(function() {
619 explainBtn.disabled = false;
620 explainBody.textContent = 'Network error — please try again.';
621 });
622 });
623 }
624
625 // ── Fix panel ─────────────────────────────────────────────────────
626 if (fixPanel && gateError) {
627 var fixBtn = document.getElementById('ai-fix-btn');
628 var fixExplanation = fixPanel.querySelector('.ai-fix-panel-explanation');
629 var fixApply = fixPanel.querySelector('.ai-fix-panel-apply');
630
631 if (fixBtn) {
632 fixBtn.addEventListener('click', function(ev) {
633 ev.preventDefault();
634 fixBtn.disabled = true;
635 fixExplanation.textContent = 'Analyzing error and generating fix…';
636 fixPanel.classList.add('visible');
637 fetch('/api/ai/fix', {
638 method: 'POST',
639 headers: { 'content-type': 'application/json' },
640 credentials: 'same-origin',
641 body: JSON.stringify({ code: ta.value.slice(0, 4000), error: gateError, language: lang }),
642 })
643 .then(function(r) { return r.json(); })
644 .then(function(j) {
645 fixBtn.disabled = false;
646 if (j && j.fix) {
647 fixCode = j.fix;
648 fixExplanation.textContent = j.explanation || 'Fix ready.';
649 fixApply.style.display = 'inline-flex';
650 } else {
651 fixExplanation.textContent = (j && j.error) || 'Could not generate fix.';
652 fixApply.style.display = 'none';
653 }
654 })
655 .catch(function() {
656 fixBtn.disabled = false;
657 fixExplanation.textContent = 'Network error — please try again.';
658 });
659 });
660
661 // Auto-trigger if gate error is present and editor just loaded
662 setTimeout(function() { fixBtn.click(); }, 600);
663 }
664
665 if (fixApply) {
666 fixApply.addEventListener('click', function() {
667 if (!fixCode) return;
668 if (confirm('Apply the AI fix? This will replace the current editor content.')) {
669 ta.value = fixCode;
670 ta.dispatchEvent(new Event('input', { bubbles: true }));
671 fixPanel.classList.remove('visible');
672 }
673 });
674 }
675 }
676
677 } catch(e) {
678 console.debug('[ai-editor] init error', e);
679 }
680})();
681`;
682}
683
358684// ─── Scoped CSS (.editor-*) ─────────────────────────────────────────────────
359685// Every selector is prefixed `.editor-*` so this surface can't bleed into
360686// the repo header / nav above. Tokens reused from layout (--bg-elevated,
@@ -626,6 +952,160 @@ const editorStyles = `
626952 .editor-field .cm-editor .cm-scroller {
627953 font-family: var(--font-mono);
628954 }
955
956 /* ─── AI inline suggestion overlay ─── */
957 .editor-wrapper {
958 position: relative;
959 }
960 .ai-suggestion {
961 position: absolute;
962 color: #555;
963 font-style: italic;
964 pointer-events: none;
965 white-space: pre;
966 font-family: var(--font-mono);
967 font-size: 13px;
968 line-height: 1.55;
969 }
970 .ai-loading {
971 position: absolute;
972 right: 10px;
973 top: 10px;
974 font-size: 11px;
975 color: #888;
976 display: none;
977 align-items: center;
978 gap: 5px;
979 pointer-events: none;
980 z-index: 10;
981 }
982 .ai-loading.visible { display: flex; }
983 .ai-spinner {
984 width: 10px;
985 height: 10px;
986 border: 1.5px solid rgba(164,139,255,0.3);
987 border-top-color: #a48bff;
988 border-radius: 50%;
989 animation: ai-spin 0.7s linear infinite;
990 }
991 @keyframes ai-spin {
992 to { transform: rotate(360deg); }
993 }
994
995 /* ─── AI explain panel ─── */
996 .ai-explain-panel {
997 background: #1a1a2e;
998 border: 1px solid #2a2a4a;
999 border-radius: 8px;
1000 padding: 14px 16px;
1001 margin-top: 10px;
1002 display: none;
1003 position: relative;
1004 }
1005 .ai-explain-panel.visible { display: block; }
1006 .ai-explain-panel-header {
1007 display: flex;
1008 align-items: center;
1009 justify-content: space-between;
1010 margin-bottom: 8px;
1011 }
1012 .ai-explain-panel-title {
1013 font-size: 11px;
1014 font-weight: 700;
1015 text-transform: uppercase;
1016 letter-spacing: 0.1em;
1017 color: #a48bff;
1018 }
1019 .ai-explain-panel-close {
1020 background: none;
1021 border: none;
1022 color: var(--text-faint);
1023 cursor: pointer;
1024 font-size: 14px;
1025 line-height: 1;
1026 padding: 2px 5px;
1027 border-radius: 4px;
1028 }
1029 .ai-explain-panel-close:hover { color: var(--text); background: rgba(255,255,255,0.06); }
1030 .ai-explain-panel-body {
1031 font-size: 13px;
1032 color: var(--text-muted);
1033 line-height: 1.6;
1034 white-space: pre-wrap;
1035 word-break: break-word;
1036 }
1037
1038 /* ─── AI fix panel ─── */
1039 .ai-fix-panel {
1040 background: #1a1a2e;
1041 border: 1px solid #2a2a4a;
1042 border-left: 3px solid #f59e0b;
1043 border-radius: 8px;
1044 padding: 14px 16px;
1045 margin-top: 10px;
1046 display: none;
1047 }
1048 .ai-fix-panel.visible { display: block; }
1049 .ai-fix-panel-header {
1050 display: flex;
1051 align-items: center;
1052 justify-content: space-between;
1053 margin-bottom: 8px;
1054 }
1055 .ai-fix-panel-title {
1056 font-size: 11px;
1057 font-weight: 700;
1058 text-transform: uppercase;
1059 letter-spacing: 0.1em;
1060 color: #f59e0b;
1061 }
1062 .ai-fix-panel-explanation {
1063 font-size: 12.5px;
1064 color: var(--text-muted);
1065 margin-bottom: 10px;
1066 line-height: 1.5;
1067 }
1068 .ai-fix-panel-apply {
1069 display: inline-flex;
1070 align-items: center;
1071 gap: 5px;
1072 padding: 6px 12px;
1073 background: rgba(245,158,11,0.12);
1074 border: 1px solid rgba(245,158,11,0.35);
1075 border-radius: 6px;
1076 color: #f59e0b;
1077 font-size: 12px;
1078 font-weight: 600;
1079 cursor: pointer;
1080 font: inherit;
1081 transition: background 100ms ease;
1082 }
1083 .ai-fix-panel-apply:hover { background: rgba(245,158,11,0.22); }
1084
1085 /* ─── AI badge ─── */
1086 .ai-powered-badge {
1087 display: inline-flex;
1088 align-items: center;
1089 gap: 4px;
1090 font-size: 11px;
1091 color: #a48bff;
1092 background: rgba(140,109,255,0.08);
1093 border: 1px solid rgba(140,109,255,0.20);
1094 border-radius: 999px;
1095 padding: 3px 8px;
1096 font-weight: 600;
1097 letter-spacing: 0.03em;
1098 white-space: nowrap;
1099 }
1100
1101 /* ─── AI suggestion hint ─── */
1102 .ai-suggestion-hint {
1103 font-size: 11px;
1104 color: var(--text-faint);
1105 margin-top: 5px;
1106 display: none;
1107 }
1108 .ai-suggestion-hint.visible { display: block; }
6291109`;
6301110
6311111function IconBranch() {
@@ -644,6 +1124,7 @@ editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"
6441124 const { owner, repo } = c.req.param();
6451125 const user = c.get("user")!;
6461126 const refAndPath = c.req.param("ref");
1127 const aiEnabled = isAiAvailable();
6471128
6481129 // Parse ref — use first segment
6491130 const slashIdx = refAndPath.indexOf("/");
@@ -682,10 +1163,17 @@ editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"
6821163 <span class="editor-path-sep">/</span>
6831164 <span class="editor-path-name">new file…</span>
6841165 </div>
685 <span class="editor-branch-pill" title="Target branch">
686 <IconBranch />
687 {ref}
688 </span>
1166 <div style="display:flex;align-items:center;gap:8px">
1167 {aiEnabled && (
1168 <span class="ai-powered-badge" title="Claude-powered inline suggestions">
1169 ✨ AI-powered editor
1170 </span>
1171 )}
1172 <span class="editor-branch-pill" title="Target branch">
1173 <IconBranch />
1174 {ref}
1175 </span>
1176 </div>
6891177 </div>
6901178
6911179 <form
@@ -742,6 +1230,16 @@ editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"
7421230 <button type="submit" class="editor-btn editor-btn-primary">
7431231 Commit new file
7441232 </button>
1233 {aiEnabled && (
1234 <button
1235 type="button"
1236 id="ai-explain-btn"
1237 class="editor-btn editor-btn-ai"
1238 title="Explain selected code (or full file) with Claude"
1239 >
1240 Explain code
1241 </button>
1242 )}
7451243 <a href={`/${owner}/${repo}`} class="editor-btn editor-btn-ghost">
7461244 Cancel
7471245 </a>
@@ -756,6 +1254,15 @@ editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"
7561254 }),
7571255 }}
7581256 />
1257 {aiEnabled && (
1258 <script
1259 dangerouslySetInnerHTML={{
1260 __html:
1261 `window.__aiEditorEnabled = true;\n` +
1262 AI_EDITOR_SCRIPT({ lang: "plaintext", gateError: "" }),
1263 }}
1264 />
1265 )}
7591266 </form>
7601267 </div>
7611268
@@ -865,6 +1372,7 @@ editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write
8651372 const { owner, repo } = c.req.param();
8661373 const user = c.get("user")!;
8671374 const refAndPath = c.req.param("ref");
1375 const aiEnabled = isAiAvailable();
8681376
8691377 // Parse ref/path
8701378 const slashIdx = refAndPath.indexOf("/");
@@ -884,6 +1392,10 @@ editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write
8841392
8851393 const fileName = filePath.split("/").pop() || filePath;
8861394 const dirParts = filePath.split("/").slice(0, -1);
1395 const lang = detectEditorLang(filePath);
1396
1397 // GateTest error from query param (e.g. redirect after failed push gate)
1398 const gateError = String(c.req.query("error") ?? "").slice(0, 500);
8871399
8881400 return c.html(
8891401 <Layout title={`Editing ${filePath} — ${owner}/${repo}`} user={user}>
@@ -933,12 +1445,25 @@ editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write
9331445 <span class="editor-path-sep">/</span>
9341446 <span class="editor-path-name">{fileName}</span>
9351447 </div>
936 <span class="editor-branch-pill" title="Target branch">
937 <IconBranch />
938 {ref}
939 </span>
1448
1449 {aiEnabled && (
1450 <span class="ai-powered-badge" title="Claude-powered inline suggestions, explain, and fix">
1451 ✨ AI-powered editor
1452 </span>
1453 )}
1454 <span class="editor-branch-pill" title="Target branch">
1455 <IconBranch />
1456 {ref}
1457 </span>
1458 </div>
9401459 </div>
9411460
1461 {gateError && (
1462
1463 <strong>GateTest error:</strong>{" "}{gateError}
1464 </div>
1465 )}
1466
9421467 <form
9431468 method="post"
9441469 action={`/${owner}/${repo}/edit/${ref}/${filePath}`}
@@ -955,7 +1480,7 @@ editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write
9551480 name="content"
9561481 rows={25}
9571482 spellcheck={false}
958 data-lang={detectEditorLang(filePath)}
1483 data-lang={lang}
9591484 >{blob.content}</textarea>
9601485 </div>
9611486 <div class="editor-field" style="margin-bottom:0">
@@ -982,6 +1507,27 @@ editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write
9821507 >
9831508 {"✨"} Suggest with AI
9841509 </button>
1510 {aiEnabled && (
1511
1512 type="button"
1513 id="ai-explain-btn"
1514 class="editor-btn editor-btn-ai"
1515 title="Explain selected code (or full file) with Claude"
1516 >
1517 Explain code
1518 </button>
1519 )}
1520 {aiEnabled && gateError && (
1521
1522 type="button"
1523 id="ai-fix-btn"
1524 class="editor-btn editor-btn-ai"
1525 title="Ask Claude to fix the GateTest error"
1526 style="border-color:rgba(245,158,11,0.45);color:#f59e0b"
1527 >
1528 ⚡ Fix error
1529 </button>
1530 )}
9851531 <a
9861532 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
9871533 class="editor-btn editor-btn-ghost"
@@ -1005,10 +1551,19 @@ editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write
10051551 __html: CODEMIRROR_INIT_SCRIPT({
10061552 textareaId: "editor-content-edit",
10071553 wrapperId: "editor-cm-edit",
1008 lang: detectEditorLang(filePath),
1554 lang,
10091555 }),
10101556 }}
10111557 />
1558 {aiEnabled && (
1559
1560 dangerouslySetInnerHTML={{
1561 __html:
1562 `window.__aiEditorEnabled = true;\n` +
1563 AI_EDITOR_SCRIPT({ lang, gateError }),
1564 }}
1565 />
1566 )}
10121567 </form>
10131568 </div>
10141569
10151570