Public: anyone can read this; the crew can change it.
The app's page tails the crew's log while the app is coming alive #144
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/88-the-app-s-page-tails-the-crew-s-log-whil → main 1 commit fork
6 files
changed +398
−119
src/web/client.ts +98 −56
| @@ -256,6 +256,45 @@ export const mirrorJs = /* js */ ` | ||
| 256 | 256 | })(); |
| 257 | 257 | `; |
| 258 | 258 | |
| 259 | /** | |
| 260 | * Turning one log line into an element, the way every reader of a run's log | |
| 261 | * draws it: a tool call bolds its name, the forge's own words go quiet and | |
| 262 | * italic, "you: " gets its own highlighted row, and anything else is the | |
| 263 | * agent speaking as \`who\`. Shared by \`runLogJs\`, \`studioRunJs\` and | |
| 264 | * \`appTailJs\` so a fourth live log never grows a fourth copy of it — | |
| 265 | * interpolated inline, so \`who\` closes over whichever script uses it. | |
| 266 | */ | |
| 267 | const logLineJs = /* js */ ` | |
| 268 | var FORGE = /^(claude:|done:|failed:|paused|declined:|cloning |branch |forked |pushing |push refused|clone kept|nothing to propose|opened pull|updated pull|committed the|could not|checking out|deploy |data: |\\$ |session |the session|interrupted|passed in|install |test |start |ready |process )/; | |
| 269 | function line(kind, speaker, fill) { | |
| 270 | var el = document.createElement('div'); | |
| 271 | el.className = 'ln ln-' + kind; | |
| 272 | var w = document.createElement('span'); | |
| 273 | w.className = 'who'; | |
| 274 | w.textContent = speaker; | |
| 275 | var t = document.createElement('span'); | |
| 276 | t.className = 'txt'; | |
| 277 | fill(t); | |
| 278 | el.appendChild(w); | |
| 279 | el.appendChild(t); | |
| 280 | return el; | |
| 281 | } | |
| 282 | function draw(text) { | |
| 283 | if (text.indexOf('you: ') === 0) return line('you', 'you', function (t) { t.textContent = text.slice(5); }); | |
| 284 | if (text.indexOf('\\u2192 ') === 0) { | |
| 285 | var rest = text.slice(2), at = rest.indexOf(' '); | |
| 286 | return line('tool', '', function (t) { | |
| 287 | var b = document.createElement('b'); | |
| 288 | b.textContent = at === -1 ? rest : rest.slice(0, at); | |
| 289 | t.appendChild(b); | |
| 290 | t.appendChild(document.createTextNode(at === -1 ? '' : ' ' + rest.slice(at + 1))); | |
| 291 | }); | |
| 292 | } | |
| 293 | if (FORGE.test(text)) return line('forge', 'forge', function (t) { t.textContent = text; }); | |
| 294 | return line('say', who, function (t) { t.textContent = text; }); | |
| 295 | } | |
| 296 | `; | |
| 297 | ||
| 259 | 298 | /** |
| 260 | 299 | * A run's log, live. The page renders every line already written and, for |
| 261 | 300 | * a run still going, refreshes itself without a script; this replaces that |
| @@ -324,36 +363,9 @@ export const runLogJs = /* js */ ` | ||
| 324 | 363 | if (elapsed && !elapsed.getAttribute('data-finished')) elapsed.setAttribute('data-finished', new Date().toISOString()); |
| 325 | 364 | } |
| 326 | 365 | |
| 327 | var FORGE = /^(claude:|done:|failed:|paused|declined:|cloning |branch |forked |pushing |push refused|clone kept|nothing to propose|opened pull|updated pull|committed the|could not|checking out|deploy |data: |\\$ |session |the session|interrupted|passed in|install |test |start |ready |process )/; | |
| 328 | 366 | // The same sorting the page did for the lines it rendered: a speaker |
| 329 | 367 | // column and the words, so a reader can tell who said what at a glance. |
| 330 | function line(kind, speaker, fill) { | |
| 331 | var el = document.createElement('div'); | |
| 332 | el.className = 'ln ln-' + kind; | |
| 333 | var w = document.createElement('span'); | |
| 334 | w.className = 'who'; | |
| 335 | w.textContent = speaker; | |
| 336 | var t = document.createElement('span'); | |
| 337 | t.className = 'txt'; | |
| 338 | fill(t); | |
| 339 | el.appendChild(w); | |
| 340 | el.appendChild(t); | |
| 341 | return el; | |
| 342 | } | |
| 343 | function draw(text) { | |
| 344 | if (text.indexOf('you: ') === 0) return line('you', 'you', function (t) { t.textContent = text.slice(5); }); | |
| 345 | if (text.indexOf('→ ') === 0) { | |
| 346 | var rest = text.slice(2), at = rest.indexOf(' '); | |
| 347 | return line('tool', '', function (t) { | |
| 348 | var b = document.createElement('b'); | |
| 349 | b.textContent = at === -1 ? rest : rest.slice(0, at); | |
| 350 | t.appendChild(b); | |
| 351 | t.appendChild(document.createTextNode(at === -1 ? '' : ' ' + rest.slice(at + 1))); | |
| 352 | }); | |
| 353 | } | |
| 354 | if (FORGE.test(text)) return line('forge', 'forge', function (t) { t.textContent = text; }); | |
| 355 | return line('say', who, function (t) { t.textContent = text; }); | |
| 356 | } | |
| 368 | ${logLineJs} | |
| 357 | 369 | function add(el) { |
| 358 | 370 | log.insertBefore(el, pulse && pulse.parentNode === log ? pulse : jump); |
| 359 | 371 | if (follow) toEnd(); else if (jump) jump.hidden = false; |
| @@ -517,34 +529,7 @@ export const studioRunJs = /* js */ ` | ||
| 517 | 529 | if (elapsed && !elapsed.getAttribute('data-finished')) elapsed.setAttribute('data-finished', new Date().toISOString()); |
| 518 | 530 | } |
| 519 | 531 | |
| 520 | var FORGE = /^(claude:|done:|failed:|paused|declined:|cloning |branch |forked |pushing |push refused|clone kept|nothing to propose|opened pull|updated pull|committed the|could not|checking out|deploy |data: |\\$ |session |the session|interrupted|passed in|install |test |start |ready |process )/; | |
| 521 | function line(kind, speaker, fill) { | |
| 522 | var el = document.createElement('div'); | |
| 523 | el.className = 'ln ln-' + kind; | |
| 524 | var w = document.createElement('span'); | |
| 525 | w.className = 'who'; | |
| 526 | w.textContent = speaker; | |
| 527 | var t = document.createElement('span'); | |
| 528 | t.className = 'txt'; | |
| 529 | fill(t); | |
| 530 | el.appendChild(w); | |
| 531 | el.appendChild(t); | |
| 532 | return el; | |
| 533 | } | |
| 534 | function draw(text) { | |
| 535 | if (text.indexOf('you: ') === 0) return line('you', 'you', function (t) { t.textContent = text.slice(5); }); | |
| 536 | if (text.indexOf('\\u2192 ') === 0) { | |
| 537 | var rest = text.slice(2), at = rest.indexOf(' '); | |
| 538 | return line('tool', '', function (t) { | |
| 539 | var b = document.createElement('b'); | |
| 540 | b.textContent = at === -1 ? rest : rest.slice(0, at); | |
| 541 | t.appendChild(b); | |
| 542 | t.appendChild(document.createTextNode(at === -1 ? '' : ' ' + rest.slice(at + 1))); | |
| 543 | }); | |
| 544 | } | |
| 545 | if (FORGE.test(text)) return line('forge', 'forge', function (t) { t.textContent = text; }); | |
| 546 | return line('say', who, function (t) { t.textContent = text; }); | |
| 547 | } | |
| 532 | ${logLineJs} | |
| 548 | 533 | function add(el) { |
| 549 | 534 | log.insertBefore(el, pulse && pulse.parentNode === log ? pulse : jump); |
| 550 | 535 | if (cap) { |
| @@ -635,6 +620,63 @@ export const studioRunJs = /* js */ ` | ||
| 635 | 620 | })(); |
| 636 | 621 | `; |
| 637 | 622 | |
| 623 | /** | |
| 624 | * The tail beside a running row on the app's own page in the studio | |
| 625 | * (\`studio/app.ts\`, \`#app-run-tail\`): the same lines a run's own page | |
| 626 | * streams, a handful at a time, so a person on the app's page sees the crew | |
| 627 | * at work without opening the run. \`data-cap\` bounds what stays in the DOM | |
| 628 | * as new lines arrive — a chatty agent left open cannot grow the page | |
| 629 | * without bound. There is exactly one of these on a page at once, so it | |
| 630 | * opens one more stream beside the app's own state stream and the page's | |
| 631 | * live regions, never one per row. It carries no controls of its own: Stop | |
| 632 | * lives on the row, the full transcript is one click away, and the row's own | |
| 633 | * word swaps in once the run is over (the page's existing live region for | |
| 634 | * "Now" already repaints on \`run.*\`) — this only ever appends lines and | |
| 635 | * stops. | |
| 636 | */ | |
| 637 | export const appTailJs = /* js */ ` | |
| 638 | (function () { | |
| 639 | var tail = document.getElementById('app-run-tail'); | |
| 640 | if (!tail || typeof EventSource === 'undefined') return; | |
| 641 | var url = tail.getAttribute('data-stream'); | |
| 642 | if (!url) return; | |
| 643 | var who = tail.getAttribute('data-who') || 'the crew'; | |
| 644 | var cap = parseInt(tail.getAttribute('data-cap') || '0', 10) || 0; | |
| 645 | // The no-script half refreshed the whole page while this run went; the | |
| 646 | // stream above does better, so it steps aside the moment it can run. | |
| 647 | var meta = document.querySelector('meta[http-equiv="refresh"]'); | |
| 648 | if (meta) meta.remove(); | |
| 649 | ||
| 650 | ${logLineJs} | |
| 651 | function add(el) { | |
| 652 | tail.appendChild(el); | |
| 653 | if (cap) { | |
| 654 | var kept = tail.querySelectorAll('.ln'); | |
| 655 | while (kept.length > cap) { tail.removeChild(kept[0]); kept = tail.querySelectorAll('.ln'); } | |
| 656 | } | |
| 657 | tail.scrollTop = tail.scrollHeight; | |
| 658 | } | |
| 659 | ||
| 660 | var source = new EventSource(url); | |
| 661 | var seeded = false; | |
| 662 | source.addEventListener('line', function (event) { | |
| 663 | if (!seeded) { | |
| 664 | if (event.lastEventId === '1') { | |
| 665 | Array.prototype.slice.call(tail.querySelectorAll('.ln')).forEach(function (el) { tail.removeChild(el); }); | |
| 666 | } | |
| 667 | seeded = true; | |
| 668 | } | |
| 669 | var text = event.data; | |
| 670 | try { var parsed = JSON.parse(text); if (typeof parsed === 'string') text = parsed; } catch (err) {} | |
| 671 | add(draw(text)); | |
| 672 | }); | |
| 673 | // The run is over: stop appending. The row above settles on its final | |
| 674 | // word the next time the page's own "Now" region repaints, which the | |
| 675 | // \`run.finished\` event this very moment triggers. | |
| 676 | source.addEventListener('done', function () { source.close(); }); | |
| 677 | })(); | |
| 678 | `; | |
| 679 | ||
| 638 | 680 | /** |
| 639 | 681 | * The code browser: the explorer column, the preview it paints beside itself, |
| 640 | 682 | * and the fuzzy file finder. |
src/web/studio/app.ts +61 −28
| @@ -24,8 +24,9 @@ import { getUsersByIds } from "../../core/users.ts"; | ||
| 24 | 24 | import { autonomyStatus } from "../../crew/autonomy.ts"; |
| 25 | 25 | import { repoDir } from "../../git/repos.ts"; |
| 26 | 26 | import type { Ctx } from "../../http/context.ts"; |
| 27 | import { readRunLog } from "../../runs/log.ts"; | |
| 27 | 28 | import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts"; |
| 28 | import { asksJs, liveRegionsJs } from "../client.ts"; | |
| 29 | import { appTailJs, asksJs, liveRegionsJs } from "../client.ts"; | |
| 29 | 30 | import { html, when, type Html } from "../html.ts"; |
| 30 | 31 | import { page } from "../layout.ts"; |
| 31 | 32 | import { liveClientJs, liveMount } from "../live.ts"; |
| @@ -41,6 +42,7 @@ import { | ||
| 41 | 42 | } from "../ui.ts"; |
| 42 | 43 | import { asksPanel, decisionsFor } from "./asks.ts"; |
| 43 | 44 | import { withLikely } from "./leans.ts"; |
| 45 | import { LOG_TAIL_CAP, logBox } from "./log.ts"; | |
| 44 | 46 | import { appPanels } from "./previews.ts"; |
| 45 | 47 | import { stopForm } from "./run.ts"; |
| 46 | 48 | import { studioCss } from "./style.ts"; |
| @@ -220,6 +222,58 @@ export async function studioApp( | ||
| 220 | 222 | // viewer who cannot write here reads "Now" as the app's own story, never |
| 221 | 223 | // by the agent's name. |
| 222 | 224 | const nameFor = (name: string): string => (ownerFacts ? name : "the crew"); |
| 225 | ||
| 226 | // The one run actually going, if any: its row gets a tail of its own | |
| 227 | // log — the last handful of lines, streamed live — beside it. Only one, | |
| 228 | // so the page never opens a stream per row. | |
| 229 | const activeRun = rowsNow.find( | |
| 230 | (r) => r.kind === "agent" && r.state === "running", | |
| 231 | ); | |
| 232 | const activeTail = activeRun | |
| 233 | ? logBox({ | |
| 234 | id: "app-run-tail", | |
| 235 | who: nameFor( | |
| 236 | activeRun.actorId === null | |
| 237 | ? "crew" | |
| 238 | : (actors.get(activeRun.actorId)?.username ?? "crew"), | |
| 239 | ), | |
| 240 | cap: LOG_TAIL_CAP, | |
| 241 | lines: readRunLog(ctx.app.config.dataDir, activeRun.id).lines, | |
| 242 | stream: `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${activeRun.id}/log?follow=1`, | |
| 243 | className: "log-tail", | |
| 244 | }) | |
| 245 | : undefined; | |
| 246 | ||
| 247 | const runRows: Html[] = []; | |
| 248 | for (const r of rowsNow) { | |
| 249 | const { word, note } = await runWord(wordCtx, r); | |
| 250 | const who = nameFor( | |
| 251 | r.actorId === null ? "crew" : (actors.get(r.actorId)?.username ?? "crew"), | |
| 252 | ); | |
| 253 | const about = r.issueId === null ? undefined : issues.get(r.issueId); | |
| 254 | const stop = stopForm(ctx, repo, r); | |
| 255 | runRows.push( | |
| 256 | row({ | |
| 257 | ...(stop ? {} : { href: `${base}/runs/${r.id}`, nav: true }), | |
| 258 | lead: avatar(who), | |
| 259 | title: stop | |
| 260 | ? html`<a href="${base}/runs/${r.id}">${about?.title ?? note}</a>` | |
| 261 | : (about?.title ?? note), | |
| 262 | meta: html`${who} · ${word === "working" ? "working now" : note} | |
| 263 | ${word === "working" | |
| 264 | ? null | |
| 265 | : html` · ${time(r.finishedAt ?? r.createdAt)}`}`, | |
| 266 | trail: stop | |
| 267 | ? html`<span class="studio-ask-actions" | |
| 268 | >${wordBadge(word)} ${stop}</span | |
| 269 | >` | |
| 270 | : wordBadge(word), | |
| 271 | }), | |
| 272 | ); | |
| 273 | if (activeTail !== undefined && r.id === activeRun?.id) | |
| 274 | runRows.push(html`<li class="log-tail-row">${activeTail}</li>`); | |
| 275 | } | |
| 276 | ||
| 223 | 277 | const nowRows: Html[] = [ |
| 224 | 278 | ...(await Promise.all( |
| 225 | 279 | openPulls.map(async (p) => { |
| @@ -235,32 +289,7 @@ export async function studioApp( | ||
| 235 | 289 | }); |
| 236 | 290 | }), |
| 237 | 291 | )), |
| 238 | ...(await Promise.all( | |
| 239 | rowsNow.map(async (r) => { | |
| 240 | const { word, note } = await runWord(wordCtx, r); | |
| 241 | const who = nameFor( | |
| 242 | r.actorId === null ? "crew" : (actors.get(r.actorId)?.username ?? "crew"), | |
| 243 | ); | |
| 244 | const about = r.issueId === null ? undefined : issues.get(r.issueId); | |
| 245 | const stop = stopForm(ctx, repo, r); | |
| 246 | return row({ | |
| 247 | ...(stop ? {} : { href: `${base}/runs/${r.id}`, nav: true }), | |
| 248 | lead: avatar(who), | |
| 249 | title: stop | |
| 250 | ? html`<a href="${base}/runs/${r.id}">${about?.title ?? note}</a>` | |
| 251 | : (about?.title ?? note), | |
| 252 | meta: html`${who} · ${word === "working" ? "working now" : note} | |
| 253 | ${word === "working" | |
| 254 | ? null | |
| 255 | : html` · ${time(r.finishedAt ?? r.createdAt)}`}`, | |
| 256 | trail: stop | |
| 257 | ? html`<span class="studio-ask-actions" | |
| 258 | >${wordBadge(word)} ${stop}</span | |
| 259 | >` | |
| 260 | : wordBadge(word), | |
| 261 | }); | |
| 262 | }), | |
| 263 | )), | |
| 292 | ...runRows, | |
| 264 | 293 | ...waiting.map((i) => { |
| 265 | 294 | const who = nameFor(i.assigneeName ?? "crew"); |
| 266 | 295 | return row({ |
| @@ -444,6 +473,10 @@ export async function studioApp( | ||
| 444 | 473 | palette: repoPalette(repo), |
| 445 | 474 | body, |
| 446 | 475 | styleCss: studioCss, |
| 447 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${asksJs}`, | |
| 476 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${asksJs}\n${appTailJs}`, | |
| 477 | // The no-script half of the tail: while the crew's run goes, the page | |
| 478 | // refreshes itself, the way the run's own page does; the script above | |
| 479 | // removes this the moment it can stream lines instead. | |
| 480 | ...(activeRun ? { refresh: { seconds: 5 } } : {}), | |
| 448 | 481 | }); |
| 449 | 482 | } |
src/web/studio/log.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | /** | |
| 2 | * A run's log, windowed: the bound, the truncation notice, and the box both | |
| 3 | * a run's full transcript (`studio/run.ts`) and a compact tail beside a | |
| 4 | * running row (`studio/app.ts`, and any page that wants the same peek) | |
| 5 | * render into. One place for the shape, so a second page never grows a | |
| 6 | * second copy of it. | |
| 7 | */ | |
| 8 | ||
| 9 | import { logLine } from "../pages/runs.ts"; | |
| 10 | import { html, when, type Html } from "../html.ts"; | |
| 11 | ||
| 12 | /** At most this many of a run's own lines held in a full transcript at | |
| 13 | * once — the live script drops the oldest as a new one lands, past this. */ | |
| 14 | export const RUN_TRANSCRIPT_CAP = 400; | |
| 15 | ||
| 16 | /** A tail beside a row names only the last handful of lines — enough to say | |
| 17 | * the crew is working, not a second transcript. */ | |
| 18 | export const LOG_TAIL_CAP = 6; | |
| 19 | ||
| 20 | /** The last `cap` lines of a log, and how many were left out. */ | |
| 21 | export function logWindow( | |
| 22 | lines: readonly string[], | |
| 23 | cap: number, | |
| 24 | ): { shown: readonly string[]; hidden: number } { | |
| 25 | const shown = lines.slice(-cap); | |
| 26 | return { shown, hidden: lines.length - shown.length }; | |
| 27 | } | |
| 28 | ||
| 29 | /** | |
| 30 | * A bounded window of a run's log: the truncation notice when lines were | |
| 31 | * left out, the lines themselves in the page's own voice (`logLine`), the | |
| 32 | * pulse for a run still going, and any control the caller wants inside the | |
| 33 | * same box (the run page's "newer below" button, say) — the live script | |
| 34 | * appends before that control, whichever one is there. | |
| 35 | */ | |
| 36 | export function logBox(opts: { | |
| 37 | id: string; | |
| 38 | who: string; | |
| 39 | cap: number; | |
| 40 | lines: readonly string[]; | |
| 41 | stream?: string; | |
| 42 | pulse?: boolean; | |
| 43 | className?: string; | |
| 44 | extra?: Html; | |
| 45 | }): Html { | |
| 46 | const { shown, hidden } = logWindow(opts.lines, opts.cap); | |
| 47 | return html`<div | |
| 48 | class="run-log${opts.className ? ` ${opts.className}` : ""}" | |
| 49 | id="${opts.id}" | |
| 50 | data-who="${opts.who}" | |
| 51 | data-cap="${opts.cap}" | |
| 52 | ${opts.stream ? html`data-stream="${opts.stream}"` : null} | |
| 53 | > | |
| 54 | ${when( | |
| 55 | hidden > 0, | |
| 56 | () => | |
| 57 | html`<p class="muted small"> | |
| 58 | Holding the last ${opts.cap} lines here; the run's own log keeps | |
| 59 | the rest. | |
| 60 | </p>`, | |
| 61 | )} | |
| 62 | ${shown.map((l) => logLine(l, opts.who))} | |
| 63 | ${when( | |
| 64 | opts.pulse === true, | |
| 65 | () => | |
| 66 | html`<div class="ln ln-pulse" id="${opts.id}-pulse"> | |
| 67 | <span class="who">${opts.who}</span | |
| 68 | ><span class="txt"><i></i><i></i><i></i></span> | |
| 69 | </div>`, | |
| 70 | )} | |
| 71 | ${opts.extra ?? null} | |
| 72 | </div>`; | |
| 73 | } |
src/web/studio/run.ts +13 −35
| @@ -27,7 +27,8 @@ import { html, when, type Html } from "../html.ts"; | ||
| 27 | 27 | import { page } from "../layout.ts"; |
| 28 | 28 | import { btn, icon } from "../ui.ts"; |
| 29 | 29 | import { previewCard } from "../pages/apps.ts"; |
| 30 | import { costText, durationText, logLine, runTitle } from "../pages/runs.ts"; | |
| 30 | import { costText, durationText, runTitle } from "../pages/runs.ts"; | |
| 31 | import { logBox, RUN_TRANSCRIPT_CAP } from "./log.ts"; | |
| 31 | 32 | import { studioCss } from "./style.ts"; |
| 32 | 33 | import { runWord, wordBadge, type WordCtx } from "./words.ts"; |
| 33 | 34 | |
| @@ -52,10 +53,6 @@ export function stopForm(ctx: Ctx, repo: Repo, run: Run): Html | null { | ||
| 52 | 53 | } |
| 53 | 54 | |
| 54 | 55 | |
| 55 | /** At most this many of the run's own lines held in the page at once — the | |
| 56 | * live script drops the oldest as a new one lands, past this. */ | |
| 57 | const RUN_TRANSCRIPT_CAP = 400; | |
| 58 | ||
| 59 | 56 | /** |
| 60 | 57 | * GET /:owner/:name/runs/:id in the studio: who is on it, the tell it came |
| 61 | 58 | * from, how long it has gone and what it has cost, the transcript itself, |
| @@ -95,8 +92,6 @@ export async function studioRun( | ||
| 95 | 92 | const unfinished = !isTerminal(run.state); |
| 96 | 93 | const duration = durationText(run); |
| 97 | 94 | const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log?follow=1`; |
| 98 | const shown = lines.slice(-RUN_TRANSCRIPT_CAP); | |
| 99 | const hidden = lines.length - shown.length; | |
| 100 | 95 | |
| 101 | 96 | // The pull this run's tell has become, once one exists: the tell itself |
| 102 | 97 | // when the run is already a pull's own (feedback, a failed check, a |
| @@ -188,41 +183,24 @@ export async function studioRun( | ||
| 188 | 183 | </p>`, |
| 189 | 184 | )} |
| 190 | 185 | ${when( |
| 191 | shown.length === 0, | |
| 186 | lines.length === 0, | |
| 192 | 187 | () => |
| 193 | 188 | html`<p class="muted small" id="run-empty"> |
| 194 | 189 | Nothing has been logged yet. |
| 195 | 190 | </p>`, |
| 196 | 191 | )} |
| 197 | 192 | <div class="run-live" id="run-live" data-state="${run.state}"> |
| 198 | <div | |
| 199 | class="run-log" | |
| 200 | id="run-log" | |
| 201 | data-who="${who}" | |
| 202 | data-cap="${RUN_TRANSCRIPT_CAP}" | |
| 203 | ${unfinished ? html`data-stream="${stream}"` : null} | |
| 204 | > | |
| 205 | ${when( | |
| 206 | hidden > 0, | |
| 207 | () => | |
| 208 | html`<p class="muted small"> | |
| 209 | Holding the last ${RUN_TRANSCRIPT_CAP} lines here; the run's own | |
| 210 | log keeps the rest. | |
| 211 | </p>`, | |
| 212 | )} | |
| 213 | ${shown.map((l) => logLine(l, who))} | |
| 214 | ${when( | |
| 215 | run.state === "running", | |
| 216 | () => | |
| 217 | html`<div class="ln ln-pulse" id="run-pulse"> | |
| 218 | <span class="who">${who}</span | |
| 219 | ><span class="txt"><i></i><i></i><i></i></span> | |
| 220 | </div>`, | |
| 221 | )} | |
| 222 | <button type="button" class="run-jump" id="run-jump" hidden> | |
| 193 | ${logBox({ | |
| 194 | id: "run-log", | |
| 195 | who, | |
| 196 | cap: RUN_TRANSCRIPT_CAP, | |
| 197 | lines, | |
| 198 | ...(unfinished ? { stream } : {}), | |
| 199 | pulse: run.state === "running", | |
| 200 | extra: html`<button type="button" class="run-jump" id="run-jump" hidden> | |
| 223 | 201 | newer below |
| 224 | </button> | |
| 225 | </div> | |
| 202 | </button>`, | |
| 203 | })} | |
| 226 | 204 | ${when( |
| 227 | 205 | writer && run.kind === "agent" && issue !== undefined, |
| 228 | 206 | () => |
src/web/style/content.ts +5
| @@ -649,6 +649,11 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 649 | 649 | .run-summary { margin: 10px 0 0; text-wrap: pretty; } |
| 650 | 650 | .run-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 12px 0; } |
| 651 | 651 | .run-actions form { display: inline-flex; } |
| 652 | /* A tail beside a running row (studio/app.ts): the same log, a peek at it | |
| 653 | — fixed height so nothing around it shifts as lines arrive, and it | |
| 654 | scrolls itself rather than the page. */ | |
| 655 | .log-tail-row { padding: 0 14px 10px; } | |
| 656 | .run-log.log-tail { max-height: 8.5em; min-height: 0; margin: 6px 0 0; font-size: 12.5px; } | |
| 652 | 657 | .table .truncate { max-width: 34ch; display: inline-block; vertical-align: bottom; } |
| 653 | 658 | .agent-brief { white-space: pre-wrap; font-size: 13px; color: var(--muted-fg); max-height: 6.5em; overflow: hidden; } |
| 654 | 659 |
test/studio.test.ts +148
| @@ -49,6 +49,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 49 | 49 | import { pendingLeans, pullWord, runWord, type WordCtx } from "../src/web/pages/studio.ts"; |
| 50 | 50 | import { servedPhrase } from "../src/web/ui.ts"; |
| 51 | 51 | import { repoDir } from "../src/git/repos.ts"; |
| 52 | import { appendRunLog } from "../src/runs/log.ts"; | |
| 52 | 53 | |
| 53 | 54 | /** One transparent pixel, dropped where a look would sit. */ |
| 54 | 55 | const PNG = Buffer.from( |
| @@ -2802,6 +2803,153 @@ describe("working: a run's own page in the studio", () => { | ||
| 2802 | 2803 | }); |
| 2803 | 2804 | }); |
| 2804 | 2805 | |
| 2806 | /** | |
| 2807 | * Issue #88: the app's page tails the crew's log while it works — the last | |
| 2808 | * handful of lines beside the running row, live, bounded, and with a | |
| 2809 | * no-script fallback that behaves like the run page's own. Its own repo and | |
| 2810 | * app, so no other test's still-running run ever counts as "the active run" | |
| 2811 | * here. | |
| 2812 | */ | |
| 2813 | describe("the app's page tails the crew's log while it works", () => { | |
| 2814 | let dataDir: string; | |
| 2815 | let app: App; | |
| 2816 | let servers: Servers; | |
| 2817 | let base: string; | |
| 2818 | let repo: Repo; | |
| 2819 | let alice: User; | |
| 2820 | let scoutId: string; | |
| 2821 | let writerCookie: string; | |
| 2822 | let readerCookie: string; | |
| 2823 | ||
| 2824 | const signIn = async (username: string): Promise<string> => | |
| 2825 | ( | |
| 2826 | ( | |
| 2827 | await fetch(`${base}/signin`, { | |
| 2828 | method: "POST", | |
| 2829 | body: new URLSearchParams({ | |
| 2830 | username, | |
| 2831 | password: "a-good-passphrase", | |
| 2832 | }), | |
| 2833 | redirect: "manual", | |
| 2834 | }) | |
| 2835 | ).headers.get("set-cookie") ?? "" | |
| 2836 | ).split(";")[0]!; | |
| 2837 | ||
| 2838 | const get = (path: string, cookie: string) => | |
| 2839 | fetch(`${base}${path}`, { | |
| 2840 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 2841 | redirect: "manual", | |
| 2842 | }); | |
| 2843 | ||
| 2844 | beforeAll(async () => { | |
| 2845 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-tail-")); | |
| 2846 | app = createApp({ dataDir, port: 0 }); | |
| 2847 | servers = await serve(app); | |
| 2848 | base = servers.url; | |
| 2849 | const a = await createUser(app.db, { | |
| 2850 | username: "alice", | |
| 2851 | email: "[email protected]", | |
| 2852 | password: "a-good-passphrase", | |
| 2853 | adminIfFirst: true, | |
| 2854 | }); | |
| 2855 | if (!a.ok) throw new Error(a.error); | |
| 2856 | alice = a.value; | |
| 2857 | const b = await createUser(app.db, { | |
| 2858 | username: "bob", | |
| 2859 | email: "[email protected]", | |
| 2860 | password: "a-good-passphrase", | |
| 2861 | }); | |
| 2862 | if (!b.ok) throw new Error(b.error); | |
| 2863 | writerCookie = await signIn("alice"); | |
| 2864 | readerCookie = await signIn("bob"); | |
| 2865 | const made = await createRepo(app, a.value, { | |
| 2866 | name: "engine", | |
| 2867 | visibility: "private", | |
| 2868 | description: "an app that builds", | |
| 2869 | }); | |
| 2870 | if (!made.ok) throw new Error(String(made.error)); | |
| 2871 | repo = made.value; | |
| 2872 | const seeded = await commitToBranch(app, a.value, repo, { | |
| 2873 | message: "Start", | |
| 2874 | files: [{ path: "README.md", content: "engine\n" }], | |
| 2875 | }); | |
| 2876 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 2877 | const enabled = await enableApp(app, a.value, repo, { slug: "engine" }); | |
| 2878 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 2879 | const scout = await createAgent(app, a.value, { username: "scout" }); | |
| 2880 | if (!scout.ok) throw new Error(String(scout.error)); | |
| 2881 | scoutId = scout.value.user.id; | |
| 2882 | expect(addToCrew(app, a.value, repo, "scout").ok).toBe(true); | |
| 2883 | }); | |
| 2884 | ||
| 2885 | afterAll(async () => { | |
| 2886 | servers.stop(); | |
| 2887 | app.close(); | |
| 2888 | await rm(dataDir, { recursive: true, force: true }); | |
| 2889 | }); | |
| 2890 | ||
| 2891 | test("the app's page carries the tail of the active run's log, bounded, and refreshes without a script", async () => { | |
| 2892 | const tell = openIssue(app, alice, repo, { title: "Speed up boot" }); | |
| 2893 | if (!tell.ok) throw new Error(String(tell.error)); | |
| 2894 | const run = createRun(app.db, { | |
| 2895 | repoId: repo.id, | |
| 2896 | kind: "agent", | |
| 2897 | issueId: tell.value.id, | |
| 2898 | actorId: scoutId, | |
| 2899 | reason: "assigned", | |
| 2900 | }); | |
| 2901 | claimRun(app.db, run.id); | |
| 2902 | for (let i = 0; i < 9; i++) | |
| 2903 | appendRunLog(app.config.dataDir, run.id, `line ${i}`); | |
| 2904 | ||
| 2905 | const page = await (await get("/alice/engine", writerCookie)).text(); | |
| 2906 | expect(page).toContain('id="app-run-tail"'); | |
| 2907 | expect(page).toContain( | |
| 2908 | `data-stream="/api/v1/repos/alice/engine/runs/${run.id}/log?follow=1"`, | |
| 2909 | ); | |
| 2910 | // Bounded on the first paint too: only the last handful made it, and | |
| 2911 | // the box says so. | |
| 2912 | expect(page).not.toContain(">line 0<"); | |
| 2913 | expect(page).toContain(">line 8<"); | |
| 2914 | expect(page).toContain("Holding the last 6 lines here"); | |
| 2915 | // No script, no problem: the page keeps refreshing while the run goes. | |
| 2916 | expect(page).toContain('http-equiv="refresh"'); | |
| 2917 | ||
| 2918 | const stream = await get( | |
| 2919 | `/api/v1/repos/alice/engine/runs/${run.id}/log?follow=1`, | |
| 2920 | writerCookie, | |
| 2921 | ); | |
| 2922 | expect(stream.status).toBe(200); | |
| 2923 | ||
| 2924 | finishRun(app.db, run.id, { state: "ok", summary: "planned" }); | |
| 2925 | const settled = await (await get("/alice/engine", writerCookie)).text(); | |
| 2926 | expect(settled).not.toContain('id="app-run-tail"'); | |
| 2927 | }); | |
| 2928 | ||
| 2929 | test("a reader without access gets the forge's not-found from the page and from the tail's own stream", async () => { | |
| 2930 | const tell = openIssue(app, alice, repo, { title: "Private boot work" }); | |
| 2931 | if (!tell.ok) throw new Error(String(tell.error)); | |
| 2932 | const run = createRun(app.db, { | |
| 2933 | repoId: repo.id, | |
| 2934 | kind: "agent", | |
| 2935 | issueId: tell.value.id, | |
| 2936 | actorId: scoutId, | |
| 2937 | reason: "assigned", | |
| 2938 | }); | |
| 2939 | claimRun(app.db, run.id); | |
| 2940 | appendRunLog(app.config.dataDir, run.id, "line 0"); | |
| 2941 | ||
| 2942 | const page = await get("/alice/engine", readerCookie); | |
| 2943 | expect(page.status).toBe(404); | |
| 2944 | const stream = await get( | |
| 2945 | `/api/v1/repos/alice/engine/runs/${run.id}/log?follow=1`, | |
| 2946 | readerCookie, | |
| 2947 | ); | |
| 2948 | expect(stream.status).toBe(404); | |
| 2949 | finishRun(app.db, run.id, { state: "ok", summary: "planned" }); | |
| 2950 | }); | |
| 2951 | }); | |
| 2952 | ||
| 2805 | 2953 | /** |
| 2806 | 2954 | * Issue #65: a public app's studio page is the app, not the owner's books. |
| 2807 | 2955 | * `canSeeOwnerFacts` (`src/core/repos.ts`) is the one predicate — the same |
new above
offline