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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/4603-a-run-log-s-lines-on-runs-draw-5-s-row-a → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/4603-a-run-log-s-lines-on-runs-draw-5-s-row-a was merged into main as 8c22bd1.

Review

Sign in to review this change.
8 files changed +65 −52
docs/DESIGN.md +1 −1
@@ -399,7 +399,7 @@ gone, so this list only shrinks.
399399 - **`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`
400400 - **`style/content.ts`, prose (markdown)** — table cell alignment, built by `markdown.ts` as `ta-${align}`: `.ta-c` `.ta-r`
401401 - **`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**).
403403 - **`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`
404404 - **`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
405405 - **`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 */ `
194194
195195 const logLineJs = /* js */ `
196196 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) {
198198 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';
200203 var w = document.createElement('span');
201 w.className = 'who';
204 w.className = 'badge';
205 if (variant) w.setAttribute('data-variant', variant);
202206 w.textContent = speaker;
207 lead.appendChild(w);
208 var main = document.createElement('span');
209 main.className = 'row-main';
203210 var t = document.createElement('span');
204 t.className = 'txt';
211 t.className = 'row-title';
205212 fill(t);
206 el.appendChild(w);
207 el.appendChild(t);
213 main.appendChild(t);
214 el.appendChild(lead);
215 el.appendChild(main);
208216 return el;
209217 }
210218 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');
212220 if (text.indexOf('\\u2192 ') === 0) {
213221 var rest = text.slice(2), at = rest.indexOf(' ');
214 return line('tool', '', function (t) {
222 return line('tool', 'tool', function (t) {
215223 var b = document.createElement('b');
216224 b.textContent = at === -1 ? rest : rest.slice(0, at);
217225 t.appendChild(b);
218226 t.appendChild(document.createTextNode(at === -1 ? '' : ' ' + rest.slice(at + 1)));
219227 });
220228 }
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');
222230 return line('say', who, function (t) { t.textContent = text; });
223231 }
224232 `;
@@ -298,8 +306,8 @@ export const studioRunJs = /* js */ `
298306 function add(el) {
299307 log.insertBefore(el, pulse && pulse.parentNode === log ? pulse : jump);
300308 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)'); }
303311 }
304312 if (follow) toEnd(); else if (jump) jump.hidden = false;
305313 }
@@ -462,8 +470,8 @@ export const appTailJs = /* js */ `
462470 function add(el) {
463471 tail.appendChild(el);
464472 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]'); }
467475 }
468476 tail.scrollTop = tail.scrollHeight;
469477 }
src/web/pages/runs.ts +21 −14
@@ -34,6 +34,7 @@ import type { Ctx } from "../../http/context.ts";
3434 import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts";
3535 import { field, readForm } from "../form.ts";
3636 import { html, when, type Html } from "../html.ts";
37 import { badge } from "../ui.ts";
3738 import { flashTo, page, redirect } from "../layout.ts";
3839 import { runsListPage } from "../studio/lists.ts";
3940 import { runView } from "../studio/run.ts";
@@ -153,28 +154,34 @@ export function lineKind(line: string): LineKind {
153154 return "say";
154155 }
155156
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. */
157160 export function logLine(line: string, who = "agent"): Html {
158161 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>`;
163162 if (kind === "tool") {
164163 const rest = line.slice(2);
165164 const at = rest.indexOf(" ");
166165 const tool = at === -1 ? rest : rest.slice(0, at);
167166 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}`);
171168 }
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,

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.

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>
178185 </div>`;
179186 }
180187
src/web/studio/log.ts +6 −3
@@ -9,6 +9,7 @@
99 import type { RunKind } from "../../core/runs.ts";
1010 import { logLine } from "../pages/runs.ts";
1111 import { html, when, type Html } from "../html.ts";
12 import { badge } from "../ui.ts";
1213 import { unnamedRun, voiceFor, type Actor } from "./voice.ts";
1314
1415 /**
@@ -98,9 +99,11 @@ export function logBox(opts: {
9899 ${when(
99100 opts.pulse === true,
100101 () =>
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 >
104107 </div>`,
105108 )}
106109 ${opts.extra ?? null}
src/web/style/content.ts +12 −19
@@ -684,23 +684,16 @@ form.inline { display: inline; margin-left: auto; }
684684 font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; tab-size: 4;
685685 }
686686 #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; }
704697 @keyframes run-pulse { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
705698 #run-jump {
706699 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; }
10681061 /* Reduced motion: the pulses hold still at a legible level, the scroll-tied
10691062 fade stays at its resting width, and these transitions land at once. */
10701063 @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; }
10731066 .ex-dir > summary > .ico, .code-layout > #code-main[aria-busy="true"], .code-layout > [data-split]::after { transition: none; }
10741067 }
10751068 `;
test/app-tail-events.test.ts +1
@@ -13,6 +13,7 @@ function node(tag: string) {
1313 className: "",
1414 textContent: "",
1515 children: [] as unknown[],
16 setAttribute(_n: string, _v: string) {},
1617 appendChild(c: unknown) {
1718 el.children.push(c);
1819 return c;
test/studio-runs.test.ts +1 −1
@@ -794,7 +794,7 @@ describe("working: a run's own page in the studio", () => {
794794 // The deploy speaks for itself: the box, and every line in it — never
795795 // "an agent", which is nobody that was actually there.
796796 expect(page).toContain('data-who="the deploy"');
797 expect(page).toContain('class="who">the deploy<');
797 expect(page).toContain('>the deploy<');
798798 expect(page).not.toContain("an agent");
799799 // A deploy that finished with nothing else to show reads as done, not
800800 // 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", () => {
465465 expect(page).not.toContain("issue-meta");
466466 // The log is drawn line by line, each by what kind of line it is: the
467467 // 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-/);
469470 expect(page).toContain('id="run-elapsed"');
470471
471472 const door = await web(alice, `/runs/${run!.id}`);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs