Public: anyone can read this; the crew can change it.
A run log's lines on /runs draw §5's row and a badge for their kind, on the server and live #4628
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4603-a-run-log-s-lines-on-runs-draw-5-s-row-a → main 4 commits fork 3 threads · 3 unresolved
8 files
changed +65
−52
docs/DESIGN.md +1 −1
| @@ -399,7 +399,7 @@ gone, so this list only shrinks. | ||
| 399 | 399 | - **`style/content.ts`, code view** — the source view and the syntax-highlight token classes `highlight-html.ts` builds as `tok-${kind}`: `.blob-view` `.ln` `.tok-attribute` `.tok-comment` `.tok-constant` `.tok-decorator` `.tok-deleted` `.tok-emphasis` `.tok-function` `.tok-heading` `.tok-inserted` `.tok-keyword` `.tok-link` `.tok-meta` `.tok-number` `.tok-operator` `.tok-plain` `.tok-property` `.tok-punctuation` `.tok-regex` `.tok-string` `.tok-tag` `.tok-type` `.tok-variable` |
| 400 | 400 | - **`style/content.ts`, prose (markdown)** — table cell alignment, built by `markdown.ts` as `ta-${align}`: `.ta-c` `.ta-r` |
| 401 | 401 | - **`style/content.ts`, diff viewer** — the diff table: its gutters, line kinds, file head and inline review comments: `.add` `.del` `.dh` `.diff` `.diff-body` `.diff-head` `.diff-note` `.diff-path` `.diff-stat` `.diff-stub` `.diff-summary` `.diff-table` `.dl` `.dl-review` `.dn` `.dn-comment` `.nonl` `.rc-hidden` `.rc-resolve` `.rc-resolved` `.wd` |
| 402 | - **`style/content.ts`, apps, runs, and the crew** — run pages, their logs and checks (a pull's look is §5's **look**): `.ln-forge` `.ln-pulse` `.ln-say` `.ln-tool` `.ln-you` `.txt` `.who` | |
| 402 | - **`style/content.ts`, apps, runs, and the crew** — run pages, their logs and checks (a pull's look is §5's **look**). | |
| 403 | 403 | - **`style/content.ts`, search results** — search results, the inbox's ask cards: `.ask-acts` `.ask-card` `.ask-group` `.ask-kind` `.ask-meta` `.ask-more-acts` `.ask-pick` `.ask-pick-hit` `.ask-short` `.ask-waiting` `.live-toggle` |
| 404 | 404 | - **`pages/board.ts`** — the board itself is §5's **board**; the spend tables `spend.ts` draws wear §5's `.table` and `.stack`, no class of their own |
| 405 | 405 | - **`pages/map.ts`** — the map of what waits for what: `.map-ask` `.map-breath` `.map-canvas` `.map-card` `.map-dot` `.map-edge` `.map-edge-crit` `.map-edge-done` `.map-main` `.map-meta` `.map-node` `.map-rule` `.map-svg` `.map-title` `.map-tool` `.map-who` `.map-who-i` `.map-who-n` `.map-word` `.map-wrap` |
src/web/client.ts +21 −13
| @@ -194,31 +194,39 @@ const elapsedTickJs = /* js */ ` | ||
| 194 | 194 | |
| 195 | 195 | const logLineJs = /* js */ ` |
| 196 | 196 | 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 |timing: )/; |
| 197 | function line(kind, speaker, fill) { | |
| 197 | function line(kind, speaker, fill, variant) { | |
| 198 | 198 | var el = document.createElement('div'); |
| 199 | el.className = 'ln ln-' + kind; | |
| 199 | el.className = 'row'; | |
| 200 | el.setAttribute('data-line', kind); | |
| 201 | var lead = document.createElement('span'); | |
| 202 | lead.className = 'row-lead'; | |
| 200 | 203 | var w = document.createElement('span'); |
| 201 | w.className = 'who'; | |
| 204 | w.className = 'badge'; | |
| 205 | if (variant) w.setAttribute('data-variant', variant); | |
| 202 | 206 | w.textContent = speaker; |
| 207 | lead.appendChild(w); | |
| 208 | var main = document.createElement('span'); | |
| 209 | main.className = 'row-main'; | |
| 203 | 210 | var t = document.createElement('span'); |
| 204 | t.className = 'txt'; | |
| 211 | t.className = 'row-title'; | |
| 205 | 212 | fill(t); |
| 206 | el.appendChild(w); | |
| 207 | el.appendChild(t); | |
| 213 | main.appendChild(t); | |
| 214 | el.appendChild(lead); | |
| 215 | el.appendChild(main); | |
| 208 | 216 | return el; |
| 209 | 217 | } |
| 210 | 218 | function draw(text) { |
| 211 | if (text.indexOf('you: ') === 0) return line('you', 'you', function (t) { t.textContent = text.slice(5); }); | |
| 219 | if (text.indexOf('you: ') === 0) return line('you', 'you', function (t) { t.textContent = text.slice(5); }, 'secondary'); | |
| 212 | 220 | if (text.indexOf('\\u2192 ') === 0) { |
| 213 | 221 | var rest = text.slice(2), at = rest.indexOf(' '); |
| 214 | return line('tool', '', function (t) { | |
| 222 | return line('tool', 'tool', function (t) { | |
| 215 | 223 | var b = document.createElement('b'); |
| 216 | 224 | b.textContent = at === -1 ? rest : rest.slice(0, at); |
| 217 | 225 | t.appendChild(b); |
| 218 | 226 | t.appendChild(document.createTextNode(at === -1 ? '' : ' ' + rest.slice(at + 1))); |
| 219 | 227 | }); |
| 220 | 228 | } |
| 221 | if (FORGE.test(text)) return line('forge', 'forge', function (t) { t.textContent = text; }); | |
| 229 | if (FORGE.test(text)) return line('forge', 'forge', function (t) { t.textContent = text; }, 'outline'); | |
| 222 | 230 | return line('say', who, function (t) { t.textContent = text; }); |
| 223 | 231 | } |
| 224 | 232 | `; |
| @@ -298,8 +306,8 @@ export const studioRunJs = /* js */ ` | ||
| 298 | 306 | function add(el) { |
| 299 | 307 | log.insertBefore(el, pulse && pulse.parentNode === log ? pulse : jump); |
| 300 | 308 | if (cap) { |
| 301 | var kept = log.querySelectorAll('.ln:not(#run-pulse)'); | |
| 302 | while (kept.length > cap) { log.removeChild(kept[0]); kept = log.querySelectorAll('.ln:not(#run-pulse)'); } | |
| 309 | var kept = log.querySelectorAll('.row[data-line]:not(#run-pulse)'); | |
| 310 | while (kept.length > cap) { log.removeChild(kept[0]); kept = log.querySelectorAll('.row[data-line]:not(#run-pulse)'); } | |
| 303 | 311 | } |
| 304 | 312 | if (follow) toEnd(); else if (jump) jump.hidden = false; |
| 305 | 313 | } |
| @@ -462,8 +470,8 @@ export const appTailJs = /* js */ ` | ||
| 462 | 470 | function add(el) { |
| 463 | 471 | tail.appendChild(el); |
| 464 | 472 | if (cap) { |
| 465 | var kept = tail.querySelectorAll('.ln'); | |
| 466 | while (kept.length > cap) { tail.removeChild(kept[0]); kept = tail.querySelectorAll('.ln'); } | |
| 473 | var kept = tail.querySelectorAll('.row[data-line]'); | |
| 474 | while (kept.length > cap) { tail.removeChild(kept[0]); kept = tail.querySelectorAll('.row[data-line]'); } | |
| 467 | 475 | } |
| 468 | 476 | tail.scrollTop = tail.scrollHeight; |
| 469 | 477 | } |
src/web/pages/runs.ts +21 −14
| @@ -34,6 +34,7 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 34 | 34 | import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts"; |
| 35 | 35 | import { field, readForm } from "../form.ts"; |
| 36 | 36 | import { html, when, type Html } from "../html.ts"; |
| 37 | import { badge } from "../ui.ts"; | |
| 37 | 38 | import { flashTo, page, redirect } from "../layout.ts"; |
| 38 | 39 | import { runsListPage } from "../studio/lists.ts"; |
| 39 | 40 | import { runView } from "../studio/run.ts"; |
| @@ -153,28 +154,34 @@ export function lineKind(line: string): LineKind { | ||
| 153 | 154 | return "say"; |
| 154 | 155 | } |
| 155 | 156 | |
| 156 | /** One line of a run's log, as the page draws it. */ | |
| 157 | /** One line of a run's log, as the page draws it: §5's row, a badge naming | |
| 158 | * who or what spoke in the lead, the words as its title. The live script in | |
| 159 | * `client.ts` builds the same markup. */ | |
| 157 | 160 | export function logLine(line: string, who = "agent"): Html { |
| 158 | 161 | const kind = lineKind(line); |
| 159 | if (kind === "you") | |
| 160 | return html`<div class="ln ln-you"> | |
| 161 | <span class="who">you</span><span class="txt">${line.slice(5)}</span> | |
| 162 | </div>`; | |
| 163 | 162 | if (kind === "tool") { |
| 164 | 163 | const rest = line.slice(2); |
| 165 | 164 | const at = rest.indexOf(" "); |
| 166 | 165 | const tool = at === -1 ? rest : rest.slice(0, at); |
| 167 | 166 | const args = at === -1 ? "" : rest.slice(at + 1); |
| 168 | return html`<div class="ln ln-tool"> | |
| 169 | <span class="who"></span><span class="txt"><b>${tool}</b> ${args}</span> | |
| 170 | </div>`; | |
| 167 | return logRow(kind, "tool", html`<b>${tool}</b> ${args}`); | |
| 171 | 168 | } |
| 172 | if (kind === "forge") | |
| 173 | return html`<div class="ln ln-forge"> | |
| 174 | <span class="who">forge</span><span class="txt">${line}</span> | |
| 175 | </div>`; | |
| 176 | return html`<div class="ln ln-say"> | |
| 177 | <span class="who">${who}</span><span class="txt">${line}</span> | |
| 169 | if (kind === "you") return logRow(kind, "you", line.slice(5), "secondary"); | |
| 170 | if (kind === "forge") return logRow(kind, "forge", line, "outline"); | |
| 171 | return logRow(kind, who, line); | |
| 172 | } | |
| 173 | ||
| 174 | /** The row itself; also the pulse's, in `studio/log.ts`. */ | |
| 175 | export function logRow( | |
| 176 | kind: string, | |
| 177 | label: string, | |
| 178 | body: Html | string | null, | |
| 179 | variant?: "secondary" | "outline", | |
| 180 | ): Html { | |
| 181 | const lead = variant === undefined ? badge(label) : badge(label, { variant }); | |
| 182 | return html`<div class="row" data-line="${kind}"> | |
| 183 | <span class="row-lead">${lead}</span> | |
| 184 | <span class="row-main"><span class="row-title">${body}</span></span> | |
| 178 | 185 | </div>`; |
| 179 | 186 | } |
| 180 | 187 |
src/web/studio/log.ts +6 −3
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | import type { RunKind } from "../../core/runs.ts"; |
| 10 | 10 | import { logLine } from "../pages/runs.ts"; |
| 11 | 11 | import { html, when, type Html } from "../html.ts"; |
| 12 | import { badge } from "../ui.ts"; | |
| 12 | 13 | import { unnamedRun, voiceFor, type Actor } from "./voice.ts"; |
| 13 | 14 | |
| 14 | 15 | /** |
| @@ -98,9 +99,11 @@ export function logBox(opts: { | ||
| 98 | 99 | ${when( |
| 99 | 100 | opts.pulse === true, |
| 100 | 101 | () => |
| 101 | html`<div class="ln ln-pulse" id="${opts.id}-pulse"> | |
| 102 | <span class="who">${opts.who}</span | |
| 103 | ><span class="txt"><i></i><i></i><i></i></span> | |
| 102 | html`<div class="row" data-line="pulse" id="${opts.id}-pulse"> | |
| 103 | <span class="row-lead">${badge(opts.who, { variant: "outline" })}</span> | |
| 104 | <span class="row-main" | |
| 105 | ><span class="row-title"><i></i><i></i><i></i></span></span | |
| 106 | > | |
| 104 | 107 | </div>`, |
| 105 | 108 | )} |
| 106 | 109 | ${opts.extra ?? null} |
src/web/style/content.ts +12 −19
| @@ -684,23 +684,16 @@ form.inline { display: inline; margin-left: auto; } | ||
| 684 | 684 | font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; tab-size: 4; |
| 685 | 685 | } |
| 686 | 686 | #run-live:has(#run-steer) .run-log { border-radius: var(--radius) var(--radius) 0 0; } |
| 687 | .run-log .ln { display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 10px; padding: 2px 0; } | |
| 688 | .run-log .who { color: var(--faint); font-size: 12.5px; font-weight: 600; padding-top: 2px; user-select: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 689 | .run-log .txt { white-space: pre-wrap; } | |
| 690 | .run-log .ln-say .who { color: var(--fg); } | |
| 691 | .run-log .ln-say + .ln-say > .who, .run-log .ln-say + .ln-tool > .who, .run-log .ln-tool + .ln-tool > .who, | |
| 692 | .run-log .ln-tool + .ln-say > .who, .run-log .ln-forge + .ln-forge > .who, .run-log .ln-say + .ln-pulse > .who, | |
| 693 | .run-log .ln-tool + .ln-pulse > .who { visibility: hidden; } | |
| 694 | .run-log .ln-tool .txt { font-family: var(--mono); font-size: 12.5px; color: var(--faint); } | |
| 695 | .run-log .ln-tool b { color: var(--fg); font-weight: 600; } | |
| 696 | .run-log .ln-forge .txt { color: var(--faint); font-style: italic; } | |
| 697 | .run-log .ln-you { margin: 6px 0; } | |
| 698 | .run-log .ln-you .who { color: var(--accent); } | |
| 699 | .run-log .ln-you .txt { background: color-mix(in srgb, var(--accent) 10%, transparent); border-left: 2px solid var(--accent); padding: 4px 10px; border-radius: 0 var(--radius) var(--radius) 0; } | |
| 700 | .run-log .ln-pulse .txt { padding-top: 6px; } | |
| 701 | .run-log .ln-pulse i { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--faint); margin-right: 4px; animation: run-pulse 1.2s infinite ease-in-out; } | |
| 702 | .run-log .ln-pulse i:nth-child(2) { animation-delay: .2s; } | |
| 703 | .run-log .ln-pulse i:nth-child(3) { animation-delay: .4s; } | |
| 687 | .run-log .row { padding: 2px 0; min-height: 0; align-items: flex-start; } | |
| 688 | .run-log .row-lead { flex: 0 0 72px; justify-content: flex-start; overflow: hidden; } | |
| 689 | .run-log .row-lead .badge { overflow: hidden; text-overflow: ellipsis; } | |
| 690 | .run-log .row-title { font-weight: 400; white-space: pre-wrap; overflow: visible; } | |
72px lead truncates speaker names with an ellipsis and nothing exposes the full text; add a title on the badge (and in client.ts line()) so a phone user or screen reader gets the whole name. |
||
| 691 | .run-log [data-line="tool"] .row-title { font-family: var(--mono); font-size: 12.5px; color: var(--faint); } | |
| 692 | .run-log [data-line="tool"] b { color: var(--fg); font-weight: 600; } | |
| 693 | .run-log [data-line="forge"] .row-title { color: var(--faint); font-style: italic; } | |
| 694 | .run-log [data-line="pulse"] i { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--faint); margin-right: 4px; animation: run-pulse 1.2s infinite ease-in-out; } | |
| 695 | .run-log [data-line="pulse"] i:nth-child(2) { animation-delay: .2s; } | |
| 696 | .run-log [data-line="pulse"] i:nth-child(3) { animation-delay: .4s; } | |
| 704 | 697 | @keyframes run-pulse { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } } |
| 705 | 698 | #run-jump { |
| 706 | 699 | position: sticky; bottom: 6px; float: right; margin-top: 6px; border-radius: 999px; box-shadow: var(--shadow-2); |
| @@ -1068,8 +1061,8 @@ textarea.editor:focus-visible { outline: none; } | ||
| 1068 | 1061 | /* Reduced motion: the pulses hold still at a legible level, the scroll-tied |
| 1069 | 1062 | fade stays at its resting width, and these transitions land at once. */ |
| 1070 | 1063 | @media (prefers-reduced-motion: reduce) { |
| 1071 | .front-live, .run-log .ln-pulse i { animation: none; } | |
| 1072 | .run-log .ln-pulse i { opacity: .6; } | |
| 1064 | .front-live, .run-log [data-line="pulse"] i { animation: none; } | |
| 1065 | .run-log [data-line="pulse"] i { opacity: .6; } | |
| 1073 | 1066 | .ex-dir > summary > .ico, .code-layout > #code-main[aria-busy="true"], .code-layout > [data-split]::after { transition: none; } |
| 1074 | 1067 | } |
| 1075 | 1068 | `; |
test/app-tail-events.test.ts +1
| @@ -13,6 +13,7 @@ function node(tag: string) { | ||
| 13 | 13 | className: "", |
| 14 | 14 | textContent: "", |
| 15 | 15 | children: [] as unknown[], |
| 16 | setAttribute(_n: string, _v: string) {}, | |
| 16 | 17 | appendChild(c: unknown) { |
| 17 | 18 | el.children.push(c); |
| 18 | 19 | return c; |
test/studio-runs.test.ts +1 −1
| @@ -794,7 +794,7 @@ describe("working: a run's own page in the studio", () => { | ||
| 794 | 794 | // The deploy speaks for itself: the box, and every line in it — never |
| 795 | 795 | // "an agent", which is nobody that was actually there. |
| 796 | 796 | expect(page).toContain('data-who="the deploy"'); |
| 797 | expect(page).toContain('class="who">the deploy<'); | |
| 797 | expect(page).toContain('>the deploy<'); | |
| 798 | 798 | expect(page).not.toContain("an agent"); |
| 799 | 799 | // A deploy that finished with nothing else to show reads as done, not |
| 800 | 800 | // as a run that has not started yet. |
test/web-apps.test.ts +2 −1
| @@ -465,7 +465,8 @@ describe("apps, runs and the crew on the web", () => { | ||
| 465 | 465 | expect(page).not.toContain("issue-meta"); |
| 466 | 466 | // The log is drawn line by line, each by what kind of line it is: the |
| 467 | 467 | // forge's own words ("checking out …") are marked as such. |
| 468 | expect(page).toContain('class="ln ln-forge"'); | |
| 468 | expect(page).toContain(`data-line="forge"`); | |
| 469 | expect(page).not.toMatch(/class="[^"]*\bln-/); | |
| 469 | 470 | expect(page).toContain('id="run-elapsed"'); |
| 470 | 471 | |
| 471 | 472 | const door = await web(alice, `/runs/${run!.id}`); |
new above
offline
Row markup duplicated from ui.ts row() (and again in client.ts). Acceptable here because row() wraps in li, but a shared div form would keep one source.