feat(specs): live edits stream onto the progress page as Claude writes them #5446
3 changed files+156−9
Modifiedsrc/lib/spec-ai.ts+62−7View fileUnifiedSplit
@@ -17,6 +17,7 @@
1717 * rest of the `ai-*` modules.
1818 */
1919
20import type Anthropic from "@anthropic-ai/sdk";
2021import { config } from "./config";
2122import {
2223 getAnthropic,
@@ -44,8 +45,20 @@ export interface GenerateSpecEditsArgs {
4445 defaultBranch: string;
4546 /** Model override. Default: `claude-sonnet-4-6` as specified by the caller. */
4647 model?: string;
48 /**
49 * Live-progress callback, fired while Claude streams the implementation:
50 * `file` the moment a new file path appears in the generated edits,
51 * `output` (throttled) with total characters streamed. Lets the spec
52 * progress page show edits happening live instead of a spinner. Callback
53 * errors are swallowed — display must never break generation.
54 */
55 onProgress?: (p: SpecEditProgress) => void;
4756}
4857
58export type SpecEditProgress =
59 | { kind: "file"; path: string }
60 | { kind: "output"; chars: number };
61
4962// ---------------------------------------------------------------------------
5063// Tunables
5164// ---------------------------------------------------------------------------
@@ -305,13 +318,55 @@ export async function generateSpecEdits(
305318 let rawText: string;
306319 try {
307320 const client = getAnthropic();
308 const message = await client.messages.create({
309 model,
310 max_tokens: 4096,
311 temperature: 0.2,
312 system: systemPrompt,
313 messages: [{ role: "user", content: userPrompt }],
314 });
321 // `temperature` is gone from both paths: it 400s on Sonnet 5 / Opus 5,
322 // both selectable in the admin model tiers now.
323 //
324 // With a progress listener we stream, so the caller can watch edits
325 // appear live (and large outputs never hit HTTP timeouts). Without one
326 // we keep the plain request — same wire format the test doubles and
327 // other callers already speak.
328 let message: Anthropic.Messages.Message;
329 if (args.onProgress) {
330 const seenPaths = new Set<string>();
331 let streamed = "";
332 let lastOutputEvent = 0;
333 const emit = (p: SpecEditProgress) => {
334 try {
335 args.onProgress?.(p);
336 } catch {
337 /* display must never break generation */
338 }
339 };
340 const stream = client.messages.stream({
341 model,
342 max_tokens: 16000,
343 system: systemPrompt,
344 messages: [{ role: "user", content: userPrompt }],
345 });
346 stream.on("text", (delta) => {
347 streamed += delta;
348 // Surface each file the moment its "path" appears in the JSON.
349 for (const m of streamed.matchAll(/"path"\s*:\s*"([^"\\]{1,300})"/g)) {
350 const p = m[1];
351 if (!seenPaths.has(p)) {
352 seenPaths.add(p);
353 emit({ kind: "file", path: p });
354 }
355 }
356 if (streamed.length - lastOutputEvent > 400) {
357 lastOutputEvent = streamed.length;
358 emit({ kind: "output", chars: streamed.length });
359 }
360 });
361 message = await stream.finalMessage();
362 } else {
363 message = await client.messages.create({
364 model,
365 max_tokens: 4096,
366 system: systemPrompt,
367 messages: [{ role: "user", content: userPrompt }],
368 });
369 }
315370 try {
316371 const { recordAiCost, extractUsage } = await import(
317372 "./ai-cost-tracker"
Modifiedsrc/lib/spec-to-pr.ts+3−0View fileUnifiedSplit
@@ -41,6 +41,8 @@ export type SpecPRArgs = {
4141 spec: string;
4242 baseRef?: string;
4343 userId: string;
44 /** Live-progress passthrough to the AI generation — see spec-ai.ts. */
45 onProgress?: import("./spec-ai").GenerateSpecEditsArgs["onProgress"];
4446};
4547
4648export type SpecPRResult =
@@ -154,6 +156,7 @@ export async function createSpecPR(args: SpecPRArgs): Promise<SpecPRResult> {
154156 fileList: ctx.context.fileList,
155157 relevantFiles: ctx.context.relevantFiles,
156158 defaultBranch: ctx.context.defaultBranch,
159 onProgress: args.onProgress,
157160 });
158161 if (!ai.ok) return { ok: false, error: `AI failed: ${ai.error}` };
159162 if (ai.edits.length === 0) {
Modifiedsrc/routes/specs.tsx+91−2View fileUnifiedSplit
@@ -69,6 +69,10 @@ export interface SpecJob {
6969 spec: string;
7070 repoId: string;
7171 userId: string;
72 // Live-edit telemetry, streamed from the AI generation: file paths in the
73 // order Claude started writing them, and total output characters so far.
74 files: string[];
75 outputChars: number;
7276}
7377
7478const specJobs = new Map<string, SpecJob>();
@@ -151,7 +155,29 @@ async function runSpecJobInBackground(
151155
152156 let result: { ok: true; prNumber: number } | { ok: false; error: string };
153157 try {
154 result = await createSpecPR(args);
158 result = await createSpecPR({
159 ...args,
160 // Live edits: publish every file Claude starts writing so the
161 // progress page renders them as they happen.
162 onProgress: (p: { kind: string; path?: string; chars?: number }) => {
163 if (p.kind === "file" && p.path && !job.files.includes(p.path)) {
164 job.files.push(p.path);
165 } else if (p.kind === "output" && typeof p.chars === "number") {
166 job.outputChars = p.chars;
167 }
168 publish(specJobTopic(job.id), {
169 event: "stage",
170 data: {
171 stage: job.stage,
172 label: job.label,
173 prNumber: job.prNumber,
174 error: job.error,
175 files: job.files,
176 outputChars: job.outputChars,
177 },
178 });
179 },
180 } as Parameters<typeof createSpecPR>[0]);
155181 } catch (err) {
156182 result = {
157183 ok: false,
@@ -967,6 +993,8 @@ specs.post("/:owner/:repo/spec", softAuth, requireAuth, async (c) => {
967993 spec,
968994 repoId: resolved.repoId,
969995 userId: user.id,
996 files: [],
997 outputChars: 0,
970998 };
971999 specJobs.set(jobId, job);
9721000
@@ -1162,6 +1190,34 @@ const progressStyles = `
11621190 cursor: pointer;
11631191 }
11641192 .sp-queue-btn:hover { background: var(--accent); color: var(--bg-elevated); }
1193 /* ── Live edits panel ── */
1194 .sp-live {
1195 margin-top: var(--space-5);
1196 border: 1px solid var(--border);
1197 border-radius: 12px;
1198 background: var(--bg-elevated);
1199 overflow: hidden;
1200 display: none;
1201 }
1202 .sp-live.is-visible { display: block; }
1203 .sp-live-head {
1204 display: flex; align-items: center; justify-content: space-between;
1205 padding: 9px 16px; background: var(--bg-secondary);
1206 border-bottom: 1px solid var(--border);
1207 font-size: 12px; font-weight: 600; text-transform: uppercase;
1208 letter-spacing: .08em; color: var(--text-muted);
1209 }
1210 .sp-live-chars { font-family: var(--font-mono); text-transform: none; letter-spacing: 0; }
1211 .sp-live-file {
1212 display: flex; align-items: center; gap: 10px;
1213 padding: 7px 16px; font-family: var(--font-mono); font-size: 12.5px;
1214 border-bottom: 1px solid var(--border-subtle); color: var(--text);
1215 }
1216 .sp-live-file:last-child { border-bottom: none; }
1217 .sp-live-file::before {
1218 content: '✎'; color: var(--accent); flex-shrink: 0;
1219 }
1220 .sp-live-file.is-latest { color: var(--accent); }
11651221`;
11661222
11671223/** Inline JS for the progress page — polls /status every 2s. ~18 lines. */
@@ -1193,7 +1249,25 @@ const PROGRESS_POLL_JS = `
11931249 if (ec) ec.className = 'sp-error-card is-visible';
11941250 if (em) em.textContent = d.error || 'Unknown error.';
11951251 }
1196 if (d.stage !== 'done' && d.stage !== 'error') { setTimeout(poll, 2000); }
1252 // Live edits: render each file Claude has started writing, newest
1253 // highlighted, plus a streamed-output counter.
1254 if (d.files && d.files.length) {
1255 var lp = document.getElementById('sp-live');
1256 var ll = document.getElementById('sp-live-list');
1257 var lc = document.getElementById('sp-live-chars');
1258 if (lp) lp.className = 'sp-live is-visible';
1259 if (lc && d.outputChars) lc.textContent = (d.outputChars/1000).toFixed(1) + 'k chars streamed';
1260 if (ll) {
1261 ll.innerHTML = '';
1262 d.files.forEach(function(f, i) {
1263 var row = document.createElement('div');
1264 row.className = 'sp-live-file' + (i === d.files.length - 1 && d.stage === 'writing' ? ' is-latest' : '');
1265 row.textContent = f;
1266 ll.appendChild(row);
1267 });
1268 }
1269 }
1270 if (d.stage !== 'done' && d.stage !== 'error') { setTimeout(poll, 1000); }
11971271 }
11981272 function poll() {
11991273 fetch(url).then(function(r){ return r.json(); }).then(update).catch(function(){ setTimeout(poll, 3000); });
@@ -1264,6 +1338,19 @@ function ProgressPage({
12641338 </li>
12651339 </ul>
12661340
1341 {/* Live edits — revealed the moment Claude starts writing files */}
1342 <div id="sp-live" class={`sp-live${job.files.length ? " is-visible" : ""}`}>
1343 <div class="sp-live-head">
1344 <span>Live edits</span>
1345 <span id="sp-live-chars" class="sp-live-chars"></span>
1346 </div>
1347 <div id="sp-live-list">
1348 {job.files.map((f) => (
1349 <div class="sp-live-file">{f}</div>
1350 ))}
1351 </div>
1352 </div>
1353
12671354 {/* Done card — hidden until JS reveals it */}
12681355 <div
12691356 id="sp-done-card"
@@ -1470,6 +1557,8 @@ specs.get("/:owner/:repo/spec/:jobId/status", softAuth, requireAuth, async (c) =
14701557 label: job.label,
14711558 prNumber: job.prNumber,
14721559 error: job.error,
1560 files: job.files,
1561 outputChars: job.outputChars,
14731562 startedAt: job.startedAt,
14741563 completedAt: job.completedAt,
14751564 });
14761565
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts