From a5d2a29e53905cfd070ae8929fccb7dae6fea6ca Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 12 Nov 2026 15:01:59 -0800 Subject: [PATCH 1/5] Say in Chat when a build opens a pull request and when it ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A build ran for an hour on a hosted deployment while Chat said nothing of it: only that someone asked chopin to break the plan into tasks. Chat now has one quiet line for each pull request a build opens ("Build opened #12 for “Scaffold the crate”.") and one when it ends ("Build finished: 2 tasks, #12 and #13.", or why it stopped or didn't start; a rebuild says which pull requests it updated). The `#` is a link to the pull request, drawn by the system row only for a GitHub pull request link whose label is its own number, and a build's line has a wrench in place of the system icon. The lines are worked out from the stored implementation state each time it is announced (tasks/build-notices.ts), after the change was persisted. Each has an ID made from the build and the pull request, so a repeat is one line; a document opened long after its builds seeds what it has seen and says nothing of them. They are posted through a new backend callback under the document lock, on the open room or the document opened for it, as the research notices are. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline (subagent) Assistant-verification: bun test apps/server/src/tasks/build-notices.test.ts (4 pass) Assistant-verification: bun test apps/server/src/agent apps/server/src/tasks (572 pass) Assistant-verification: bun test apps/web/src/chat/transcript.test.tsx (19 pass) Assistant-verification: bun run types Assistant-verification: bun run ci User-preference: "make a Build visible and approvable where people work" Co-authored-by: Alex Lavaee --- apps/server/src/main.ts | 31 ++++ apps/server/src/plan/service.ts | 5 + apps/server/src/tasks/build-notices.test.ts | 182 ++++++++++++++++++++ apps/server/src/tasks/build-notices.ts | 131 ++++++++++++++ apps/server/src/tasks/notifications.ts | 17 ++ apps/web/src/chat/transcript.test.tsx | 36 +++- apps/web/src/chat/transcript.tsx | 38 +++- 7 files changed, 436 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/tasks/build-notices.test.ts create mode 100644 apps/server/src/tasks/build-notices.ts diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 3ce1296aa..3198c79bc 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -430,6 +430,11 @@ function documentBackend(): Service.Backend { }, liveBuild: !!config.liveBuild, onBuildStopped: id => liveSync?.stopped(id), + onBuildNotice: (id, notice) => { + announceBuildNotice(id, notice).catch(err => + console.error(`[build] could not post ${notice.id} in ${id}'s Chat:`, err) + ); + }, onEditingUnlocked: createEditingUnlocked({ withDocumentLock, opened: id => Rooms.get(id)?.plan, @@ -2691,6 +2696,32 @@ function announceResearchTerminal(channelId: string, id: string, text: string): }); } +/** + * A build's line in Chat, after the change it reports was persisted. It waits for the document lock + * the report may still hold, then posts on the open room, or on the document opened for it. + */ +function announceBuildNotice( + channelId: string, + notice: { id: string; text: string }, +): Promise { + if (cutting) return Promise.resolve(); + return withDocumentLock(channelId, async () => { + let post = (plan: Service.Plan) => + Chat.noticeOnce({ chat: plan.chat, plan, server, room: channelId }, notice.id, notice.text); + let active = Rooms.get(channelId)?.plan; + if (active) { + await post(active); + return; + } + let detached = await Service.open(channelId, documentBackend(), server); + try { + await post(detached); + } finally { + await Service.close(detached); + } + }); +} + /** Run `action` on a document's live plan, or open it detached for the duration. */ function withDecisionPlan(channelId: string, action: (plan: Service.Plan) => Promise) { return withDocumentLock(channelId, async () => { diff --git a/apps/server/src/plan/service.ts b/apps/server/src/plan/service.ts index 4cf6fda25..36ff3f721 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -151,6 +151,11 @@ export type Backend = { onBuildStopped?: (channelId: string) => void; /** A document's editing lock was released and announced. */ onEditingUnlocked?: (channelId: string) => void; + /** + * A build opened a pull request or ended, after it was persisted: post the line in Chat. Called + * once per notice for each open plan; the notice's ID keeps a repeat to one line. + */ + onBuildNotice?: (channelId: string, notice: { id: string; text: string }) => void; }; export type DocumentTarget = { diff --git a/apps/server/src/tasks/build-notices.test.ts b/apps/server/src/tasks/build-notices.test.ts new file mode 100644 index 000000000..74f0c204f --- /dev/null +++ b/apps/server/src/tasks/build-notices.test.ts @@ -0,0 +1,182 @@ +import { expect, test } from "bun:test"; + +import { buildNotices, pullRequestLink } from "./build-notices"; +import { transition } from "./lifecycle"; + +import type { BuildRequest } from "@chopin/protocol/implementation"; +import type { Graph, Run } from "./graphs"; +import type { Lifecycle, LifecycleState } from "./lifecycle"; + +let tasks = ["scaffold", "proof"].map((id, index) => ({ + id, + title: index === 0 ? "Scaffold the crate" : "Prove the contract", + context: "The document.", + goal: "Do it.", + acceptance: ["It is done.", "It is checked."], + dependsOn: index === 0 ? [] : ["scaffold"], +})); + +let graph: Graph = { + versions: [{ number: 1, revision: 1, planRevision: 3, state: "locked", definition: { tasks } }], +}; + +let run: Run = { + id: "build-1", + user: "U_ana", + client: { name: "Fake", version: "1" }, + session: "s", + planRevision: 3, + graphVersion: 1, + graphRevision: 1, + repository: "octo/score", + branch: "main", + commit: "0".repeat(40), + startedAt: "2026-11-12T10:00:00.000Z", +}; + +let build: BuildRequest = { + id: "build-1", + user: "U_ana", + connectionId: "c", + repositoryId: "R", + checkout: { repository: "octo/score", commit: "0".repeat(40) }, + planRevision: 3, + graphVersion: 1, + graphRevision: 1, + createdAt: "2026-11-12T10:00:00.000Z", + expiresAt: 0, + state: "running", +}; + +const PR = "https://github.com/octo/score/pull/12"; + +function step(state: LifecycleState, input: Record): LifecycleState { + let result = transition(state, { runId: run.id, ...input } as never); + if (result.kind !== "accepted") throw new Error(result.kind); + return result.state; +} + +function started(): LifecycleState { + let state: LifecycleState = { graph, execution: run, lifecycle: { history: [] } as Lifecycle }; + state = step(state, { kind: "start", taskId: "scaffold", idempotencyKey: "a" }); + return step(state, { + kind: "report_pr", + taskId: "scaffold", + url: PR, + state: "open", + idempotencyKey: "b", + }); +} + +test("a running build's pull request is one line, named for its first task, until the build ends", () => { + let state = started(); + let notices = buildNotices({ ...state, builds: [build], live: undefined }); + expect(notices).toEqual([{ + id: `build-pr:build-1:${PR}`, + text: `Build opened [#12](${PR}) for “Scaffold the crate”.`, + }]); + // The next task reporting the same pull request adds no second line. + state = step(state, { + kind: "complete", + taskId: "scaffold", + summary: "Done.", + idempotencyKey: "c", + }); + state = step(state, { kind: "start", taskId: "proof", idempotencyKey: "d" }); + state = step(state, { + kind: "report_pr", + taskId: "proof", + url: PR, + state: "open", + idempotencyKey: "e", + }); + expect(buildNotices({ ...state, builds: [build], live: undefined })).toEqual(notices); +}); + +test("a finished build says how many tasks and links its pull requests; a stopped one says why", () => { + let state = started(); + state = step(state, { + kind: "complete", + taskId: "scaffold", + summary: "Done.", + idempotencyKey: "c", + }); + state = step(state, { kind: "start", taskId: "proof", idempotencyKey: "d" }); + let second = "https://github.com/octo/score/pull/13"; + state = step(state, { + kind: "report_pr", + taskId: "proof", + url: second, + state: "open", + idempotencyKey: "e", + }); + state = step(state, { kind: "complete", taskId: "proof", summary: "Done.", idempotencyKey: "f" }); + state = step(state, { + kind: "report_verification", + passed: true, + summary: "Both pass.", + reviewerMethod: "Ran the checks.", + evidence: tasks.map(task => ({ taskId: task.id, evidence: ["passed"] })), + tasksNeedingWork: [], + idempotencyKey: "v", + }); + expect(state.execution).toBeUndefined(); + let ended = { ...build, state: "stopped" as const, finishedAt: "2026-11-12T11:00:00.000Z" }; + let notices = buildNotices({ ...state, builds: [ended], live: undefined }); + expect(notices.at(-1)).toEqual({ + id: "build-end:build-1", + text: `Build finished: 2 tasks, [#12](${PR}) and [#13](${second}).`, + }); + + let blocked = step(started(), { + kind: "request_revision", + reason: "The verifier refused the crate.", + idempotencyKey: "r", + }); + expect(buildNotices({ ...blocked, builds: [ended], live: undefined }).at(-1)).toEqual({ + id: "build-end:build-1", + text: "Build stopped: The verifier refused the crate.", + }); + + let failed = { ...build, state: "failed" as const, error: "The agent did not start." }; + expect(buildNotices({ graph, lifecycle: { history: [] }, builds: [failed], live: undefined })) + .toEqual([{ id: "build-end:build-1", text: "Build didn’t start: The agent did not start." }]); +}); + +test("a rebuild that lands says which pull requests it updated", () => { + let at = "2026-11-12T12:00:00.000Z"; + let rebuild: BuildRequest = { + ...build, + id: "rebuild-1", + kind: "rebuild", + state: "stopped", + finishedAt: at, + }; + let notices = buildNotices({ + graph, + lifecycle: { history: [] }, + builds: [rebuild], + live: { + pullRequests: [PR], + commits: [ + { + pullRequest: PR, + sha: "a", + message: "older", + revision: 2, + at: "2026-11-12T11:00:00.000Z", + }, + { pullRequest: PR, sha: "b", message: "edit", revision: 4, at }, + ], + } as never, + }); + expect(notices).toEqual([{ + id: "build-end:rebuild-1", + text: `Build finished: updated [#12](${PR}).`, + }]); +}); + +test("a pull request link is its number, and anything else is the link itself", () => { + expect(pullRequestLink(PR)).toBe(`[#12](${PR})`); + expect(pullRequestLink("https://example.com/x")).toBe(""); +}); diff --git a/apps/server/src/tasks/build-notices.ts b/apps/server/src/tasks/build-notices.ts new file mode 100644 index 000000000..6f1046899 --- /dev/null +++ b/apps/server/src/tasks/build-notices.ts @@ -0,0 +1,131 @@ +/** + * The lines Chat gets for a Build: when it opens a pull request, and when it ends. Each has a fixed + * ID made from the build and what happened, so posting one twice keeps one line, and a restart + * that sees the same state posts nothing new. + */ + +import { implementationLifecycle } from "./lifecycle"; + +import type { Plan } from "../plan/service"; + +export type BuildNotice = { id: string; text: string }; + +type State = Pick; + +/** `#12` linked to its pull request; any other link as itself. */ +export function pullRequestLink(url: string): string { + let number = url.match(/\/pull\/(\d+)(?:[/?#]|$)/)?.[1]; + return number ? `[#${number}](${url})` : `<${url}>`; +} + +function links(urls: Iterable): string { + let list = [...new Set(urls)].map(pullRequestLink); + return list.length <= 1 ? list.join("") : `${list.slice(0, -1).join(", ")} and ${list.at(-1)}`; +} + +function sentence(text: string): string { + let trimmed = text.replace(/\s+/g, " ").trim().replace(/[.!?]+$/, ""); + return trimmed.length > 200 ? `${trimmed.slice(0, 200).replace(/\s+\S*$/, "")}…` : trimmed; +} + +/** Every notice the current state calls for, in the order they happened. */ +export function buildNotices(state: State): BuildNotice[] { + let graph = state.graph; + if (!graph) return []; + let lifecycle = implementationLifecycle({ + graph, + execution: state.execution, + lifecycle: state.lifecycle, + }); + let notices: BuildNotice[] = []; + let titles = (graphVersion: number) => + new Map( + graph.versions.find(version => version.number === graphVersion)?.definition.tasks + .map(task => [task.id, task.title]) ?? [], + ); + let known = new Set(state.live?.pullRequests ?? []); + + // A running first build: each pull request the first time one of its tasks reports it. + let run = state.execution; + if (run && lifecycle.activity) { + let named = titles(run.graphVersion); + let seen = new Set(); + for (let task of lifecycle.activity.tasks) { + let url = task.state === "queued" ? undefined : task.pullRequest?.url; + if (!url || seen.has(url)) continue; + seen.add(url); + let title = named.get(task.id); + notices.push({ + id: `build-pr:${run.id}:${url}`, + text: `Build ${known.has(url) ? "updated" : "opened"} ${pullRequestLink(url)}${ + title ? ` for “${sentence(title)}”` : "" + }.`, + }); + } + } + + for (let build of state.builds) { + if (build.state !== "stopped" && build.state !== "failed") continue; + let id = `build-end:${build.id}`; + if (build.kind === "rebuild") { + let updated = (state.live?.commits ?? []) + .filter(commit => build.finishedAt && commit.at === build.finishedAt) + .map(commit => commit.pullRequest); + let unchanged = state.live?.noChange?.some(item => item.buildId === build.id); + notices.push({ + id, + text: updated.length + ? `Build finished: updated ${links(updated)}.` + : unchanged + ? "Build finished: the document needed no code change." + : build.state === "failed" + ? `Build failed${build.error ? `: ${sentence(build.error)}` : ""}.` + : `Build stopped${build.error ? `: ${sentence(build.error)}` : ""}.`, + }); + continue; + } + let ended = lifecycle.history.findLast(item => item.run.id === build.id); + if (ended && (ended.outcome.kind === "implemented" || ended.outcome.kind === "delivered")) { + let tasks = ended.progress.tasks.filter(task => task.state === "completed"); + let opened = tasks.flatMap(task => task.pullRequest ? [task.pullRequest.url] : []); + let count = `${tasks.length} ${tasks.length === 1 ? "task" : "tasks"}`; + notices.push({ + id, + text: opened.length + ? `Build finished: ${count}, ${links(opened)}.` + : `Build finished: ${count}, no code change needed.`, + }); + } else if (ended && ended.outcome.kind === "revision_requested") { + // A blocked task says why better than the release that followed it. + let blocked = ended.progress.tasks.find(task => task.state === "blocked" && task.blocker); + let title = blocked && titles(ended.run.graphVersion).get(blocked.id); + let reason = sentence(ended.outcome.reason); + notices.push({ + id, + text: blocked?.blocker + ? `Build stopped: ${title ? `“${sentence(title)}” is blocked: ` : ""}${ + sentence(blocked.blocker) + }.` + : /^the build stopped before it finished$/i.test(reason) + ? "Build stopped before it finished." + : `Build stopped: ${reason}.`, + }); + } else if (run?.id === build.id) { + // Stopped while its run still holds the tasks: Build offers what to do next. + notices.push({ + id, + text: `Build stopped${ + build.error ? `: ${sentence(build.error)}` : "" + }. Open Build to retry or return it for changes.`, + }); + } else { + notices.push({ + id, + text: build.state === "failed" + ? `Build didn’t start${build.error ? `: ${sentence(build.error)}` : ""}.` + : `Build stopped${build.error ? `: ${sentence(build.error)}` : ""}.`, + }); + } + } + return notices; +} diff --git a/apps/server/src/tasks/notifications.ts b/apps/server/src/tasks/notifications.ts index 2ea773508..03623596d 100644 --- a/apps/server/src/tasks/notifications.ts +++ b/apps/server/src/tasks/notifications.ts @@ -1,3 +1,4 @@ +import { buildNotices } from "./build-notices"; import { locksEditing } from "./builds"; import type { Plan } from "../plan/service"; import { broadcast } from "../wire"; @@ -13,8 +14,15 @@ export function implementationStatus(plan: Plan) { /** Last observed lock, seeded on open so a restored build can announce its release once. */ let announced = new WeakMap(); +/** + * Build notices this plan has already seen, seeded on open with every one its stored state calls + * for: a document opened long after its builds finished gets no line for them. + */ +let noticed = new WeakMap>(); + export function rememberImplementationLock(plan: Plan): void { announced.set(plan, implementationStatus(plan).locked); + noticed.set(plan, new Set(buildNotices(plan).map(notice => notice.id))); } export function announceImplementation(plan: Plan) { @@ -29,4 +37,13 @@ export function announceImplementation(plan: Plan) { if (wasLocked && !status.locked) { plan.persistence.onEditingUnlocked?.(plan.id); } + let post = plan.persistence.onBuildNotice; + if (!post) return; + let seen = noticed.get(plan) ?? new Set(); + noticed.set(plan, seen); + for (let notice of buildNotices(plan)) { + if (seen.has(notice.id)) continue; + seen.add(notice.id); + post(plan.id, notice); + } } diff --git a/apps/web/src/chat/transcript.test.tsx b/apps/web/src/chat/transcript.test.tsx index fd1681d23..324b5c7d9 100644 --- a/apps/web/src/chat/transcript.test.tsx +++ b/apps/web/src/chat/transcript.test.tsx @@ -4,7 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { rememberAvatars } from "@chopin/editor/people"; import { markdownClass } from "./assistant-ui/markdown-classes.test-fixtures"; -import { Transcript } from "./transcript"; +import { systemParts, Transcript } from "./transcript"; import { childDocumentPath } from "@chopin/protocol/document-url"; import { answerArtifact, @@ -308,3 +308,37 @@ test("people speak with their own picture and no tag; Chopin, a research child a expect(markup).toContain('data-slot="agent-handoff"'); expect(markup).toContain("Compare VPS pricing"); }); + +test("a build's line links its pull requests and nothing else", () => { + let url = "https://github.com/o/r/pull/12"; + expect( + systemParts( + `Build finished: 2 tasks, [#12](${url}) and [#13](https://github.com/o/r/pull/13).`, + ), + ) + .toEqual([ + "Build finished: 2 tasks, ", + { number: "12", url }, + " and ", + { number: "13", url: "https://github.com/o/r/pull/13" }, + ".", + ]); + // Any other Markdown link, or a label that is not its pull request's number, stays as written. + for ( + let text of [ + "Build opened [#12](https://example.com/o/r/pull/12).", + `Build opened [#13](${url}).`, + "Build opened [here](https://github.com/o/r/pull/12).", + ] + ) expect(systemParts(text)).toEqual([text]); + let markup = renderToStaticMarkup(createElement(Transcript, { + entries: [{ + id: "n", + author: { kind: "system" }, + text: `Build opened [#12](${url}) for “A”.`, + ts: 1, + }], + } as never)); + expect(markup).toContain(`href="${url}"`); + expect(markup).toContain(">#12 for “A”."); +}); diff --git a/apps/web/src/chat/transcript.tsx b/apps/web/src/chat/transcript.tsx index 289df871b..efc7e980e 100644 --- a/apps/web/src/chat/transcript.tsx +++ b/apps/web/src/chat/transcript.tsx @@ -15,7 +15,7 @@ import { useSyncExternalStore, } from "react"; import type { ReactNode } from "react"; -import { MessageForwardIcon, MessageIcon, SignInIcon } from "@chopin/icons"; +import { MessageForwardIcon, MessageIcon, SignInIcon, WrenchIcon } from "@chopin/icons"; import { parseChildDocumentPath } from "@chopin/protocol/document-url"; import { ChopinMark, Face, openCommentThread, PLANNER_NAME, useCardMeta } from "@chopin/editor"; @@ -125,6 +125,22 @@ function readyChild(text: string): string | undefined { return path ? parseChildDocumentPath(path)?.childSlug : undefined; } +/** A GitHub pull request link a build's line names as `[#12](url)`; nothing else is a link. */ +const PULL_LINK = /\[#(\d+)\]\((https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\1)\)/g; + +/** A system line's words, with a build's pull request links drawn as links. */ +export function systemParts(text: string): Array { + let parts: Array = []; + let last = 0; + for (let match of text.matchAll(PULL_LINK)) { + if (match.index > last) parts.push(text.slice(last, match.index)); + parts.push({ number: match[1]!, url: match[2]! }); + last = match.index + match[0].length; + } + if (last < text.length) parts.push(text.slice(last)); + return parts; +} + function SystemEntry( { enter, item }: { enter: boolean; item: Extract }, ) { @@ -156,10 +172,26 @@ function SystemEntry( data-chat-system >
-

- {displayText(item.text)} + {systemParts(displayText(item.text)).map((part, index) => + typeof part === "string" + ? part + : ( + + #{part.number} + + ) + )}

); From f642a15aedd7aa623b3a0bfd6ccfefcd6bd0477a Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 12 Nov 2026 15:02:10 -0800 Subject: [PATCH 2/5] Head Chat's run strip with the running build, its task and its pull requests During a build Chat's run strip listed only the Planner's workflow runs, so a build that took an hour was visible only in Build. The strip now starts with the document's running build, read from the same snapshot as Build: one line with a pulsing dot, "Build" (or "Building changes" for a rebuild), "1 of 2 tasks" and its age, unfolded into the task being built, its agent's latest activity, the pull requests it has opened as #12 links, and Open Build. Beside it a running workflow folds itself unless it waits on people. It shows whether or not the Planner runs here, and leaves the strip the moment the build ends; Chat's line about how it ended stays. The browser spec pairs the fake connector, pauses its agent on its first task, checks the row on a desktop window and at a phone's width, opens Build from it, then lets the agent finish and checks Chat's lines and that the strip lets the build go. It fails on main, where the strip never shows. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline (subagent) Assistant-verification: bun test apps/web/src/build-model.test.ts (60 pass) Assistant-verification: bun test apps/web/src/chat/run-card.test.tsx (22 pass) Assistant-verification: bun run types Assistant-verification: bun run ci Assistant-verification: bun run e2e e2e/implementation-launcher.e2e.ts e2e/chat-run-strip.e2e.ts e2e/run-card.e2e.ts e2e/build-restart.e2e.ts e2e/stopped-build-editing.e2e.ts e2e/chat-row-edges.e2e.ts e2e/chat-hover-contrast.e2e.ts e2e/chat-long-transcript.e2e.ts e2e/chat-phone.e2e.ts e2e/message-toolbar-layout.e2e.ts e2e/pane-work.e2e.ts --workers=2 (77 passed, 1 skipped, 1 failed: chat-row-edges "scrolling a long Chat moves only its thumb", 160 forced layouts from Playwright's own script, the same on main) User-preference: "make a Build visible and approvable where people work" Co-authored-by: Alex Lavaee --- apps/web/src/build-model.test.ts | 81 ++++++++++++++++++ apps/web/src/build-model.ts | 61 ++++++++++++++ apps/web/src/chat/chat.tsx | 13 ++- apps/web/src/chat/run-card.test.tsx | 37 +++++++++ apps/web/src/chat/run-card.tsx | 122 +++++++++++++++++++++++++++- apps/web/src/room-workspace.tsx | 4 + e2e/implementation-launcher.e2e.ts | 74 ++++++++++++++++- 7 files changed, 384 insertions(+), 8 deletions(-) diff --git a/apps/web/src/build-model.test.ts b/apps/web/src/build-model.test.ts index f191313f2..7e78b85f0 100644 --- a/apps/web/src/build-model.test.ts +++ b/apps/web/src/build-model.test.ts @@ -11,6 +11,7 @@ import { buildLook, buildPhase, buildRefusalCopy, + buildRun, buildTabReady, builtCount, builtDetail, @@ -1064,3 +1065,83 @@ describe("a pull request a person closed", () => { ); }); }); + +describe("the running build in Chat's run strip", () => { + let two = (state: "in_progress" | "queued") => + snapshot({ + graph: { + ...snapshot().graph!, + definition: { + tasks: ["First", "Second"].map((title, index) => ({ + id: String(index), + title, + context: "", + goal: "", + acceptance: [], + dependsOn: [], + })), + }, + }, + build: { ...build("running"), activity: { text: "Running a command", at: "x" } }, + lifecycle: { + execution: { state: "active" }, + history: [], + activity: { + tasks: [{ + id: "0", + state: "completed", + pullRequest: { url: "https://github.com/o/r/pull/7", state: "open" }, + }, { id: "1", state }], + events: [], + }, + }, + }); + + it("names the task being built, the agent's activity and the pull requests so far", () => { + expect(buildRun(two("in_progress"))).toEqual({ + id: "b", + kind: "build", + state: "building", + done: 1, + total: 2, + task: "Second", + activity: "Running a command", + started: "2026-10-10T10:00:00.000Z", + pullRequests: ["https://github.com/o/r/pull/7"], + }); + // Between tasks it names the next one. + expect(buildRun(two("queued"))?.task).toBe("Second"); + }); + + it("shows a queued build without a task, a rebuild by its pull requests, and nothing once it ends", () => { + expect(buildRun(snapshot({ build: build("queued") }))).toMatchObject({ + state: "queued", + done: 0, + total: 1, + }); + expect(buildRun(snapshot({ build: build("queued") }))?.task).toBeUndefined(); + let live = { + buildId: "b", + user: "u", + repositoryId: "r", + baseRevision: 1, + pullRequests: ["https://github.com/o/r/pull/7"], + commits: [], + tasks: [], + outOfSync: false, + rebuild: { ...build("running"), id: "r2", kind: "rebuild" as const }, + }; + expect(buildRun(snapshot({ live }))).toMatchObject({ + id: "r2", + kind: "rebuild", + state: "building", + pullRequests: ["https://github.com/o/r/pull/7"], + }); + for (let state of ["stopped", "failed"] as const) { + expect(buildRun(snapshot({ build: build(state) }))).toBeUndefined(); + expect(buildRun(snapshot({ live: { ...live, rebuild: { ...live.rebuild, state } } }))) + .toBeUndefined(); + } + expect(buildRun(snapshot())).toBeUndefined(); + }); +}); diff --git a/apps/web/src/build-model.ts b/apps/web/src/build-model.ts index e76a517cc..f97a82dbf 100644 --- a/apps/web/src/build-model.ts +++ b/apps/web/src/build-model.ts @@ -876,3 +876,64 @@ export function retryAction(look: BuildLook): "Retry" | undefined { export function buildTabReady(snapshot: Snapshot | undefined, look: BuildLook): boolean { return look.kind !== "plan" || !!snapshot && hasBuilt(snapshot); } + +/** A running build as Chat's run strip shows it, beside the workflow runs. */ +export type BuildRun = { + id: string; + /** A first build works through its tasks; a rebuild carries edits onto its pull requests. */ + kind: "build" | "rebuild"; + state: "queued" | "building"; + done: number; + total: number; + /** The task being built now, by title. */ + task?: string; + /** The coding agent's latest activity. */ + activity?: string; + /** When it was asked for, as an ISO time. */ + started: string; + /** Pull requests it has opened or is updating, newest last. */ + pullRequests: string[]; +}; + +/** + * The build Chat's run strip shows while one runs, or nothing. It is read from the same snapshot + * as Build, so the strip and Build never disagree; once the build ends the strip lets it go and + * Chat's line about how it ended stays. + */ +export function buildRun(snapshot: Snapshot | undefined): BuildRun | undefined { + if (!snapshot) return; + let rebuild = snapshot.live?.rebuild; + if (rebuild && RUNNING.has(rebuild.state)) { + return { + id: rebuild.id, + kind: "rebuild", + state: rebuild.state === "queued" ? "queued" : "building", + done: 0, + total: 0, + ...(rebuild.activity ? { activity: rebuild.activity.text } : {}), + started: rebuild.createdAt, + pullRequests: [...snapshot.live!.pullRequests], + }; + } + let build = snapshot.build; + if (!build || !RUNNING.has(build.state)) return; + let progress = snapshot.lifecycle.activity?.tasks ?? []; + let tasks = snapshot.graph?.definition.tasks ?? []; + let state = (id: string) => progress.find(task => task.id === id)?.state ?? "queued"; + let current = tasks.find(task => state(task.id) === "in_progress") + ?? tasks.find(task => state(task.id) === "queued"); + let { done, total } = progressCount(snapshot); + return { + id: build.id, + kind: "build", + state: build.state === "queued" ? "queued" : "building", + done, + total, + ...(current && build.state !== "queued" ? { task: current.title } : {}), + ...(build.activity ? { activity: build.activity.text } : {}), + started: build.createdAt, + pullRequests: [ + ...new Set(progress.flatMap(task => task.pullRequest ? [task.pullRequest.url] : [])), + ], + }; +} diff --git a/apps/web/src/chat/chat.tsx b/apps/web/src/chat/chat.tsx index 1da98921d..d050a7a5b 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -125,6 +125,7 @@ import { withNoticesAfter, } from "./references"; import { runCounts, RunStack } from "./run-card"; +import type { BuildRun } from "../build-model"; import { runStages } from "../api"; import { ResizeHandle } from "../resizable-pane"; import { WorkspaceThreadContext } from "../workspace"; @@ -199,6 +200,10 @@ export type ChatProps = { sourceDestination?: ChatDestination; /** Opens Decisions, where a waiting workflow's questions are. */ onShowDecisions?: () => void; + /** The document's running build, which the run strip shows first. */ + build?: BuildRun; + /** Opens Build, where the running build's tasks are. */ + onShowBuild?: () => void; /** * Opens the document's research composer with this brief, when this * document offers research, and says why when it cannot. @@ -315,6 +320,8 @@ export function Chat( machine, onTranscript, onShowDecisions, + build, + onShowBuild, onResearch, onShowResearch, referencesEnabled, @@ -1575,9 +1582,11 @@ export function Chat( {offlineBarNode &&
{offlineBarNode}
} {transcriptView} - {agent && !!runs?.length && ( + {(agent && !!runs?.length || build) && (
@@ -1585,7 +1594,7 @@ export function Chat( onResume={runId => wire?.send("chat:resume-run", { runId })} onShowDecisions={onShowDecisions} - runs={runs} + runs={agent ? runs ?? [] : []} />
)} diff --git a/apps/web/src/chat/run-card.test.tsx b/apps/web/src/chat/run-card.test.tsx index 1e62097a4..706f3fdca 100644 --- a/apps/web/src/chat/run-card.test.tsx +++ b/apps/web/src/chat/run-card.test.tsx @@ -456,3 +456,40 @@ test("the composer offers Resume only for paused runs whose Planner can hear it" expect(runCounts([away(run("c", "paused", 3)), run("d", "paused", 4)])) .toEqual({ active: 0, paused: 1 }); }); + +test("a running build heads the strip, unfolded with its task, activity and pull requests, and folds a running workflow beneath it", () => { + let build = { + id: "b1", + kind: "build" as const, + state: "building" as const, + done: 1, + total: 2, + task: "Prove the contract", + activity: "Running a command", + started: new Date(Date.now() - 90_000).toISOString(), + pullRequests: ["https://github.com/o/r/pull/12"], + }; + let markup = renderToStaticMarkup(createElement(RunStack, { + build, + onShowBuild: () => {}, + runs: [run("drafting", "running", 100)], + })); + let toggles = [...markup.matchAll(/aria-label="([^"]+)"[^>]*data-run-toggle/g)].map(match => + match[1] + ); + expect(toggles[0]).toMatch(/^Build, 1 of 2 tasks, 1m$/); + expect(toggles[1]).toMatch(/^drafting, running/); + expect(markup).toContain("Prove the contract"); + expect(markup).toContain("Running a command"); + expect(markup).toContain('aria-label="Pull request #12"'); + expect(markup).toContain(">Open Build"); + // Beside the build, the running workflow does not unfold itself. + expect(markup.match(/aria-expanded="true"/g)).toHaveLength(1); + + let queued = renderToStaticMarkup(createElement(RunStack, { + build: { ...build, kind: "rebuild" as const, state: "queued" as const, task: undefined }, + runs: [], + })); + expect(queued).toMatch(/aria-label="Building changes, queued, 1m"/); + expect(queued).not.toContain("Open Build"); +}); diff --git a/apps/web/src/chat/run-card.tsx b/apps/web/src/chat/run-card.tsx index 2d04d2b8e..16edb38cc 100644 --- a/apps/web/src/chat/run-card.tsx +++ b/apps/web/src/chat/run-card.tsx @@ -11,6 +11,7 @@ import { settledStages } from "@chopin/protocol/run-stages"; import "./run-row.css"; import type { Chat as Wire } from "@chopin/protocol"; +import type { BuildRun } from "../build-model"; const SHOWN_STAGES = 6; const SHOWN_RUNS = 3; @@ -474,11 +475,114 @@ function RunRow( ); } -export function RunStack( - { runs, loadStages, onShowDecisions, onPause, onResume, offline }: RunControls & { - runs: readonly Wire.Run[]; +/** `#12` for a pull request link, else nothing. */ +function pullNumber(url: string): string | undefined { + return url.match(/\/pull\/(\d+)(?:[/?#]|$)/)?.[1]; +} + +/** + * The document's running build, at the head of the strip: what it is on, what its agent is doing, + * and the pull requests it has opened, with the way into Build. It goes when the build ends; Chat's + * line about how it ended stays. + */ +function BuildRunRow( + { build, open, onToggle, onShowBuild }: { + build: BuildRun; + open: boolean; + onToggle: () => void; + onShowBuild?: () => void; }, ) { + let now = useNow(true); + let started = Date.parse(build.started) / 1000; + let since = elapsed(now - (Number.isFinite(started) ? started : now)); + let title = build.kind === "rebuild" ? "Building changes" : "Build"; + let status = build.state === "queued" + ? "queued" + : build.kind === "rebuild" || !build.total + ? "running" + : `${build.done} of ${build.total} tasks`; + let detailId = `build-run-${build.id}`; + let links = build.pullRequests.flatMap(url => { + let number = pullNumber(url); + return number ? [{ url, number }] : []; + }); + return ( +
  • +
    + +
    + {open && ( +
    + {build.task && ( +

    {build.task}

    + )} + {build.activity && ( +

    + {build.activity} +

    + )} +
    + {links.map(link => ( + + #{link.number} + + ))} + {onShowBuild && ( + + )} +
    +
    + )} +
  • + ); +} + +export function RunStack( + { runs, build, onShowBuild, loadStages, onShowDecisions, onPause, onResume, offline }: + & RunControls + & { + runs: readonly Wire.Run[]; + /** The document's running build, shown first; absent when none runs. */ + build?: BuildRun; + onShowBuild?: () => void; + }, +) { + let [buildOpen, setBuildOpen] = useState(true); let [chosen, setChosen] = useState>(new Map()); let [showAll, setShowAll] = useState(false); let ordered = orderRuns(runs); @@ -495,8 +599,18 @@ export function RunStack( data-run-stack="" >
      + {build && ( + setBuildOpen(open => !open)} + open={buildOpen} + /> + )} {shown.map(run => { - let open = chosen.get(run.id) ?? run.id === fallback; + // Beside a build, whose details are unfolded, only a run waiting on people unfolds itself. + let first = run.id === fallback && (!build || run.status === "waiting"); + let open = chosen.get(run.id) ?? first; return ( buildBlockers(build.snapshot), [build.snapshot]); + let runningBuild = useMemo(() => buildRun(build.snapshot), [build.snapshot]); // Touch has no tooltips, so a phone tap on Build, or on the build slot, says why it can't open. let buildUnavailable = build.look.kind === "plan" && build.look.blocked ? build.look.blocked @@ -2101,6 +2103,8 @@ export function RoomWorkspace( ? "You have read-only access to this document." : undefined} onShowDecisions={() => selectDestination("decisions")} + build={runningBuild} + onShowBuild={() => selectDestination("build")} onResearch={researchEnabled ? startResearch : undefined} onShowResearch={showResearchDraft} people={peopleHere(members)} diff --git a/e2e/implementation-launcher.e2e.ts b/e2e/implementation-launcher.e2e.ts index 36410f473..cfa2bf602 100644 --- a/e2e/implementation-launcher.e2e.ts +++ b/e2e/implementation-launcher.e2e.ts @@ -1,6 +1,6 @@ import { content, expect, roomPath, test } from "./room"; import { execFileSync, spawn } from "node:child_process"; -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { builtBuild, failedBuild } from "./build-seed"; @@ -470,7 +470,7 @@ for (let mode of ["block", "complete", "startup-failure", "live"] as const) { await expect(view.getByRole("link", { name: /^Pull request #\d+/ })).toHaveCount(2); await expect(blocked).toHaveAttribute("aria-expanded", "false"); // The tasks the connector started, completed and opened pull requests for show under - // Build, as the Planner says; Chat has no card for a Build or its tasks. + // Build; once it has ended, Chat's run strip lets the build go. await expect(view.getByRole("list", { name: "Tasks" }).locator(":scope > li")) .toHaveCount(2); let chat = page.getByRole("complementary", { name: "Chat", exact: true }); @@ -517,6 +517,76 @@ for (let mode of ["block", "complete", "startup-failure", "live"] as const) { }); } +test("a running build heads Chat's run strip with its task, and Chat says when it opens each pull request and when it finishes", async ({ seed, join: enter, room, baseURL }) => { + test.setTimeout(90_000); + await seed("# Chat sees the build\n\nValidate the local agent connection.\n", { + graph: preparedGraph(), + }); + let signals = await mkdtemp(join(tmpdir(), "chopin-strip-signals-")); + let paused = join(signals, "paused"); + let resume = join(signals, "resume"); + let local = await connector( + baseURL!, + ["bun", join(ROOT, "apps/connector/src/testing/fake-implementer.ts"), "--complete", "--http"], + { CHOPIN_TEST_PAUSED: paused, CHOPIN_TEST_RESUME: resume }, + ); + try { + let page = await enter(person()); + await page.goto(await local.pairing); + await page.getByRole("button", { name: "Add machine", exact: true }).click(); + await expect(page.getByRole("status").filter({ hasText: "Connected" })).toHaveText("Connected"); + await page.goto(testChannelPath(room)); + let chat = page.getByRole("complementary", { name: "Chat", exact: true }); + await expect(chat.getByRole("region", { name: "Workflow runs" })).toHaveCount(0); + await page.getByRole("button", { name: "Build plan", exact: true }).click(); + + // The agent is on its first task: the build heads the strip, unfolded, with that task. + await expect.poll(() => access(paused).then(() => true, () => false), { timeout: 30_000 }) + .toBe(true); + let strip = chat.getByRole("region", { name: "Workflow runs" }); + let row = strip.getByRole("button", { name: /^Build, 0 of 2 tasks, \d+s$/ }); + await expect(row).toBeVisible(); + await expect(row).toHaveAttribute("aria-expanded", "true"); + await expect(strip.getByText("Connect the local agent", { exact: true })).toBeVisible(); + await page.screenshot({ path: "/tmp/zd/build-visibility/shots/after-strip-desktop.png" }); + // The same build at a phone's width, in Chat's own pane. + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("navigation", { name: "Workspace view" }) + .getByRole("button", { name: /^Chat/ }).click(); + await expect(strip.getByRole("button", { name: /^Build, 0 of 2 tasks/ })).toBeInViewport(); + await expect(strip.getByText("Connect the local agent", { exact: true })).toBeInViewport(); + await page.screenshot({ path: "/tmp/zd/build-visibility/shots/after-strip-phone.png" }); + await page.setViewportSize({ width: 1280, height: 720 }); + // Open Build shows the build's tasks. + await strip.getByRole("button", { name: "Open Build", exact: true }).click(); + await expect(buildView(page)).toBeVisible(); + await expect(buildView(page).getByText("0 of 2 tasks", { exact: true })).toBeVisible(); + + await writeFile(resume, ""); + // One line for each pull request it opens, then one when it finishes, with their links. + let opened = chat.getByText(/^Build opened #10[12] for “/); + await expect(opened).toHaveCount(2, { timeout: 30_000 }); + await expect( + chat.getByText("Build opened #101 for “Connect the local agent”.", { exact: true }), + ) + .toBeVisible(); + let finished = chat.getByText("Build finished: 2 tasks, #101 and #102.", { exact: true }); + await expect(finished).toBeVisible({ timeout: 30_000 }); + await expect(finished.getByRole("link", { name: "#102" })).toHaveAttribute( + "href", + "https://github.com/octo-org/score/pull/102", + ); + await page.screenshot({ path: "/tmp/zd/build-visibility/shots/after-notices-desktop.png" }); + // The ended build leaves the strip; the lines stay, once each, after a reload. + await expect(strip).toHaveCount(0); + await page.reload(); + await expect(chat.getByText(/^Build (opened|finished)/)).toHaveCount(3); + } finally { + await local.close(); + await rm(signals, { recursive: true, force: true }); + } +}); + test("a build's agent opens draft pull requests with the machine's own GitHub token", async ({ seed, join: enter, room, baseURL }) => { test.setTimeout(60_000); await seed("# Draft pull requests\n\nOpen them as the machine's owner.\n", { From 4569dc6f5da05a63fcb7167dc5c556c40c6c2035 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 12 Nov 2026 15:02:10 -0800 Subject: [PATCH 3/5] Tell the Planner that Build plan is the approval, so it never says tasks wait for one After chopin rewrote a document's tasks it told people the new version "is a draft, so it still needs approval", but nothing anywhere offered an approval, and pressing Build went ahead. The code is clear that a person's press is the approval: queueBuild approves the exact draft it starts, and a built document's rebuild works from the document's own edits, not from the task graph, with no review. The Planner was only told "Only people may approve, lock or start implementation". edit_implementation_graph's description and the Planner's instructions now say that the Build plan press approves the saved draft and starts it, and a saved draft's result carries a note for the Planner to read before it says what happens next: on a document not yet built, Build plan builds exactly this draft; on a built one, Build carries the document's edits onto its pull requests and the draft is not what it runs. Build's card under "Not built yet" says "Build plan approves these tasks and builds them." Assistant-model: Claude Opus 5.5 Assistant-workflow: inline (subagent) Assistant-verification: bun test apps/server/src/agent apps/server/src/tasks (572 pass) Assistant-verification: bun run types Assistant-verification: bun run ci User-preference: "make a Build visible and approvable where people work" Co-authored-by: Alex Lavaee --- apps/server/src/agent/planner.ts | 4 +++- apps/server/src/agent/tools.ts | 20 ++++++++++++++++++-- apps/web/src/build-view.tsx | 8 +++++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/apps/server/src/agent/planner.ts b/apps/server/src/agent/planner.ts index 7d355fb69..0b605f8aa 100644 --- a/apps/server/src/agent/planner.ts +++ b/apps/server/src/agent/planner.ts @@ -177,7 +177,9 @@ dependencies only. Tasks with no dependency are independent roots and can run in parallel; a dependency means the later task cannot be safely completed first. Add, replace, reorder and remove definitions only through the graph tool. Never edit plan prose while preparing implementation, and never approve, -lock or start implementation — those are decisions and actions for people. +lock or start implementation — those are decisions and actions for people. A +person's Build plan press is the approval: it approves the saved draft and starts +it, so never tell people that saved tasks still need approval. Background work is separate from this conversation. Use \`list_background_jobs\` and \`read_background_job\` only when a later user request makes diff --git a/apps/server/src/agent/tools.ts b/apps/server/src/agent/tools.ts index aff4cbb28..e4f7d171b 100644 --- a/apps/server/src/agent/tools.ts +++ b/apps/server/src/agent/tools.ts @@ -105,6 +105,20 @@ const GRAPH_REFUSALS: Record = { "stale-graph": "The graph changed. Call read_implementation_graph again.", }; +/** + * What a saved draft means, for the Planner to say: there is no separate approval. A first build's + * Build plan press approves exactly this draft and starts it. A built document's Build carries its + * edits onto the pull requests from the document itself, so a draft there is not what it runs. + */ +function draftNote(plan: { live?: unknown }): string { + return plan.live + ? "Saved as a draft. This document is already built: Build carries the document's edits " + + "onto its pull requests from the document itself, not from this graph, and needs no " + + "approval. Don't say the draft needs approval; say that Build builds the changes." + : "Saved as a draft. Pressing Build plan approves exactly this draft and starts it; there " + + "is no other approval step. Don't say it needs approval; say that Build plan builds it."; +} + function sameMemberRequest( current: ActiveMemberRequest | undefined, expected: Pick, @@ -709,7 +723,9 @@ export const documentTools = { description: "Create or revise the draft implementation graph against the plan and graph " + "revisions from read_implementation_graph. Submit one atomic batch of add, replace, " + "reorder and remove operations. This never changes plan content. Only people may " - + "approve, lock or start implementation.", + + "approve, lock or start implementation: a person's Build plan press approves the saved " + + "draft and starts it, so never tell people a draft still needs approval. Read the " + + "result's note before you describe what happens next.", inputSchema: jsonSchema({ type: "object", properties: { @@ -752,7 +768,7 @@ export const documentTools = { let ready = implementationReadiness(context.plan, args.planRevision); if (!ready.ok) return { ok: false, reason: "not-ready", blockers: ready.blockers }; let result = await implementationGraphs().revise(context.plan, args); - if (result.ok) return { ok: true, graph: result.value }; + if (result.ok) return { ok: true, graph: result.value, note: draftNote(context.plan) }; let message = GRAPH_REFUSALS[result.reason]; return { ok: false, reason: result.reason, ...(message ? { message } : {}) }; }), diff --git a/apps/web/src/build-view.tsx b/apps/web/src/build-view.tsx index 1fe4c3e54..05cd74983 100644 --- a/apps/web/src/build-view.tsx +++ b/apps/web/src/build-view.tsx @@ -240,9 +240,15 @@ export function BuildView( if (phase.kind === "drafting" && phase.draft === "returned") { return { title: "Returned for changes", detail: "Build plan builds it again." }; } + // Build plan is the approval: pressing it approves these tasks and starts them. return { title: "Not built yet", - ...(phase.kind === "review" ? { count: plural(tasks.length, "task", "tasks") } : {}), + ...(phase.kind === "review" + ? { + count: plural(tasks.length, "task", "tasks"), + detail: "Build plan approves these tasks and builds them.", + } + : {}), }; } if (look.kind === "tasks") { From 3c7bdad3909deafaf557d7e8cfff7c0dadfef713 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 12 Nov 2026 15:02:18 -0800 Subject: [PATCH 4/5] Fade Chat's transcript into the pane above the run strip, and keep its last line clear of it The maintainer saw Chat's transcript run under the workflow runs card. The transcript does end at the card's top edge, but a line scrolled past it ended on a hard cut against the card, which reads as the card covering it. Its last 16px now fade into the pane, as its top edge already fades under the header. The fade is drawn over the scroller, never inside it, so scrolling restyles nothing (a scroll-state query on the scroller restyled the whole transcript once it left the bottom); resting on the newest line it lies on the transcript's own bottom space, under no text. A browser spec seeds a long Chat and four runs and checks, on a desktop window and a phone, that at the transcript's end its scroll area, newest message and scrollbar end above the card, that the newest message clears the fade, and that this holds as runs unfold, as every run shows, and as they fold again. DESIGN.md records the build in the strip, Chat's build lines and the edge. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline (subagent) Assistant-verification: bun run types Assistant-verification: bun run ci Assistant-verification: bun run e2e e2e/chat-row-edges.e2e.ts e2e/chat-run-strip.e2e.ts e2e/chat-hover-contrast.e2e.ts --workers=2 (10 passed with the forced-layout line of "moves only its thumb" logged instead of asserted: 160 forced layouts, as on main; dirty objects 15 and restyles 12, as on main) User-preference: "nit, but the scroll box gets clipped under the workflow widget" Co-authored-by: Alex Lavaee --- apps/web/DESIGN.md | 2 +- apps/web/src/chat/overlay-scrollbar.css | 20 +++++ e2e/chat-run-strip.e2e.ts | 112 ++++++++++++++++++++++++ e2e/playwright.config.ts | 2 + 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 e2e/chat-run-strip.e2e.ts diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 2a8cab1d4..8a93294ab 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -208,7 +208,7 @@ Rectangular controls use a restrained four-step radius scale: small for grips an - **Chat threads, context usage and compaction:** Any delivered Chat message can open a side thread (_Reply in thread_). On desktop split layouts, opening a thread splits the Chat area the way Slack splits a channel and a thread: the thread panel takes roughly 40% of the combined Chat and Thread width (360–560px) to the right of main Chat (at least 420px), separated by a 1px divider and its own draggable resize handle (_Resize thread_). The document pane yields width first down to 480px; when the window cannot hold all three panes at their minimums, the document folds into a compact top strip keeping its title, presence and a _Document_ button that closes the thread and restores the sheet, while Chat and Thread share the space cleanly. On phones and compact viewports, a thread opens as a full-height sheet with _Back to Chat_; pressing `Escape` or closing returns focus to the message control or summary row that opened it. In main Chat, _Reply in thread_ is a button of the message's toolbar (see Message toolbar), the root message's row carries the selected row shade while its thread is open, and once a thread has replies it shows an inline summary button: up to three replier faces in first-reply order, _N replies_ in petrol, and the relative age (_Last reply 2m ago_, giving way to _View thread ›_ on hover or focus). Inside the thread panel, a quiet provenance line (_Forked from Chat at 3:24 PM. chopin remembers everything up to here._) leads the root message, drawn as Chat draws it, a hairline _N replies_ divider separates the replies, the root and every reply have the message toolbar and reaction pills without _Reply in thread_, as Slack's thread view has them (the root's reactions are its Chat message's, and a reaction chopin answers is answered in the thread), and the thread reuses the main composer through a single boundary with an _Also send to Chat_ checkbox underneath. A reply sent that way is one message in both transcripts; when it asks chopin, chopin's answer (each of its messages as it is shown), a failure banner and the sender's _Retry_ appear in main Chat too, while the turn runs in the thread's own session. A failed reply in the thread shows main Chat's failure banner, with _Retry_ for its sender, above the thread's composer. A harness's token usage is not drawn: neither composer has a context ring (`ComposerContext`, still in the copied `elements-composer`, is not used), and `elements-context-breakdown` appears only on the design-audit plate. When a session compacts context, a quiet inline spinner (_Compacting earlier messages…_) gives way to an expandable hairline divider (_Earlier messages were summarized_) built on `Unfold` with message and saved-token counts as spaced siblings. - **Machine fix-it steps:** One component (`fix-steps.tsx`, its words from `machine-fix.ts`), drawn at the top of the machine button's menu and under _How to fix_ in chopin's ephemeral row, and loaded only once a machine has something to fix. A bold title in the base role (_Can't connect to Build box_), one plain sentence of why in secondary ink (the machine's reported line in words: _A program it needs can't be found on it._, _It runs an older chopin that can't say why it stops._, else _It stops each time before it connects._), then numbered steps, a quiet tabular number in the gutter beside one action each in primary ink, a command never in the sentence but alone under it in a well like Chat's code blocks (the inset surface, a hairline, the large radius, monospace in the extra-small role, wrapping at a space or within a long word, never wider than the menu) with a 24px ghost Copy button that turns to a success check for two seconds and says _Copied_ to a screen reader. Only the viewer's own machine is told to run something (_On Build box, see why it stops:_ `chopin status`); another's is _Ask @mona to run this on their machine:_. The last step says nothing needs pressing (_Wait a moment._ with _It connects again by itself once it's fixed._ in secondary ink under it), and, where the viewer may choose, _Or choose another machine below._ (in the note, _Choose machine_ opens the menu). A hairline sets apart the foot: _Show details_ at its start unfolds, under it, the machine's own words and when it was last tried (_Last tried at 6:29 PM._), exactly as reported, in quiet monospace; at its end, _Learn more_ in petrol. How often it was tried is never said, here or anywhere a person reads: a machine is connecting, connected or can't connect. No alarm colour: nothing is lost and Chopin keeps trying. Inside the menu every control is one of its items, so the arrow keys reach them, and on a phone's sheet they work without closing it. The menu is a little wider (23rem) while it holds them. - **Message delivery:** Nothing a person sends to Chopin is held for later, so nothing about a message waits, is queued or carries a clock or ticks. A person's own message to the document's machine enters Chat at once as an ordinary message and the composer empties; while a turn runs it is given to that turn and reads as any other message, where it was sent among the Planner's own lines. While only this browser has it, because its own connection is not back or a starting machine is still attaching, it says _Sending_ in the tertiary ink at the line's end, faded in after a moment so that a send the server answers at once never shows it; once the server has it there is no mark. When the machine is away, still reconnecting in its hand-over window (_Still connecting to @mona's machine, so this message wasn't sent. Send it again in a moment._), or unattached, or when the running turn cannot take a message (_chopin can't take messages while it works on this machine. Stop it to ask now._), and the composer holds no newer draft, the typed words return to the draft (with the one button offering Stop while Chopin works, then Send once stopped), so nothing typed is lost, and chopin says why to this person alone, as Slack's ephemeral messages do: the last row of the transcript, under a quiet _Only visible to you_ with a small lock over the text column, is chopin's ordinary row (its mark and presence, its name, its tag and the time) with the server's sentence in the message role and, where the machine has something to fix, _How to fix_ in petrol after it, which unfolds the fix-it steps. Nothing about it is an error or an alert: the composer keeps its own edge and it is said politely, once. It stays until chopin takes a message or another refusal takes its place, and goes with the page. When a newer draft is already in the composer, or another refusal turns the message down, the message says so on itself: a warning glyph, _Couldn't send._ in the destructive ink with the reason in plain words (for a machine that is away: _@mona's machine is offline, so this message wasn't sent. Send it again when it's back._), and _Try again_ and _Delete_ in its menu (see Message toolbar), which act on this browser alone. While the page's offline notice shows, both give way to it and return with the connection. -- **Message rows:** Chat lays a message out as Slack does (`c-message_kit__gutter`), in Chopin's colours: a 36px rounded-square avatar (a person's face, or Chopin's mark for the Planner) 8px from the text, the name in bold with the time in the extra-small role and tertiary ink beside it, and the text under it in the base role (15px on about 22px, as Slack's `c-message_kit__message`). Consecutive messages from one person are grouped: only the first shows the avatar and name, and each after it shows its time in the avatar's gutter while its row is shown. A shown row is shaded across the pane's whole width and its whole pitch, square, as Slack's rows tile its message pane: from the middle of the gap above it to the middle of the gap below it (half of the 4px between messages of a group, half of the 16px between the transcript's rows, over the name line of the first message in a group), so neighbouring rows' shades meet with no unshaded band between them and none over another, and a mouse anywhere in that pitch, the avatar and the gaps included, is on that row (a touch screen keeps the rows' own boxes, so a reaction's 44px target past its row still takes the tap); it is shaded in the Chat row shade (`--color-chat-row-hover`): ink at 4% over whatever the transcript lies on, so it is always the same darker step, 1.08:1 on the ground, the Chat pane and a phone's page alike, a little firmer than Slack's hovered `#f8f8f8` on white (1.06:1) and never a lighter one, which on the ground read as no hover at all. A message whose thread is open keeps its row in the selected row shade (`--color-chat-row-selected`, ink at 6.5%, 1.16:1, one such step past the hover), and a message jumped to is washed in the brand wash in the same square, full-width row, which then fades. Everything drawn on any of them meets AA against what is behind it, read from the rendered row by `e2e/chat-hover-contrast.e2e.ts`: the time 5.1:1 or more, petrol links and the replies count 4.5:1 or more, and the toolbar's icons 4.96:1 on its white box. Every one of these reaches Chat's very edges, the edges a person sees it lie in: beside the document, Chat has no frame of its own and lies on the ground from the sidebar's edge (or the window's) to the document's sheet, or to the thread's divider, and a thread from that divider to the sheet (or to its own panel's edge, where its header's rule ends, while the document is folded to its bar); alone, on a phone or in a narrow window, the edges are its pane's. A row is shaded across all of it, the 8px before the sheet where Resize chat is taken included, with its text where it always was: the transcript reaches past its column into the gutters around it, its rows keep their 20px gutters inside that, and its stack clips the shade at the edges. Nothing is set aside at either edge because the transcript draws its own scrollbar over its rows, as Slack's message pane does, and sets no lane aside for one: the system's bar is hidden on every engine (forced colours keep it), and a 6px round thumb inset in a 10px lane that stops 6px short of each end, in the strongest scrollbar ink (4:1 or more on the ground and every shade and wash), shows while the pane is scrolled or pointed at, darkens to the tertiary ink under the pointer and while dragged, and sits over the rows' end, clear of Resize chat's grip and of the toolbar 12px in; keyboard scrolling, anchoring and screen readers are the scroller's own. The workflow runs card under the transcript does the same inside its own box: a run's line is shaded in the hover surface across the card's whole width under the pointer, square, its first and last lines rounded only by the card's corners; the hairline above a line is its own and the shade runs under it, so a pointer moving down the card passes from one line's shade into the next with no band between. Above its first row the transcript keeps 20px more for that row's toolbar, which stands over the row, so at the top of a Chat the first message's bar, its emoji and their shadow are whole. Who speaks is said the way Slack says an app is one. A person has their GitHub picture and no tag. Everyone else is an app with a 36px rounded-square app avatar and, between the name and the time, a tag of its kind, after Slack's `APP` (`c-app_badge`): the word in the extra-small role, bold capitals, secondary ink, 4px inside a box of ink at a tenth with the small radius, so it reads the same on a shaded row. The Planner is _Agent_, named `chopin`, with Chopin's mark; a research child is _Subagent_, named _Research_, with a sparkle on the brand wash, in its own row when it says it is ready or could not finish and when its report card arrives; each tool call is _Tool_, beside its name on its line. What an app attaches to its message (the Planner's tool calls once opened, the handoff to research and the research workers' progress) is drawn as Slack's attachment (`c-message_attachment`): a 4px rounded bar in the edge colour down its start edge, petrol while the work runs, and the content 12px beside it. The handoff and the workers are assistant-ui's `AgentHandoff` and `SubagentList`, copied unmodified and recorded, filling the attachment's width. +- **Message rows:** Chat lays a message out as Slack does (`c-message_kit__gutter`), in Chopin's colours: a 36px rounded-square avatar (a person's face, or Chopin's mark for the Planner) 8px from the text, the name in bold with the time in the extra-small role and tertiary ink beside it, and the text under it in the base role (15px on about 22px, as Slack's `c-message_kit__message`). Consecutive messages from one person are grouped: only the first shows the avatar and name, and each after it shows its time in the avatar's gutter while its row is shown. A shown row is shaded across the pane's whole width and its whole pitch, square, as Slack's rows tile its message pane: from the middle of the gap above it to the middle of the gap below it (half of the 4px between messages of a group, half of the 16px between the transcript's rows, over the name line of the first message in a group), so neighbouring rows' shades meet with no unshaded band between them and none over another, and a mouse anywhere in that pitch, the avatar and the gaps included, is on that row (a touch screen keeps the rows' own boxes, so a reaction's 44px target past its row still takes the tap); it is shaded in the Chat row shade (`--color-chat-row-hover`): ink at 4% over whatever the transcript lies on, so it is always the same darker step, 1.08:1 on the ground, the Chat pane and a phone's page alike, a little firmer than Slack's hovered `#f8f8f8` on white (1.06:1) and never a lighter one, which on the ground read as no hover at all. A message whose thread is open keeps its row in the selected row shade (`--color-chat-row-selected`, ink at 6.5%, 1.16:1, one such step past the hover), and a message jumped to is washed in the brand wash in the same square, full-width row, which then fades. Everything drawn on any of them meets AA against what is behind it, read from the rendered row by `e2e/chat-hover-contrast.e2e.ts`: the time 5.1:1 or more, petrol links and the replies count 4.5:1 or more, and the toolbar's icons 4.96:1 on its white box. Every one of these reaches Chat's very edges, the edges a person sees it lie in: beside the document, Chat has no frame of its own and lies on the ground from the sidebar's edge (or the window's) to the document's sheet, or to the thread's divider, and a thread from that divider to the sheet (or to its own panel's edge, where its header's rule ends, while the document is folded to its bar); alone, on a phone or in a narrow window, the edges are its pane's. A row is shaded across all of it, the 8px before the sheet where Resize chat is taken included, with its text where it always was: the transcript reaches past its column into the gutters around it, its rows keep their 20px gutters inside that, and its stack clips the shade at the edges. Nothing is set aside at either edge because the transcript draws its own scrollbar over its rows, as Slack's message pane does, and sets no lane aside for one: the system's bar is hidden on every engine (forced colours keep it), and a 6px round thumb inset in a 10px lane that stops 6px short of each end, in the strongest scrollbar ink (4:1 or more on the ground and every shade and wash), shows while the pane is scrolled or pointed at, darkens to the tertiary ink under the pointer and while dragged, and sits over the rows' end, clear of Resize chat's grip and of the toolbar 12px in; keyboard scrolling, anchoring and screen readers are the scroller's own. The workflow runs card under the transcript does the same inside its own box: a run's line is shaded in the hover surface across the card's whole width under the pointer, square, its first and last lines rounded only by the card's corners; the hairline above a line is its own and the shade runs under it, so a pointer moving down the card passes from one line's shade into the next with no band between. A document's running build heads that card, as Slack's workflow status leads an app's message: one line, a pulsing dot, _Build_ (or _Building changes_ for a rebuild), _1 of 2 tasks_ and its age, unfolded beneath into the task being built, its agent's latest activity, the pull requests it has opened as `#12` links and _Open Build_; beside it a running workflow folds itself unless it waits on people, and the build leaves the card the moment it ends. Chat says what the build did in its quiet system lines, with a wrench in place of the system icon: once for each pull request it opens (_Build opened #12 for “Scaffold the crate”._) and once when it ends (_Build finished: 2 tasks, #12 and #13._, or why it stopped), each `#` a link to its pull request; a document opened long after its builds says nothing of them. The transcript ends at the card's top edge, its newest line and its scrollbar above it, and its last 16px fade into the pane, as its top edge fades under the header, so a line scrolled past it never reads as hidden under the card; resting on the newest line the fade lies on the transcript's own bottom space, under no text. The fade is drawn over the scroller, never in it, so scrolling restyles nothing. Above its first row the transcript keeps 20px more for that row's toolbar, which stands over the row, so at the top of a Chat the first message's bar, its emoji and their shadow are whole. Who speaks is said the way Slack says an app is one. A person has their GitHub picture and no tag. Everyone else is an app with a 36px rounded-square app avatar and, between the name and the time, a tag of its kind, after Slack's `APP` (`c-app_badge`): the word in the extra-small role, bold capitals, secondary ink, 4px inside a box of ink at a tenth with the small radius, so it reads the same on a shaded row. The Planner is _Agent_, named `chopin`, with Chopin's mark; a research child is _Subagent_, named _Research_, with a sparkle on the brand wash, in its own row when it says it is ready or could not finish and when its report card arrives; each tool call is _Tool_, beside its name on its line. What an app attaches to its message (the Planner's tool calls once opened, the handoff to research and the research workers' progress) is drawn as Slack's attachment (`c-message_attachment`): a 4px rounded bar in the edge colour down its start edge, petrol while the work runs, and the content 12px beside it. The handoff and the workers are assistant-ui's `AgentHandoff` and `SubagentList`, copied unmodified and recorded, filling the attachment's width. - **Message toolbar:** Every Chat message, a person's or the Planner's, has one floating box after Slack's (`c-message_actions__container` and `c-message_actions__group`): absolutely placed at the row's top right, 12px in from the row's right edge (from where Resize chat's grip begins, where the row is shaded over it) and 16px above its top, over the row's edge, so it never moves the text or changes the row's height. Nothing is set aside for it: every line of a message wraps only at the text column's end, and while the bar shows it may cover the end of the first line. A message whose top is scrolled out of the pane, short or tall, in Chat or a thread, keeps its bar in view as Slack's does: it is sticky on a rail down the row's end, so it stands at the top of what shows of the row, just clear of the pane's top fade (8px from the edge where nothing above is hidden), follows the pane as it scrolls, may stand over the lines beside it or the row below, leaves with the row's last line and is back at the row's corner once its top is in view; the browser keeps it there and nothing is measured. The shown row is drawn over the other rows (under their names and avatars, the pane's top fade and its scrollbar), so its bar is whole wherever it stands; a row with only its last line showing has its bar beside that line, half over the row below. It is a rounded square (8px, `--radius-lg`) of the page's surface with a hairline ring in the button outline's edge (ink at 12%, as Slack's is at 13%) and the strong resting shadow, so its white box stands off a shown row's shade (1.2:1, and 1.27:1 on the selected row), 2px inside, holding 32px square buttons with 4px corners, Slack's 20px icons and no gap between them, each greyed in the hover surface under the pointer and named by the shared dark tooltip above it. This bar and the reaction pills are the deliberate exceptions to the no-shadow, no-bordered-box rule. It holds, in order, three quick reactions (✅ _Looks right_, ❓ _Explain this_, 👎 _Not quite_, each named and with tooltips by those words and a toggle with `aria-pressed`), _Add reaction_, _Reply in thread_ (where the message can have a thread: it opens the thread, or starts it; never in a thread's own panel) and a vertical three-dots button, _Message actions_, and nothing else. Only one row shows its bar and shade at a time: the row whose menu or picker is open, else the hovered row, else the row with keyboard focus. The action row (`MessageActions`, `elements-message-actions` with `elements-surfaces`) and the overflow menu (`DropdownMenu`, shadcn's `dropdown-menu` on Base UI Menu) are copied from their registries into `src/components` (`THIRD_PARTY_NOTICES.md` and `src/components/sources.json`) and carry only a recorded extension-point patch (`message-actions.tsx.patch`) for Chopin's items, menu trigger and copy-to-check icon swap; Chopin's `.chat-message-toolbar` class on the copied row gives it Slack's box, and the menu keeps its registry geometry. The menu offers _Copy_ (the stored Markdown, character for character), _Copy as plain text_ (what the page shows: Markdown syntax gone, tables, strikethrough and task lists rendered as text, a link as its text, list items on their own lines, `@handle` and the Planner address as drawn), then _Try again_ and _Delete_ for a message the server refused. Copying swaps the menu item's copy icon for a check (`use-copy-to-clipboard`, MIT; see `THIRD_PARTY_NOTICES.md`) and its label for _Copied_, the menu closes when that ends and says _Copy_ again, and a polite live region says _Copied_ once. The bar shows on hover, on keyboard focus (one tab stop, arrow keys inside, a `toolbar`) and while its menu or the emoji picker is open; on a touch screen it comes from a long press, sits wholly above the message in a lane of its own so it never covers its text, and keeps its 32px visible buttons with 44px invisible `::after` touch targets. Add reaction opens Slack's emoji picker (see _Emoji_), and the picker takes one emoji typed or pasted, kept exactly as written. Reactions sit under the message as Slack's pills (`c-reaction`): 24px tall with round ends, 6px inside, the emoji and its count 2px apart, on the inset fill with a hairline ring in the edge colour; your own is in the accent's wash and ring with its count in bold, and a pointer on the row finds Slack's add-reaction pill (`c-reaction_add`, 34px wide) after them, a shortcut to the toolbar's _Add reaction_ that a keyboard and a screen reader reach there instead. Who reacted is in the tooltip and name; pressing a pill toggles yours. They are stored per message and per person, live for everyone in the room, and shown at once as pending (dimmed) when only this browser has the message or the connection is down, then sent when it can be. The server takes one extended grapheme cluster of at most 64 bytes as an emoji and 20 different reactions from a person to a message; one it turns down is not shown, and the message says _Couldn't react_ and why, in a polite status line that goes at the person's next reaction. - **Markdown text:** Read-only Markdown (Chat messages, a decision card's text and the phrases in it) is drawn by `MarkdownText`, copied from [assistant-ui](https://www.assistant-ui.com/elements/markdown-text)'s registry (`@assistant-ui/markdown-text`, with `tooltip-icon-button`, `use-copy-to-clipboard` and shadcn's `tooltip`) and recorded in `src/components/sources.json`. Because `markdown-text` has no standalone registry entry and Chat has no assistant-ui runtime, a recorded patch (`markdown-text.tsx.patch`) forwards props, sets `data-slot="markdown-text"`, and exposes `CodeHeader`'s button slot and `defaultComponents`, while `tsconfig.json` and `vite.config.ts` map `@assistant-ui/react` and `@assistant-ui/react-markdown` to Chopin's props-driven `react-markdown` seam (`src/chat/assistant-ui/markdown-primitive.tsx`). Its elements keep upstream's `aui-md-*` Tailwind classes, sizes, borders and radii on the generated geometry layer and take only Chopin's colours through `src/assistant-ui.css`; a decision card opts out of that root slot so its prose and diagram controls keep the document's fluid scale and focus ring. A surface passes only what differs: Chat draws a heading as a line of text, and a link to another site opens in a new tab while one into the document or another of Chopin's documents is followed in place, as the document's own are; a card reads no tables, strikethrough or autolinks except in an option's own visual, draws a Mermaid fence with the document's renderer through `componentsByLanguage`, and loads only the document's own images. Chat's messages also read GitHub's tables, strikethrough (two tildes) and task lists, whose boxes are named _Task_; footnotes and bare addresses stay plain text there, as they always were. A fenced block in Chat is Slack's code block in Chopin's colours, restyled from outside the copy by Chat's own unlayered rules (`src/chat/markdown.css`) as Chat's images are: one box the full width of the message's text, a hairline in the edge colour, the large radius and the inset ground, its one slim header row (upstream's `CodeHeader`, 4px above and beside its 24px `TooltipIconButton`, no band or rule under it) holding the language at the start in the extra-small role and tertiary ink and the copy control (_Copy code_, then for three seconds _Copied_ with a check, or _Couldn't copy_ with a warning glyph in the danger icon role, which a touch screen shows without a tooltip) at the end, then the code at Chopin's code text role (the mono stack, the small text role on 1.5, tabs of two), padded 12px, whose long lines scroll inside the block in a lane the keyboard reaches, on a phone too. A block whose fence names a language is coloured with the document's own highlighter (`@chopin/editor/code-tokens`, Shiki through `@pierre/diffs`, loaded with the first such block), each token in one of Chopin's roles at 4.5:1 or more on the inset ground: keywords in petrol ink, functions and types in the merged plum, strings in the success ink, numbers and constants in the warning ink, comments and punctuation in the tertiary ink, a diff's added and removed lines in the success and destructive inks; a block that names none, or names plain text, stays as written, and a block that streams in colours only the lines it gains. A card's code has none of this. A table scrolls inside its own lane. Raw HTML never reaches the page as markup: Chat drops it and a card shows it as the text it was written as. The collaborative editor is not drawn this way. A document shown read-only (an earlier version) stays in the editor's own read-only view, because it carries decisions, diagrams, comments and tables the dialect defines and MarkdownText does not. - **Touch targets:** A control keeps its visible box on a touch screen. Where its box is under 44px (`--control-target-coarse`) an invisible `::after` of that size grows around it, so a finger just outside the control still reaches it, and the control never gets bigger, taller or spaced differently to make room for one. This applies only when the primary pointer is coarse (`(pointer: coarse)`, mirrored as `data-plan-coarse-pointer`): a touch-screen laptop used with a mouse keeps the desktop's sizes. Stacked menu rows keep their own 28px, and a row that follows another stands 44px from it (one margin, which is the height the rows no longer take themselves) so the menu is as tall as before and neighbouring targets meet without overlapping. A control that clips its own overflow cannot grow a target past its edge. Copied assistant-ui elements follow the same rule through the generated layer. diff --git a/apps/web/src/chat/overlay-scrollbar.css b/apps/web/src/chat/overlay-scrollbar.css index 3fbf01880..b33a847bc 100644 --- a/apps/web/src/chat/overlay-scrollbar.css +++ b/apps/web/src/chat/overlay-scrollbar.css @@ -95,3 +95,23 @@ transition: none; } } + +/* + * The transcript's lower edge, above the run strip or the composer: its last 16px fade into the pane, + * so a line scrolled past it fades out as at the top edge instead of ending on a hard line that reads + * as the strip covering it. Resting on the newest line the fade lies on the transcript's own bottom + * space, under no text. It is drawn over the scroller rather than in it, so scrolling restyles nothing. + */ +.workspace-root:not([data-workspace-phone]) .chat-scroll-host::after { + content: ""; + position: absolute; + z-index: 3; + inset-inline: 0; + inset-block-end: 0; + block-size: calc(var(--spacing) * 4); + background: linear-gradient( + color-mix(in srgb, var(--scroll-edge-surface) 0%, transparent), + var(--scroll-edge-surface) + ); + pointer-events: none; +} diff --git a/e2e/chat-run-strip.e2e.ts b/e2e/chat-run-strip.e2e.ts new file mode 100644 index 000000000..c122fd625 --- /dev/null +++ b/e2e/chat-run-strip.e2e.ts @@ -0,0 +1,112 @@ +/** + * Chat's run strip and the transcript above it: the transcript's scroll area, its newest message and + * its scrollbar end at the strip's top edge however tall the strip grows, on a desktop window and a + * touch phone, against the `AGENT=on` harness project. + */ +import { expect, test } from "./room"; + +import type { Chat } from "../packages/protocol/index"; +import type { Page } from "@playwright/test"; + +const DESKTOP = { viewport: { width: 1280, height: 800 } }; +const PHONE = { hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } }; + +function messages(count: number): Chat.Entry[] { + return Array.from({ length: count }, (_, index) => + ({ + id: `m${String(index).padStart(3, "0")}`, + author: { kind: "member", handle: "bo" }, + text: `Message ${ + index + 1 + }: a line of conversation long enough to wrap in a narrow Chat column.`, + ts: 1_700_000_000_000 + index * 60_000, + }) as Chat.Entry); +} + +function workflow(id: string, status: Chat.Run["status"], stages = 1): Chat.Run { + let started = Math.floor(Date.now() / 1000) - 600; + return { + id, + name: `workflow-${id}`, + status, + started, + updated: started + 60, + ...(status === "running" ? {} : { ended: started + 60 }), + stages: Array.from({ length: stages }, (_, index) => ({ + id: `${id}:s${index}`, + name: `stage-${index + 1}`, + status: status === "running" && index === stages - 1 ? "running" : "completed", + started: started + index, + })), + waiting: 0, + }; +} + +function chatPane(page: Page) { + return page.getByRole("complementary", { includeHidden: true, name: "Chat" }); +} + +async function openChat(page: Page, phone: boolean): Promise { + if (!phone) return; + await page.getByRole("navigation", { name: "Workspace view" }) + .getByRole("button", { name: /^Chat/ }).click(); + await expect.poll(async () => (await chatPane(page).boundingBox())?.x).toBe(0); +} + +/** Where the transcript, its newest message and its scrollbar end, against the strip's top edge. */ +function geometry(page: Page) { + return page.evaluate(() => { + let scroll = document.querySelector("[data-chat-scroll]")!; + let strip = document.querySelector("[data-chat-runs]")!; + let rows = scroll.querySelectorAll("[data-chat-message-id]"); + let track = scroll.parentElement!.querySelector(".chat-scroll-track"); + return { + scrollBottom: scroll.getBoundingClientRect().bottom, + stripTop: strip.getBoundingClientRect().top, + lastBottom: rows[rows.length - 1]!.getBoundingClientRect().bottom, + trackBottom: track?.getBoundingClientRect().bottom ?? 0, + fromEnd: scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight, + }; + }); +} + +/** At the transcript's end its newest message, and the scrollbar, stand clear above the strip. */ +async function clearOfStrip(page: Page): Promise { + await expect.poll(async () => (await geometry(page)).fromEnd).toBeLessThanOrEqual(2); + let shape = await geometry(page); + expect(shape.scrollBottom).toBeLessThanOrEqual(shape.stripTop + 0.5); + expect(shape.lastBottom).toBeLessThanOrEqual(shape.stripTop + 0.5); + expect(shape.trackBottom).toBeLessThanOrEqual(shape.stripTop + 0.5); + // The edge's 16px fade lies on the transcript's own bottom space, under none of its text. + expect(shape.scrollBottom - shape.lastBottom).toBeGreaterThanOrEqual(16); +} + +test("a transcript that scrolls ends above the run strip, and stays on its newest message as the strip grows and shrinks", async ({ join, seed }) => { + test.setTimeout(90_000); + await seed("# Busy\n\nA long conversation.\n", { + transcript: messages(40), + workflowRuns: [ + workflow("a", "stopped", 4), + workflow("b", "finished", 6), + workflow("c", "failed", 3), + workflow("d", "finished"), + ], + }); + for (let [options, phone] of [[DESKTOP, false], [PHONE, true]] as const) { + let page = await join(phone ? "bo" : "ana", options); + await openChat(page, phone); + let strip = chatPane(page).getByRole("region", { name: "Workflow runs" }); + await expect(strip).toBeVisible(); + await clearOfStrip(page); + // Unfolding runs, showing every run and folding them again: the newest message stays. + await strip.getByRole("button", { name: /^workflow-a,/ }).click(); + await clearOfStrip(page); + await strip.getByRole("button", { name: /^workflow-b, finished/ }).click(); + await clearOfStrip(page); + await strip.getByRole("button", { name: "+1 more" }).click(); + await clearOfStrip(page); + await strip.getByRole("button", { name: /^workflow-b, finished/ }).click(); + await strip.getByRole("button", { name: /^workflow-a,/ }).click(); + await clearOfStrip(page); + } +}); diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index ec969a77b..29333b9f0 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -190,6 +190,7 @@ export default defineConfig({ "**/chat-tab-complete.e2e.ts", "**/chat-mention-format.e2e.ts", "**/run-card.e2e.ts", + "**/chat-run-strip.e2e.ts", "**/message-reactions.e2e.ts", "**/reaction-reader.e2e.ts", "**/auth-lifecycle.e2e.ts", @@ -233,6 +234,7 @@ export default defineConfig({ "**/chat-tab-complete.e2e.ts", "**/chat-mention-format.e2e.ts", "**/run-card.e2e.ts", + "**/chat-run-strip.e2e.ts", "**/message-reactions.e2e.ts", "**/reaction-reader.e2e.ts", ], From ab5e7f0812d35c18a00fea6c5fae244d2577f61d Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 12 Nov 2026 15:30:56 -0800 Subject: [PATCH 5/5] Keep Chat's runs card to half of Chat, scrolling in its own lane, so it never pushes the composer away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With several workflow runs unfolded the card under Chat's transcript grew to its full height: in a 600px window it measured 906px, squeezed the transcript to 12px above the window's top and pushed the composer below its bottom, on a desktop window and on a phone alike. Only a single run's "every stage" lane had a bound. The card's wrapper, in main Chat and in a thread, now takes at most half of Chat's height and scrolls beyond that, with its scrolling contained. The browser spec unfolds five runs of six stages in a 1280 × 600 window and a 390 × 664 phone and checks that the card is at most half of Chat and scrolls, that the transcript keeps its place and height, that the card ends above the composer and the composer inside Chat and the window, that the last run's stages are reached by scrolling the card, and that the newest message stays clear of it. Without the rule it fails (906 against 289). Assistant-model: Claude Opus 5.5 Assistant-workflow: inline (subagent) Assistant-verification: bun run e2e e2e/chat-run-strip.e2e.ts --project=harness --workers=2 (2 passed; the new test fails without the rule) User-preference: "nit, but the scroll box gets clipped under the workflow widget" Co-authored-by: Alex Lavaee --- apps/web/DESIGN.md | 2 +- apps/web/src/chat/chat.tsx | 2 +- apps/web/src/chat/run-card.css | 11 +++++ apps/web/src/chat/threads/thread-panel.tsx | 2 +- e2e/chat-run-strip.e2e.ts | 49 ++++++++++++++++++++++ 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 8a93294ab..7a10e9803 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -208,7 +208,7 @@ Rectangular controls use a restrained four-step radius scale: small for grips an - **Chat threads, context usage and compaction:** Any delivered Chat message can open a side thread (_Reply in thread_). On desktop split layouts, opening a thread splits the Chat area the way Slack splits a channel and a thread: the thread panel takes roughly 40% of the combined Chat and Thread width (360–560px) to the right of main Chat (at least 420px), separated by a 1px divider and its own draggable resize handle (_Resize thread_). The document pane yields width first down to 480px; when the window cannot hold all three panes at their minimums, the document folds into a compact top strip keeping its title, presence and a _Document_ button that closes the thread and restores the sheet, while Chat and Thread share the space cleanly. On phones and compact viewports, a thread opens as a full-height sheet with _Back to Chat_; pressing `Escape` or closing returns focus to the message control or summary row that opened it. In main Chat, _Reply in thread_ is a button of the message's toolbar (see Message toolbar), the root message's row carries the selected row shade while its thread is open, and once a thread has replies it shows an inline summary button: up to three replier faces in first-reply order, _N replies_ in petrol, and the relative age (_Last reply 2m ago_, giving way to _View thread ›_ on hover or focus). Inside the thread panel, a quiet provenance line (_Forked from Chat at 3:24 PM. chopin remembers everything up to here._) leads the root message, drawn as Chat draws it, a hairline _N replies_ divider separates the replies, the root and every reply have the message toolbar and reaction pills without _Reply in thread_, as Slack's thread view has them (the root's reactions are its Chat message's, and a reaction chopin answers is answered in the thread), and the thread reuses the main composer through a single boundary with an _Also send to Chat_ checkbox underneath. A reply sent that way is one message in both transcripts; when it asks chopin, chopin's answer (each of its messages as it is shown), a failure banner and the sender's _Retry_ appear in main Chat too, while the turn runs in the thread's own session. A failed reply in the thread shows main Chat's failure banner, with _Retry_ for its sender, above the thread's composer. A harness's token usage is not drawn: neither composer has a context ring (`ComposerContext`, still in the copied `elements-composer`, is not used), and `elements-context-breakdown` appears only on the design-audit plate. When a session compacts context, a quiet inline spinner (_Compacting earlier messages…_) gives way to an expandable hairline divider (_Earlier messages were summarized_) built on `Unfold` with message and saved-token counts as spaced siblings. - **Machine fix-it steps:** One component (`fix-steps.tsx`, its words from `machine-fix.ts`), drawn at the top of the machine button's menu and under _How to fix_ in chopin's ephemeral row, and loaded only once a machine has something to fix. A bold title in the base role (_Can't connect to Build box_), one plain sentence of why in secondary ink (the machine's reported line in words: _A program it needs can't be found on it._, _It runs an older chopin that can't say why it stops._, else _It stops each time before it connects._), then numbered steps, a quiet tabular number in the gutter beside one action each in primary ink, a command never in the sentence but alone under it in a well like Chat's code blocks (the inset surface, a hairline, the large radius, monospace in the extra-small role, wrapping at a space or within a long word, never wider than the menu) with a 24px ghost Copy button that turns to a success check for two seconds and says _Copied_ to a screen reader. Only the viewer's own machine is told to run something (_On Build box, see why it stops:_ `chopin status`); another's is _Ask @mona to run this on their machine:_. The last step says nothing needs pressing (_Wait a moment._ with _It connects again by itself once it's fixed._ in secondary ink under it), and, where the viewer may choose, _Or choose another machine below._ (in the note, _Choose machine_ opens the menu). A hairline sets apart the foot: _Show details_ at its start unfolds, under it, the machine's own words and when it was last tried (_Last tried at 6:29 PM._), exactly as reported, in quiet monospace; at its end, _Learn more_ in petrol. How often it was tried is never said, here or anywhere a person reads: a machine is connecting, connected or can't connect. No alarm colour: nothing is lost and Chopin keeps trying. Inside the menu every control is one of its items, so the arrow keys reach them, and on a phone's sheet they work without closing it. The menu is a little wider (23rem) while it holds them. - **Message delivery:** Nothing a person sends to Chopin is held for later, so nothing about a message waits, is queued or carries a clock or ticks. A person's own message to the document's machine enters Chat at once as an ordinary message and the composer empties; while a turn runs it is given to that turn and reads as any other message, where it was sent among the Planner's own lines. While only this browser has it, because its own connection is not back or a starting machine is still attaching, it says _Sending_ in the tertiary ink at the line's end, faded in after a moment so that a send the server answers at once never shows it; once the server has it there is no mark. When the machine is away, still reconnecting in its hand-over window (_Still connecting to @mona's machine, so this message wasn't sent. Send it again in a moment._), or unattached, or when the running turn cannot take a message (_chopin can't take messages while it works on this machine. Stop it to ask now._), and the composer holds no newer draft, the typed words return to the draft (with the one button offering Stop while Chopin works, then Send once stopped), so nothing typed is lost, and chopin says why to this person alone, as Slack's ephemeral messages do: the last row of the transcript, under a quiet _Only visible to you_ with a small lock over the text column, is chopin's ordinary row (its mark and presence, its name, its tag and the time) with the server's sentence in the message role and, where the machine has something to fix, _How to fix_ in petrol after it, which unfolds the fix-it steps. Nothing about it is an error or an alert: the composer keeps its own edge and it is said politely, once. It stays until chopin takes a message or another refusal takes its place, and goes with the page. When a newer draft is already in the composer, or another refusal turns the message down, the message says so on itself: a warning glyph, _Couldn't send._ in the destructive ink with the reason in plain words (for a machine that is away: _@mona's machine is offline, so this message wasn't sent. Send it again when it's back._), and _Try again_ and _Delete_ in its menu (see Message toolbar), which act on this browser alone. While the page's offline notice shows, both give way to it and return with the connection. -- **Message rows:** Chat lays a message out as Slack does (`c-message_kit__gutter`), in Chopin's colours: a 36px rounded-square avatar (a person's face, or Chopin's mark for the Planner) 8px from the text, the name in bold with the time in the extra-small role and tertiary ink beside it, and the text under it in the base role (15px on about 22px, as Slack's `c-message_kit__message`). Consecutive messages from one person are grouped: only the first shows the avatar and name, and each after it shows its time in the avatar's gutter while its row is shown. A shown row is shaded across the pane's whole width and its whole pitch, square, as Slack's rows tile its message pane: from the middle of the gap above it to the middle of the gap below it (half of the 4px between messages of a group, half of the 16px between the transcript's rows, over the name line of the first message in a group), so neighbouring rows' shades meet with no unshaded band between them and none over another, and a mouse anywhere in that pitch, the avatar and the gaps included, is on that row (a touch screen keeps the rows' own boxes, so a reaction's 44px target past its row still takes the tap); it is shaded in the Chat row shade (`--color-chat-row-hover`): ink at 4% over whatever the transcript lies on, so it is always the same darker step, 1.08:1 on the ground, the Chat pane and a phone's page alike, a little firmer than Slack's hovered `#f8f8f8` on white (1.06:1) and never a lighter one, which on the ground read as no hover at all. A message whose thread is open keeps its row in the selected row shade (`--color-chat-row-selected`, ink at 6.5%, 1.16:1, one such step past the hover), and a message jumped to is washed in the brand wash in the same square, full-width row, which then fades. Everything drawn on any of them meets AA against what is behind it, read from the rendered row by `e2e/chat-hover-contrast.e2e.ts`: the time 5.1:1 or more, petrol links and the replies count 4.5:1 or more, and the toolbar's icons 4.96:1 on its white box. Every one of these reaches Chat's very edges, the edges a person sees it lie in: beside the document, Chat has no frame of its own and lies on the ground from the sidebar's edge (or the window's) to the document's sheet, or to the thread's divider, and a thread from that divider to the sheet (or to its own panel's edge, where its header's rule ends, while the document is folded to its bar); alone, on a phone or in a narrow window, the edges are its pane's. A row is shaded across all of it, the 8px before the sheet where Resize chat is taken included, with its text where it always was: the transcript reaches past its column into the gutters around it, its rows keep their 20px gutters inside that, and its stack clips the shade at the edges. Nothing is set aside at either edge because the transcript draws its own scrollbar over its rows, as Slack's message pane does, and sets no lane aside for one: the system's bar is hidden on every engine (forced colours keep it), and a 6px round thumb inset in a 10px lane that stops 6px short of each end, in the strongest scrollbar ink (4:1 or more on the ground and every shade and wash), shows while the pane is scrolled or pointed at, darkens to the tertiary ink under the pointer and while dragged, and sits over the rows' end, clear of Resize chat's grip and of the toolbar 12px in; keyboard scrolling, anchoring and screen readers are the scroller's own. The workflow runs card under the transcript does the same inside its own box: a run's line is shaded in the hover surface across the card's whole width under the pointer, square, its first and last lines rounded only by the card's corners; the hairline above a line is its own and the shade runs under it, so a pointer moving down the card passes from one line's shade into the next with no band between. A document's running build heads that card, as Slack's workflow status leads an app's message: one line, a pulsing dot, _Build_ (or _Building changes_ for a rebuild), _1 of 2 tasks_ and its age, unfolded beneath into the task being built, its agent's latest activity, the pull requests it has opened as `#12` links and _Open Build_; beside it a running workflow folds itself unless it waits on people, and the build leaves the card the moment it ends. Chat says what the build did in its quiet system lines, with a wrench in place of the system icon: once for each pull request it opens (_Build opened #12 for “Scaffold the crate”._) and once when it ends (_Build finished: 2 tasks, #12 and #13._, or why it stopped), each `#` a link to its pull request; a document opened long after its builds says nothing of them. The transcript ends at the card's top edge, its newest line and its scrollbar above it, and its last 16px fade into the pane, as its top edge fades under the header, so a line scrolled past it never reads as hidden under the card; resting on the newest line the fade lies on the transcript's own bottom space, under no text. The fade is drawn over the scroller, never in it, so scrolling restyles nothing. Above its first row the transcript keeps 20px more for that row's toolbar, which stands over the row, so at the top of a Chat the first message's bar, its emoji and their shadow are whole. Who speaks is said the way Slack says an app is one. A person has their GitHub picture and no tag. Everyone else is an app with a 36px rounded-square app avatar and, between the name and the time, a tag of its kind, after Slack's `APP` (`c-app_badge`): the word in the extra-small role, bold capitals, secondary ink, 4px inside a box of ink at a tenth with the small radius, so it reads the same on a shaded row. The Planner is _Agent_, named `chopin`, with Chopin's mark; a research child is _Subagent_, named _Research_, with a sparkle on the brand wash, in its own row when it says it is ready or could not finish and when its report card arrives; each tool call is _Tool_, beside its name on its line. What an app attaches to its message (the Planner's tool calls once opened, the handoff to research and the research workers' progress) is drawn as Slack's attachment (`c-message_attachment`): a 4px rounded bar in the edge colour down its start edge, petrol while the work runs, and the content 12px beside it. The handoff and the workers are assistant-ui's `AgentHandoff` and `SubagentList`, copied unmodified and recorded, filling the attachment's width. +- **Message rows:** Chat lays a message out as Slack does (`c-message_kit__gutter`), in Chopin's colours: a 36px rounded-square avatar (a person's face, or Chopin's mark for the Planner) 8px from the text, the name in bold with the time in the extra-small role and tertiary ink beside it, and the text under it in the base role (15px on about 22px, as Slack's `c-message_kit__message`). Consecutive messages from one person are grouped: only the first shows the avatar and name, and each after it shows its time in the avatar's gutter while its row is shown. A shown row is shaded across the pane's whole width and its whole pitch, square, as Slack's rows tile its message pane: from the middle of the gap above it to the middle of the gap below it (half of the 4px between messages of a group, half of the 16px between the transcript's rows, over the name line of the first message in a group), so neighbouring rows' shades meet with no unshaded band between them and none over another, and a mouse anywhere in that pitch, the avatar and the gaps included, is on that row (a touch screen keeps the rows' own boxes, so a reaction's 44px target past its row still takes the tap); it is shaded in the Chat row shade (`--color-chat-row-hover`): ink at 4% over whatever the transcript lies on, so it is always the same darker step, 1.08:1 on the ground, the Chat pane and a phone's page alike, a little firmer than Slack's hovered `#f8f8f8` on white (1.06:1) and never a lighter one, which on the ground read as no hover at all. A message whose thread is open keeps its row in the selected row shade (`--color-chat-row-selected`, ink at 6.5%, 1.16:1, one such step past the hover), and a message jumped to is washed in the brand wash in the same square, full-width row, which then fades. Everything drawn on any of them meets AA against what is behind it, read from the rendered row by `e2e/chat-hover-contrast.e2e.ts`: the time 5.1:1 or more, petrol links and the replies count 4.5:1 or more, and the toolbar's icons 4.96:1 on its white box. Every one of these reaches Chat's very edges, the edges a person sees it lie in: beside the document, Chat has no frame of its own and lies on the ground from the sidebar's edge (or the window's) to the document's sheet, or to the thread's divider, and a thread from that divider to the sheet (or to its own panel's edge, where its header's rule ends, while the document is folded to its bar); alone, on a phone or in a narrow window, the edges are its pane's. A row is shaded across all of it, the 8px before the sheet where Resize chat is taken included, with its text where it always was: the transcript reaches past its column into the gutters around it, its rows keep their 20px gutters inside that, and its stack clips the shade at the edges. Nothing is set aside at either edge because the transcript draws its own scrollbar over its rows, as Slack's message pane does, and sets no lane aside for one: the system's bar is hidden on every engine (forced colours keep it), and a 6px round thumb inset in a 10px lane that stops 6px short of each end, in the strongest scrollbar ink (4:1 or more on the ground and every shade and wash), shows while the pane is scrolled or pointed at, darkens to the tertiary ink under the pointer and while dragged, and sits over the rows' end, clear of Resize chat's grip and of the toolbar 12px in; keyboard scrolling, anchoring and screen readers are the scroller's own. The workflow runs card under the transcript does the same inside its own box: a run's line is shaded in the hover surface across the card's whole width under the pointer, square, its first and last lines rounded only by the card's corners; the hairline above a line is its own and the shade runs under it, so a pointer moving down the card passes from one line's shade into the next with no band between. A document's running build heads that card, as Slack's workflow status leads an app's message: one line, a pulsing dot, _Build_ (or _Building changes_ for a rebuild), _1 of 2 tasks_ and its age, unfolded beneath into the task being built, its agent's latest activity, the pull requests it has opened as `#12` links and _Open Build_; beside it a running workflow folds itself unless it waits on people, and the build leaves the card the moment it ends. Chat says what the build did in its quiet system lines, with a wrench in place of the system icon: once for each pull request it opens (_Build opened #12 for “Scaffold the crate”._) and once when it ends (_Build finished: 2 tasks, #12 and #13._, or why it stopped), each `#` a link to its pull request; a document opened long after its builds says nothing of them. The transcript ends at the card's top edge, its newest line and its scrollbar above it, and its last 16px fade into the pane, as its top edge fades under the header, so a line scrolled past it never reads as hidden under the card; resting on the newest line the fade lies on the transcript's own bottom space, under no text. The fade is drawn over the scroller, never in it, so scrolling restyles nothing. However many runs are unfolded, the card takes at most half of Chat's height (main Chat's and a thread's alike) and scrolls in its own lane beyond that, so it never pushes the transcript off the top or the composer off the bottom. Above its first row the transcript keeps 20px more for that row's toolbar, which stands over the row, so at the top of a Chat the first message's bar, its emoji and their shadow are whole. Who speaks is said the way Slack says an app is one. A person has their GitHub picture and no tag. Everyone else is an app with a 36px rounded-square app avatar and, between the name and the time, a tag of its kind, after Slack's `APP` (`c-app_badge`): the word in the extra-small role, bold capitals, secondary ink, 4px inside a box of ink at a tenth with the small radius, so it reads the same on a shaded row. The Planner is _Agent_, named `chopin`, with Chopin's mark; a research child is _Subagent_, named _Research_, with a sparkle on the brand wash, in its own row when it says it is ready or could not finish and when its report card arrives; each tool call is _Tool_, beside its name on its line. What an app attaches to its message (the Planner's tool calls once opened, the handoff to research and the research workers' progress) is drawn as Slack's attachment (`c-message_attachment`): a 4px rounded bar in the edge colour down its start edge, petrol while the work runs, and the content 12px beside it. The handoff and the workers are assistant-ui's `AgentHandoff` and `SubagentList`, copied unmodified and recorded, filling the attachment's width. - **Message toolbar:** Every Chat message, a person's or the Planner's, has one floating box after Slack's (`c-message_actions__container` and `c-message_actions__group`): absolutely placed at the row's top right, 12px in from the row's right edge (from where Resize chat's grip begins, where the row is shaded over it) and 16px above its top, over the row's edge, so it never moves the text or changes the row's height. Nothing is set aside for it: every line of a message wraps only at the text column's end, and while the bar shows it may cover the end of the first line. A message whose top is scrolled out of the pane, short or tall, in Chat or a thread, keeps its bar in view as Slack's does: it is sticky on a rail down the row's end, so it stands at the top of what shows of the row, just clear of the pane's top fade (8px from the edge where nothing above is hidden), follows the pane as it scrolls, may stand over the lines beside it or the row below, leaves with the row's last line and is back at the row's corner once its top is in view; the browser keeps it there and nothing is measured. The shown row is drawn over the other rows (under their names and avatars, the pane's top fade and its scrollbar), so its bar is whole wherever it stands; a row with only its last line showing has its bar beside that line, half over the row below. It is a rounded square (8px, `--radius-lg`) of the page's surface with a hairline ring in the button outline's edge (ink at 12%, as Slack's is at 13%) and the strong resting shadow, so its white box stands off a shown row's shade (1.2:1, and 1.27:1 on the selected row), 2px inside, holding 32px square buttons with 4px corners, Slack's 20px icons and no gap between them, each greyed in the hover surface under the pointer and named by the shared dark tooltip above it. This bar and the reaction pills are the deliberate exceptions to the no-shadow, no-bordered-box rule. It holds, in order, three quick reactions (✅ _Looks right_, ❓ _Explain this_, 👎 _Not quite_, each named and with tooltips by those words and a toggle with `aria-pressed`), _Add reaction_, _Reply in thread_ (where the message can have a thread: it opens the thread, or starts it; never in a thread's own panel) and a vertical three-dots button, _Message actions_, and nothing else. Only one row shows its bar and shade at a time: the row whose menu or picker is open, else the hovered row, else the row with keyboard focus. The action row (`MessageActions`, `elements-message-actions` with `elements-surfaces`) and the overflow menu (`DropdownMenu`, shadcn's `dropdown-menu` on Base UI Menu) are copied from their registries into `src/components` (`THIRD_PARTY_NOTICES.md` and `src/components/sources.json`) and carry only a recorded extension-point patch (`message-actions.tsx.patch`) for Chopin's items, menu trigger and copy-to-check icon swap; Chopin's `.chat-message-toolbar` class on the copied row gives it Slack's box, and the menu keeps its registry geometry. The menu offers _Copy_ (the stored Markdown, character for character), _Copy as plain text_ (what the page shows: Markdown syntax gone, tables, strikethrough and task lists rendered as text, a link as its text, list items on their own lines, `@handle` and the Planner address as drawn), then _Try again_ and _Delete_ for a message the server refused. Copying swaps the menu item's copy icon for a check (`use-copy-to-clipboard`, MIT; see `THIRD_PARTY_NOTICES.md`) and its label for _Copied_, the menu closes when that ends and says _Copy_ again, and a polite live region says _Copied_ once. The bar shows on hover, on keyboard focus (one tab stop, arrow keys inside, a `toolbar`) and while its menu or the emoji picker is open; on a touch screen it comes from a long press, sits wholly above the message in a lane of its own so it never covers its text, and keeps its 32px visible buttons with 44px invisible `::after` touch targets. Add reaction opens Slack's emoji picker (see _Emoji_), and the picker takes one emoji typed or pasted, kept exactly as written. Reactions sit under the message as Slack's pills (`c-reaction`): 24px tall with round ends, 6px inside, the emoji and its count 2px apart, on the inset fill with a hairline ring in the edge colour; your own is in the accent's wash and ring with its count in bold, and a pointer on the row finds Slack's add-reaction pill (`c-reaction_add`, 34px wide) after them, a shortcut to the toolbar's _Add reaction_ that a keyboard and a screen reader reach there instead. Who reacted is in the tooltip and name; pressing a pill toggles yours. They are stored per message and per person, live for everyone in the room, and shown at once as pending (dimmed) when only this browser has the message or the connection is down, then sent when it can be. The server takes one extended grapheme cluster of at most 64 bytes as an emoji and 20 different reactions from a person to a message; one it turns down is not shown, and the message says _Couldn't react_ and why, in a polite status line that goes at the person's next reaction. - **Markdown text:** Read-only Markdown (Chat messages, a decision card's text and the phrases in it) is drawn by `MarkdownText`, copied from [assistant-ui](https://www.assistant-ui.com/elements/markdown-text)'s registry (`@assistant-ui/markdown-text`, with `tooltip-icon-button`, `use-copy-to-clipboard` and shadcn's `tooltip`) and recorded in `src/components/sources.json`. Because `markdown-text` has no standalone registry entry and Chat has no assistant-ui runtime, a recorded patch (`markdown-text.tsx.patch`) forwards props, sets `data-slot="markdown-text"`, and exposes `CodeHeader`'s button slot and `defaultComponents`, while `tsconfig.json` and `vite.config.ts` map `@assistant-ui/react` and `@assistant-ui/react-markdown` to Chopin's props-driven `react-markdown` seam (`src/chat/assistant-ui/markdown-primitive.tsx`). Its elements keep upstream's `aui-md-*` Tailwind classes, sizes, borders and radii on the generated geometry layer and take only Chopin's colours through `src/assistant-ui.css`; a decision card opts out of that root slot so its prose and diagram controls keep the document's fluid scale and focus ring. A surface passes only what differs: Chat draws a heading as a line of text, and a link to another site opens in a new tab while one into the document or another of Chopin's documents is followed in place, as the document's own are; a card reads no tables, strikethrough or autolinks except in an option's own visual, draws a Mermaid fence with the document's renderer through `componentsByLanguage`, and loads only the document's own images. Chat's messages also read GitHub's tables, strikethrough (two tildes) and task lists, whose boxes are named _Task_; footnotes and bare addresses stay plain text there, as they always were. A fenced block in Chat is Slack's code block in Chopin's colours, restyled from outside the copy by Chat's own unlayered rules (`src/chat/markdown.css`) as Chat's images are: one box the full width of the message's text, a hairline in the edge colour, the large radius and the inset ground, its one slim header row (upstream's `CodeHeader`, 4px above and beside its 24px `TooltipIconButton`, no band or rule under it) holding the language at the start in the extra-small role and tertiary ink and the copy control (_Copy code_, then for three seconds _Copied_ with a check, or _Couldn't copy_ with a warning glyph in the danger icon role, which a touch screen shows without a tooltip) at the end, then the code at Chopin's code text role (the mono stack, the small text role on 1.5, tabs of two), padded 12px, whose long lines scroll inside the block in a lane the keyboard reaches, on a phone too. A block whose fence names a language is coloured with the document's own highlighter (`@chopin/editor/code-tokens`, Shiki through `@pierre/diffs`, loaded with the first such block), each token in one of Chopin's roles at 4.5:1 or more on the inset ground: keywords in petrol ink, functions and types in the merged plum, strings in the success ink, numbers and constants in the warning ink, comments and punctuation in the tertiary ink, a diff's added and removed lines in the success and destructive inks; a block that names none, or names plain text, stays as written, and a block that streams in colours only the lines it gains. A card's code has none of this. A table scrolls inside its own lane. Raw HTML never reaches the page as markup: Chat drops it and a card shows it as the text it was written as. The collaborative editor is not drawn this way. A document shown read-only (an earlier version) stays in the editor's own read-only view, because it carries decisions, diagrams, comments and tables the dialect defines and MarkdownText does not. - **Touch targets:** A control keeps its visible box on a touch screen. Where its box is under 44px (`--control-target-coarse`) an invisible `::after` of that size grows around it, so a finger just outside the control still reaches it, and the control never gets bigger, taller or spaced differently to make room for one. This applies only when the primary pointer is coarse (`(pointer: coarse)`, mirrored as `data-plan-coarse-pointer`): a touch-screen laptop used with a mouse keeps the desktop's sizes. Stacked menu rows keep their own 28px, and a row that follows another stands 44px from it (one margin, which is the height the rows no longer take themselves) so the menu is as tall as before and neighbouring targets meet without overlapping. A control that clips its own overflow cannot grow a target past its edge. Copied assistant-ui elements follow the same rule through the generated layer. diff --git a/apps/web/src/chat/chat.tsx b/apps/web/src/chat/chat.tsx index d050a7a5b..b643e588a 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -1583,7 +1583,7 @@ export function Chat( {transcriptView} {(agent && !!runs?.length || build) && ( -
      +
      {agent && !!view.runs?.length && ( -
      +
      { + test.setTimeout(90_000); + await seed("# Many runs\n\nA long conversation.\n", { + transcript: messages(20), + workflowRuns: ["a", "b", "c", "d", "e"].map(id => workflow(id, "finished", 6)), + }); + for ( + let [options, phone] of [ + [{ viewport: { width: 1280, height: 600 } }, false], + [{ hasTouch: true, isMobile: true, viewport: { width: 390, height: 664 } }, true], + ] as const + ) { + let page = await join(phone ? "bo" : "ana", options); + await openChat(page, phone); + let strip = chatPane(page).getByRole("region", { name: "Workflow runs" }); + await strip.getByRole("button", { name: "+2 more" }).click(); + for (let id of ["a", "b", "c", "d", "e"]) { + await strip.getByRole("button", { name: new RegExp(`^workflow-${id},`) }).click(); + } + let shape = await page.evaluate(() => { + let main = document.querySelector("[data-chat-runs]")!.parentElement!; + let runs = document.querySelector("[data-chat-runs]")!; + let scroll = document.querySelector("[data-chat-scroll]")!; + let composer = main.querySelector(".chat-composer")!; + let box = (element: HTMLElement) => element.getBoundingClientRect(); + return { + main: box(main).toJSON(), + runs: box(runs).toJSON(), + scroll: box(scroll).toJSON(), + composer: box(composer).toJSON(), + runsScrolls: runs.scrollHeight > runs.clientHeight, + window: innerHeight, + }; + }); + expect(shape.runs.height).toBeLessThanOrEqual(shape.main.height / 2 + 1); + expect(shape.runsScrolls).toBe(true); + expect(shape.scroll.top).toBeGreaterThanOrEqual(shape.main.top - 1); + expect(shape.scroll.height).toBeGreaterThan(48); + expect(shape.runs.bottom).toBeLessThanOrEqual(shape.composer.top + 0.5); + expect(shape.composer.bottom).toBeLessThanOrEqual(shape.main.bottom + 0.5); + expect(shape.composer.bottom).toBeLessThanOrEqual(shape.window + 0.5); + // The last run's stages are reached by scrolling the strip itself. + let last = strip.getByText("stage-6").last(); + await last.scrollIntoViewIfNeeded(); + await expect(last).toBeInViewport(); + await clearOfStrip(page); + } +});