From 7cba657221642817aa7ace997732fde2392fb507 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 12 Nov 2026 16:15:15 -0800 Subject: [PATCH 1/2] Draw nothing more of a turn once its answer is out, and offer Stop only for work people can watch After chopin finished an answer, a "Getting oriented" row appeared under it for a few seconds with Stop still showing, then went away. That row was a background job's turn: keeping the document's title, which now runs right after a reply. Its turn reached Chat like a reply's, with no message of its own, so Chat drew its empty working row. A reply's own turn also stayed busy after its last word while its after-answer check ran, which kept Stop up. A turn now says when nothing more of it is for people to watch (`Turn.quiet`, optional, so earlier clients ignore it). The server sends a background job's turn quiet from its start, and marks a reply's turn quiet once its words are all out, while the check runs. A repair step the check starts clears it, since that is work again, shown in place on the answer. Chat and threads draw a quiet turn as the turn that ended: no working row and no "Started at", its tools kept in the summary. Stop is offered for a quiet turn only once a message was refused because of it, so the person can still stop a job to ask now. The workspace's Chat tab says chopin is working only for a turn people watch, while the document's activity and its decisions' write-up still follow every busy turn, since a job is what writes a decision's prose. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline (subagent) Assistant-verification: bun test apps/server/src/chat apps/web/src/chat apps/server/src/conversation-plan (2,897 pass after the invoke fix) Assistant-verification: new unit tests fail on 8634eefaf (reply-policy quiet, job quiet, invoke offsets, transcript-state) Assistant-verification: e2e chat-after-answer: 4/4 pass here, 4/4 fail on 8634eefaf ("Getting oriented", Stop, "Chat, chopin working") Assistant-verification: e2e chat-after-answer, responsive-activity, smoke, chat-phone, composer-send-stop: 50 passed after the Chat tab change Assistant-verification: e2e chat-after-answer, in-document-links, chat-markdown, chat-threads, chat-thread-replies, harness, composer-send-stop, steering, conversation-initiative, smoke, reaction-reader, responsive-activity: 111 passed Assistant-verification: bun run types; bun run ci User-preference: Things should be polished like the Slack UX Co-authored-by: Alex Lavaee --- apps/server/src/chat/invoke.test.ts | 8 +- apps/server/src/chat/job-lifecycle.test.ts | 19 ++ apps/server/src/chat/reply-policy.test.ts | 39 ++++ apps/server/src/chat/reply-policy.ts | 7 + apps/server/src/chat/service.ts | 18 +- apps/web/DESIGN.md | 2 +- apps/web/src/chat/chat.tsx | 52 ++++- apps/web/src/chat/threads/thread-panel.tsx | 18 +- apps/web/src/chat/transcript-state.test.ts | 65 +++++++ apps/web/src/chat/transcript-state.ts | 29 ++- apps/web/src/room-workspace.tsx | 18 +- docs/hosted-agent.md | 3 +- e2e/chat-after-answer.e2e.ts | 214 +++++++++++++++++++++ packages/protocol/chat.d.ts | 7 + 14 files changed, 470 insertions(+), 29 deletions(-) create mode 100644 e2e/chat-after-answer.e2e.ts diff --git a/apps/server/src/chat/invoke.test.ts b/apps/server/src/chat/invoke.test.ts index e7e43fb77..93e0b3364 100644 --- a/apps/server/src/chat/invoke.test.ts +++ b/apps/server/src/chat/invoke.test.ts @@ -639,10 +639,12 @@ test("the atomic harness verifies a checkout before posting and every later sess await browserTurn(context, "@chopin Continue"); expect(await Chat.invoke(context, user, "Again", other)).toBe("checkout-unverified"); await browserTurn(context, "@chopin Once more"); - let offsets = events.filter(event => event.kind === "chat:state" && event.busy) - .map(event => (event.turn as Wire.Turn).entryOffset); + let busy = events.filter(event => event.kind === "chat:state" && event.busy) + .map(event => event.turn as Wire.Turn); // Invoked instructions post first; browser turns reserve their prompt before it is appended. - expect(offsets).toEqual([1, 2, 3]); + expect(busy.filter(turn => !turn.quiet).map(turn => turn.entryOffset)).toEqual([1, 2, 3]); + // Each turn goes quiet once its words are all out, the same turn at the same offset. + expect(busy.filter(turn => turn.quiet).map(turn => turn.entryOffset)).toEqual([1, 2, 3]); expect(seen).toHaveLength(3); for (let turn of seen) { expect(turn.full?.cwd).toBe(matching); diff --git a/apps/server/src/chat/job-lifecycle.test.ts b/apps/server/src/chat/job-lifecycle.test.ts index edca22650..c440ae0bd 100644 --- a/apps/server/src/chat/job-lifecycle.test.ts +++ b/apps/server/src/chat/job-lifecycle.test.ts @@ -74,3 +74,22 @@ test("a final persist failure settles the job and releases the queue", async () expect(state.chat.busy).toBe(false); expect(state.chat.waiting).toEqual([]); }); + +test("a background job's turn is quiet, so Chat draws nothing for it and offers no Stop", async () => { + let h = harness(); + let running = Chat.job(h.context, JOB, "prompt", "session"); + await until(() => h.prompts.length === 1); + let states = h.frames.filter(frame => frame.kind === "chat:state") as Array< + { busy: boolean; turn?: { quiet?: boolean } } + >; + expect(states.at(-1)?.busy).toBe(true); + expect(states.at(-1)?.turn?.quiet).toBe(true); + await runJobTool(h.chat, "refine_decision", async () => ({ output: {} })); + h.emit("session.idle"); + expect(await running).toEqual({ status: "done", output: "{}" }); + let ended = h.frames.filter(frame => frame.kind === "chat:state").at(-1) as + | { busy: boolean; turn?: unknown } + | undefined; + expect(ended).toMatchObject({ busy: false }); + expect(ended?.turn).toBeUndefined(); +}); diff --git a/apps/server/src/chat/reply-policy.test.ts b/apps/server/src/chat/reply-policy.test.ts index c3d265360..e978f0b1d 100644 --- a/apps/server/src/chat/reply-policy.test.ts +++ b/apps/server/src/chat/reply-policy.test.ts @@ -45,6 +45,7 @@ function fixture(turns: Part[][], decide: Decide | undefined = verdict(0)) { capturedDecisions: undefined as (() => number) | undefined, keptDecisions: undefined as (() => number) | undefined, repair: undefined as boolean | undefined, + quiet: undefined as ((quiet: boolean) => void) | undefined, publish: (part: Part) => published.push(part), }; return { options, published, prompts, controller, run: () => publishPlannerReplies(options) }; @@ -121,6 +122,44 @@ test("a finished message is checked while what follows it streams, and the check expect(f.prompts).toHaveLength(1); }); +test("once the reply's words are all out, the turn is quiet while its check runs", async () => { + let checking = Promise.withResolvers(); + let checked = Promise.withResolvers(); + let f = fixture([text("I updated the document.")], async (request, options) => { + checked.resolve(); + await checking.promise; + return verdict(0)(request, options); + }); + let quiet: boolean[] = []; + let published = 0; + f.options.quiet = value => { + quiet.push(value); + published = f.published.length; + }; + let running = f.run(); + await checked.promise; + await Bun.sleep(0); + // Quiet only after the last of the words, while the check still runs. + expect(quiet).toEqual([true]); + expect(published).toBe(text("I updated the document.").length); + checking.resolve(); + await running; + expect(quiet).toEqual([true]); +}); + +test("a repair step the check starts is work again, and its end is quiet", async () => { + let f = fixture( + [text("Which scope?"), text("I put the scope question beside the goal.")], + async (request, options) => verdict(said(f).length === 1 ? 1 : 0)(request, options), + ); + f.options.capturedDecisions = () => 1; + let quiet: Array<[boolean, number]> = []; + f.options.quiet = value => quiet.push([value, f.prompts.length]); + await f.run(); + // Quiet after the first reply, work again before the repair's prompt, quiet after it. + expect(quiet).toEqual([[true, 1], [false, 1], [true, 2]]); +}); + test("tool activity passes through while a decision tool is waiting", async () => { let tool: Part = { type: "tool-input-start", id: "ask", toolName: "ask" }; let f = fixture([[tool]]); diff --git a/apps/server/src/chat/reply-policy.ts b/apps/server/src/chat/reply-policy.ts index 642888da4..31101fcfd 100644 --- a/apps/server/src/chat/reply-policy.ts +++ b/apps/server/src/chat/reply-policy.ts @@ -120,6 +120,11 @@ export async function publishPlannerReplies(options: { /** Count decisions that already existed and that the Planner repeated this turn. */ keptDecisions?: () => number; publish: (part: Part) => void; + /** + * Told `true` once an attempt's words are all out, so what is left of the turn (the check) is not + * shown as work, and `false` as a repair step begins, which is. + */ + quiet?: (quiet: boolean) => void; beginAttempt?: () => void; }): Promise { let { session, decide, publish } = options; @@ -132,6 +137,7 @@ export async function publishPlannerReplies(options: { let toolsTruncated = false; for (let attempt = 0; attempt < 2; attempt++) { if (signal.aborted) return; + if (attempt > 0) options.quiet?.(false); let asked = false; let messages = new Map(); let checks: Promise[] = []; @@ -206,6 +212,7 @@ export async function publishPlannerReplies(options: { if (failure) throw failure; if (signal.aborted) return; for (let id of messages.keys()) check(id); + options.quiet?.(true); await Promise.all(checks); if (signal.aborted || !asked) return; if (options.repair === false) { diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index b87e1a86c..c726abf09 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -525,7 +525,7 @@ function now(): number { return Math.floor(Date.now() / 1000); } -function begin(handle: string, entryOffset: number): Wire.Turn { +function begin(handle: string, entryOffset: number, quiet = false): Wire.Turn { let startedAt = Date.now(); return { id: ulid(), @@ -534,9 +534,21 @@ function begin(handle: string, entryOffset: number): Wire.Turn { entryOffset, startedAt, responded: false, + ...(quiet ? { quiet: true } : {}), }; } +/** + * Says whether what is left of the running turn is for people to watch. A reply's words are all + * out while its after-answer check runs; a repair step the check starts is work again. + */ +function quieten(chat: Chat, server: Server, room: string, quiet: boolean): void { + if (!chat.turn || !!chat.turn.quiet === quiet) return; + if (quiet) chat.turn.quiet = true; + else delete chat.turn.quiet; + state(chat, server, room); +} + function digest(value: string): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; } @@ -2980,7 +2992,8 @@ async function run( if (!reserved) { chat.busy = true; - chat.turn = begin(handle, chat.entries.length); + // A background job's turn is neither saved nor shown: Chat draws nothing for it. + chat.turn = begin(handle, chat.entries.length, !!jobTurn); chat.acting = thread; chat.actingChannel = about?.threadChannel; (jobTurn ? jobState : state)(chat, server, room); @@ -3189,6 +3202,7 @@ async function run( && record.origin === "planner" && !!record.followUp && holdsQuestion(record.status) ).length, publish: part => translate(context, part), + quiet: quiet => quieten(chat, server, room, quiet), beginAttempt: () => { chat.toolEntries = new Map(); if (chat.turnImages) chat.turnImages.calls = new Set(); diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 7a10e9803..ea2d87980 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -201,7 +201,7 @@ Rectangular controls use a restrained four-step radius scale: small for grips an - **Menus and popovers:** Every menu follows shadcn's Nova menu. The panel has 4px padding, the large radius, and a hairline ring with the raised shadow. Items are 28px: 4px by 6px padding, a 6px icon gap, the medium radius and the small text role. Group labels use the extra-small text role in medium weight and tertiary ink with the item's inset. Dividers are 1px, run through the panel's padding to both edges and keep 4px above and below. A selected item is tinted with the selected surface, never marked with a check. A setting that turns on and off (the document's _Full width_) is a checkbox row with a small switch at its end, petrol when on and the control edge when off; the row is not tinted, and choosing it leaves the menu open so the change is seen behind it. Secondary detail sits right-aligned in tertiary ink on the same line. - **Composer menus and assistant-ui:** The `@` people and the `/` commands are assistant-ui's Composer menu, copied unmodified into `src/components` (`ComposerMenu`, its Mentions row `ComposerPersonItem` and its Slash commands row `ComposerCommandItem`): a 288px floating panel with 16px corners and 6px padding, rows of a round badge, a name and a tiny mono tag. A copy keeps its own classes, sizes and radii (one generated layer gives its roots Tailwind's scale, never Chopin's control, pill or menu tokens) and takes only Chopin's colours, through the shadcn colour variables in `src/assistant-ui.css`; [Adding an assistant-ui element](../../docs/assistant-ui.md) says how, and a copy is never restyled here. A recorded patch adds an extension point and changes no class or size: a person's own picture in the Mentions badge, with a tag that can be any word. Chopin's own state drives the menus through props (`mentions.ts`, `commands.ts`), not the element's `useMentionMatches` and `applyMention`, because the draft keeps its caret and its references. The `#` menu of the document's parts and the other documents is still Chopin's own, in the shared menu recipe, until its element is copied. `THIRD_PARTY_NOTICES.md` names the source, its revision and the MIT notice, and nothing of assistant-ui's runtime is used. - **Commands and mentions:** Typing `/` as the whole draft opens assistant-ui's Slash commands list over the composer: Chat's own command (_/research_) and the commands the document's agent reports (a skill, a prompt template, a workflow, any other kind the agent named), in the order reported, one row each: the icon of its kind, `/name`, a line of description that gives way to a narrow screen, and `↵` on the row Enter runs. It filters as you type, by the name or by what follows a skill's prefix, and says nothing when nothing is left. Enter or Tab chooses; an agent's command becomes one chip that opens the draft, and the words typed after it are what it is run with. `@` is for people, as in Slack: assistant-ui's Mentions list offers `chopin` first, tagged `agent`, with its mark in a circle, then the people who can read the document, tagged `human`, each with their GitHub picture in the badge, a rounded square as a person is everywhere else: those here first, then the others the server lists (the repository's collaborators who can read it, or, where GitHub will not list them, the document's creator and authors), then those who have spoken in Chat, filtered by the login typed, and nothing else. `#` and the Mention docs button (which puts a `#` at the caret) are for the document: its sections (tagged with their level), Decisions, research cards and its repository's files, each with an icon and a tag that says what it is, above the other documents, filtered by the words typed, which may go on past a space or a slash; once they do, the menu shows only while something matches, so a `#12` in a sentence holds no empty menu open. The document's parts arrive a moment after the other documents and a stale answer is never offered. No heading divides either list, since each row's tag says what it is. In all three menus Enter or Tab chooses the highlighted row and the draft keeps the focus; Shift+Tab, and Tab with no menu open, move the focus on. Choosing chopin or a person writes `@login`, drawn as a mention, and every `@login` that names chopin or someone who can read the document is drawn as one wherever it is, in the draft as it is typed and in the sent message alike (`message-mentions.ts`), while one that names no one stays words; choosing a part or a document is a chip, the same chip and the same reference whichever way it was reached. A chip is the one treatment for every reference the draft can hold except a document's link: the transcript's tint (`--color-brand-wash`, brand ink, the small radius, medium weight, no underline), whole in every edit (deleting at its edge or inside it takes all of it, a selection that reaches into one takes the rest, a caret placed inside one moves to its end before typing), and drawn the same, as a chip and never a link, once the message is sent. Both lists are the shared composer menu with its tinted active row, their region scrolls and is reachable by keyboard, and on a phone they meet both edges of the composer like the model picker. Neither says anything of the machine: a Planner that reports no commands simply offers Chat's own. -- **Composer box and send and stop:** The composer is a message box in the shape of Slack's: a rounded rectangle (the large radius, a 1px control boundary that darkens to tertiary ink on focus and turns destructive when a send fails, the page's surface) holding, top to bottom, a formatting toolbar, Chopin's own field and a row of controls. Every size is Chopin's control size (28px ghost buttons with Slack's 18px glyphs, a 44px target that grows only on touch) and every colour a token. The toolbar sits on the inset ground under a hairline: bold, italic and strikethrough, a link, a numbered and a bulleted list, code and a code block, each formatting the selection in place, as Slack's does, and shown pressed while the caret is in its format, none taking the draft's focus or selection; each names its key in its tooltip. The draft renders what it holds as it will read (bold, italic and struck words, links, lists, inline code on the inset ground and code blocks in a hairline box), never the marks: it is Lexical, the editor the documents use, loaded beside the page's code rather than in it. Slack's keys format too (⌘ or Ctrl with B, I, Shift+X, Shift+C, Shift+7, Shift+8 and Alt+Shift+C; ⌘K links chosen words and is search otherwise; ⌘U is refused, Markdown having no underline), and so do Slack's typed marks as they close (`*bold*`, `_italic_`, `~strike~`, `` `code` ``, and `-`, `1.` or ``` at the start of a line). A link's address is asked in one row over the box (_Link_, Save, and Remove for a link already there), with `https://` put before a bare host. Enter sends; Shift+Enter breaks the line, makes the next item in a list (and on an empty item leaves it) and the next line in a code block (and on an empty last line leaves it). A rich paste keeps its formatting and a plain one is words. What is sent is the Markdown the formatting means (Chat messages are Markdown), the same Markdown a person typing its marks would send, and a draft read back from Markdown (a reload, a refused send) is drawn the same way; Markdown the draft would write differently (a quote, a table, `*` emphasis) stays as the words it was written in. It gives way on a phone and where the pane is narrow, and the Aa button in the row turns it off and on; the choice is kept for each person in their browser. The row has `+` (attach, where the server takes files), Mention docs, Aa, an @ that starts a mention at the caret, the sparkle button that asks Chopin, the microphone (where speech can be turned into words) and the model pill on the left, and Resume and the send control on the right. The send control is one primary rounded-square button with a paper plane, driven by Chopin's state (`send-stop.ts`): while the Planner is idle it is Send, disabled while the draft is empty; while the Planner runs with an empty draft it is Stop (a square) and stops it, under the permission rules Stop has always had, so someone who may not stop it sees a disabled Send, never a Stop they cannot press; while it runs and the draft has words it is Send, which posts the message exactly as Enter does (a message sent meanwhile is posted at once, the composer says nothing of waiting, and one that mentions chopin steers the turn that is running), and it is Stop again once the draft is sent or cleared. The accessible name and tooltip always say what the icon shows (_Send message_, _Stop generating_), and one polite live region says once when it changes, never for the tokens of a reply. There is no chevron beside it, because Chopin has no send options to put there. The emoji button has no state to draw yet and is not drawn, and there is no context-window ring, in this composer or a thread's, even where a harness reports token usage; where no files can be taken or no speech recognized, neither `+` nor the microphone is. Resume is its own ghost button beside Send. +- **Composer box and send and stop:** The composer is a message box in the shape of Slack's: a rounded rectangle (the large radius, a 1px control boundary that darkens to tertiary ink on focus and turns destructive when a send fails, the page's surface) holding, top to bottom, a formatting toolbar, Chopin's own field and a row of controls. Every size is Chopin's control size (28px ghost buttons with Slack's 18px glyphs, a 44px target that grows only on touch) and every colour a token. The toolbar sits on the inset ground under a hairline: bold, italic and strikethrough, a link, a numbered and a bulleted list, code and a code block, each formatting the selection in place, as Slack's does, and shown pressed while the caret is in its format, none taking the draft's focus or selection; each names its key in its tooltip. The draft renders what it holds as it will read (bold, italic and struck words, links, lists, inline code on the inset ground and code blocks in a hairline box), never the marks: it is Lexical, the editor the documents use, loaded beside the page's code rather than in it. Slack's keys format too (⌘ or Ctrl with B, I, Shift+X, Shift+C, Shift+7, Shift+8 and Alt+Shift+C; ⌘K links chosen words and is search otherwise; ⌘U is refused, Markdown having no underline), and so do Slack's typed marks as they close (`*bold*`, `_italic_`, `~strike~`, `` `code` ``, and `-`, `1.` or ``` at the start of a line). A link's address is asked in one row over the box (_Link_, Save, and Remove for a link already there), with `https://` put before a bare host. Enter sends; Shift+Enter breaks the line, makes the next item in a list (and on an empty item leaves it) and the next line in a code block (and on an empty last line leaves it). A rich paste keeps its formatting and a plain one is words. What is sent is the Markdown the formatting means (Chat messages are Markdown), the same Markdown a person typing its marks would send, and a draft read back from Markdown (a reload, a refused send) is drawn the same way; Markdown the draft would write differently (a quote, a table, `*` emphasis) stays as the words it was written in. It gives way on a phone and where the pane is narrow, and the Aa button in the row turns it off and on; the choice is kept for each person in their browser. The row has `+` (attach, where the server takes files), Mention docs, Aa, an @ that starts a mention at the caret, the sparkle button that asks Chopin, the microphone (where speech can be turned into words) and the model pill on the left, and Resume and the send control on the right. The send control is one primary rounded-square button with a paper plane, driven by Chopin's state (`send-stop.ts`): while the Planner is idle it is Send, disabled while the draft is empty; while the Planner runs with an empty draft it is Stop (a square) and stops it (a turn the server marks quiet is not running for this: a background job such as keeping the title, or a reply whose words are all out while its check runs, draws no working row and offers Stop only after a message was refused because of it), under the permission rules Stop has always had, so someone who may not stop it sees a disabled Send, never a Stop they cannot press; while it runs and the draft has words it is Send, which posts the message exactly as Enter does (a message sent meanwhile is posted at once, the composer says nothing of waiting, and one that mentions chopin steers the turn that is running), and it is Stop again once the draft is sent or cleared. The accessible name and tooltip always say what the icon shows (_Send message_, _Stop generating_), and one polite live region says once when it changes, never for the tokens of a reply. There is no chevron beside it, because Chopin has no send options to put there. The emoji button has no state to draw yet and is not drawn, and there is no context-window ring, in this composer or a thread's, even where a harness reports token usage; where no files can be taken or no speech recognized, neither `+` nor the microphone is. Resume is its own ghost button beside Send. - **Asking chopin:** Who a message goes to is in its words, as in Slack: a message that mentions `@chopin` (in any case, anywhere outside a reference) asks chopin, and one without goes to the team, except for a person alone in the document (below). The composer draws no other state for it: no chip, no mode, and no placeholder but the two below. The sparkle button in the row (_Ask chopin_, its tooltip naming the chord) and Command+J or Control+J (listed in the shortcuts sheet, from the one registry of chords) write `@chopin` and a space into the draft, the same mention the `@` menu writes and the words a person could type: at the caret when the field has it (the button keeps the field's focus and caret), otherwise at the start, set apart by a space on each side, with the caret after it. A draft that already asks chopin gets no second mention; the button only puts the caret after the first one (or at the end, for a command). The button is never spent and has no pressed state, since the mention in the words already says who the message is for. The mention is words: Backspace takes it out like any others, and nothing stays on after a message is sent. A draft kept from before, whose prepared send asked chopin without naming it, comes back with the mention it went with. The empty draft says _Message the team, or @chopin to ask_. A person alone in the document is talking to chopin, as in a direct message with a Slack app, and the server takes their every message as one to chopin, so their empty draft says _Message chopin_ instead, and says the team's words again the moment a second person opens the document (it is the server's count, the same one that routes the message). Where the document has no Planner there is no button, and the chord does nothing. - **Model and reasoning control:** In the composer's toolbar, left of the other controls, assistant-ui's own model pill (`ComposerModelTrigger`, a 32px ghost pill with a chevron and no icon, copied unmodified) names the model that will run and, in tertiary ink, the reasoning level it will run at: the ones chosen for the document, or the agent's own model and level where nobody chose (_Choose a model_ only where the agent names no model of its own). No word of it says _Default_. It opens assistant-ui's Model selector, also copied, over the composer: a search field, the models grouped by provider with a one-line description under each name as the agent reports it, a check at the one that will run, and, only for a model that has levels, the _Thinking_ row: the levels exactly as the harness reports them, `xhigh` as `xhigh` and `High` beside `high` as two (a level the agent calls `Default` is one of them), the one that will run checked, the reported string being what a turn carries; the interface-copy rule's sentence case applies to what Chopin writes, not to a value a harness reports. A pick keeps both halves for the document: choosing a model keeps the level that runs now where the model offers it (else the agent's own level where it does), and choosing a level keeps the model that runs now, so neither follows the agent's own default when it moves. The model under the pointer, or the keyboard's active model (in search results too), lies on one shade darker than the popover (`--color-selected`, as an active row of Chopin's menus), and every other row on the popover; a level under the pointer takes the same shade, and the chosen level the petrol wash inside a petrol edge, as the emoji picker's chosen skin tone, so a level only pointed at never reads as chosen. A level is a setting, so choosing one leaves the popover open, while choosing a model closes it and returns focus to the pill; Escape, Tab out or a pointer elsewhere closes it, and the keys are the element's own. The popover is never taller than the screen has room for beside the pill: the model list gives up its height and scrolls, so the search field and the _Thinking_ row stay on screen. Levels wider than the row are one lane that scrolls sideways under a wheel, a finger or the arrow keys, with a fade on the side that still hides levels, as a wide table's. The list and the lane each draw a quiet scrollbar whenever they overflow, without a pointer over them, so a person sees there is more: Chat's thumb (6px, round, the strongest scrollbar ink, 3:1 or more on the popover), sized and placed by how much is in view, darkening under the pointer and draggable by a mouse, and none where everything fits; the chosen level is in view whenever the popover opens or the choice changes, sliding there unless motion is reduced. The pill gives way to an ellipsis rather than push the other controls, on a phone as on a desktop, and the level stays in its accessible name and the open list. Nothing is drawn where the document's harness offers no choice or the person cannot write. Where a client withdrew its models, or an older one took its place, the picker is gone but a choice somebody saved has its words in the composer's notice until Chat records the fallback; while no client is attached, or one that offered its models has yet to report them, the notice says nothing of it until a turn has really fallen back, and a document nobody chose a model for never has one. A choice the harness no longer offers says what runs instead, in plain words with none of Chopin's own terms, in the trigger's name, in the popover and, before a turn records it in Chat, in the composer's notice. A turn that could not read the choice at all, the catalog or the stored choice, never runs the default silently: Chat says once, and every composer's notice says, _This document's model choice couldn't be read, so chopin used its default model._, until a turn can read it again. These words are the composer's own notice, not a banner: a cause of their own, drawn beneath the connection banner when the machine's banner is drawn (the machine went after the turn), and giving way, as the composer's other notices do, to the room's own connection banner, a refused send and a failed reply, and back with the connection. - **Files and dictation in the composer:** Images and text files and speech are the composer's own state, in hooks (`useFiles`, `useDictation`) that the surfaces take by props, so nothing that draws a chip or the microphone keeps state of its own. They fill the box's slots from assistant-ui's own elements, copied and recorded: the `+` at the start of the row (_Add files_, one of the 28px ghost buttons, with a hidden picker) attaches; the files wait above the text, under the toolbar, as assistant-ui's staged chips (`ComposerAttachments` and `ComposerAttachmentChip` of its Composer) drawn the way Slack's composer draws them, from outside (`chat/media/media.css`), 8px apart on the page's surface with a 1px hairline and 12px corners: an image is a 64px square of its picture, with Slack's ring of how far it has uploaded over it (the petrol arc on a hairline circle) and its name and percent kept for a screen reader; any other file is a card 64px tall, as tall as an image's square, of its type's 40px coloured icon (see _Chat files_), its name in bold and its size, or the percent with a 2px bar along its foot while it uploads; each has its remove button, a 20px ink circle, on its top-end corner, shown on hover or focus and always on a touch screen; a file that cannot go keeps the card's layout, whatever it is, so its reason has room. An image too large to send whole (over `CHAT_ATTACHMENT_MAX_BYTES`, or more than 40 million pixels) is made smaller in the browser before it uploads, at most 4096 pixels on its longest side, then narrower until it fits (a JPEG stays a JPEG and anything else becomes a WebP), so it is never refused for its size, and the viewer says so; the microphone is assistant-ui's own round voice button (`ComposerVoiceButton`, 32px, ink and a square while it listens) in the row after the sparkle, and while it listens the element's voice (`ComposerVoice`: the pulsing dot, the waveform, the time so far, _Transcribing_ while a recording is worked on) sits above the text where the files do; a sent message draws any image attachments through Chat's image elements (see _Chat images_) and its other files as _Chat files_. A paste into the field and a drop anywhere on the box add files too, the box's edge taking the accent and saying _Drop files to add them_ while a drag is over it; where the server takes none, there is no `+`, and the composer is as it was. Three things the chips have no place for are recorded extension points: the id of the file (two pasted screenshots are both _image.png_), the picture of an image in the box of the icon, and what sits beside the status, which is Chopin's own icon buttons to try an upload again and to remove a file that did not go. The chip is the one place a reason is said: a file the browser or the server refuses (a type, a size, a count or a total) is the chip in its error state, its reason in the danger text, with no way to try again and the send held back until it is removed, and a screen reader hears it once; a file the document's model cannot read is shown the same way while the message is for Chopin and is fine again when it is for the team, and `+` says so in its own name and description and offers text in its picker. Images go only to a model that reads them. Mention docs, which `+` used to be, is the document glyph beside it. A message sent before its files have uploaded says _Sending once your files have uploaded_ and goes by itself; if one fails it stays in the composer with its text; a message of only files is sent by the round button, which stops Chopin only when there is nothing to send. Dictation exists only where the browser recognizes speech on the device or the deployment set up transcription, and never takes the field's focus; what is being said is drawn in the tertiary ink beside the caret and is never part of the draft, and what is final is written at the caret as typing would. Where motion is reduced the dot and the shimmer hold still. Why it stopped is the composer's own strip, an alert with a Dismiss, and the polite live region says only that it started and stopped. Words about either use none of Chopin's own terms, and `composer.test.tsx` keeps it so. diff --git a/apps/web/src/chat/chat.tsx b/apps/web/src/chat/chat.tsx index b643e588a..071cb71f9 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -136,7 +136,9 @@ import { Transcript } from "./transcript"; let LazyThreadPanel = lazy(() => import("./threads/thread-panel")); import { initialTranscript, + offersStop, progressOf, + shownWork, silentRestart, transcriptReducer, } from "./transcript-state"; @@ -183,8 +185,14 @@ export type ChatProps = { /** The room's connection is lost, catching up or just back: the offline bar's to tell. */ offline?: boolean; active?: boolean; - /** `history` marks the transcript arriving, after which `busy` is known rather than assumed. */ - onActivity?: (event: { type: "message" | "working"; busy: boolean; history?: true }) => void; + /** + * `history` marks the transcript arriving, after which `busy` is known rather than assumed. + * `working` is whether that turn is one people watch: a quiet turn (`Chat.Turn.quiet`) is busy + * and not working. + */ + onActivity?: ( + event: { type: "message" | "working"; busy: boolean; working: boolean; history?: true }, + ) => void; conversationPlan?: ConversationPlan.State; conversationPlanJobs?: ConversationPlan.Job[]; onCardLink?: (link: CardLink) => void; @@ -504,6 +512,7 @@ export function Chat( let synchronized = useRef(undefined); let activity = useRef(onActivity); let reportedBusy = useRef(false); + let reportedWorking = useRef(false); activity.current = onActivity; // A socket opens before its fresh transcript arrives, and reconnects reuse // the same Wire. Only that transcript makes this composer current. @@ -638,10 +647,14 @@ export function Chat( !!draft.text.trim() || files.files.length > 0 || outbox.some(item => !item.acknowledged), ); let quiet = quietRestart.current; - let { working: workingTurn, suspended: suspendedWork } = useMemo( + let { working: workingTurn, suspended: suspendedWork, settled: settledWork } = useMemo( () => progressOf(transcript, transcriptReady, quiet), [transcript, transcriptReady, quiet], ); + let completedWork = useMemo( + () => shownWork(transcript.completedWork, settledWork), + [transcript.completedWork, settledWork], + ); // `#` and `@` both open at the caret; the one typed nearer to it is the one being written. let hashed = referencesEnabled && composerReady && !submitting ? referenceTrigger(draft.text, selection.start, selection.end) @@ -773,16 +786,26 @@ export function Chat( dispatchIntents({ kind: "history", entries: frame.entries, handle }); setBusy(frame.busy); reportedBusy.current = frame.busy; + reportedWorking.current = frame.busy && !frame.turn?.quiet; setRuns(frame.runs); setParticipation({ room, wire, state: frame.participation }); setParticipationKey(current => current + 1); // History is not unread, but a turn already in progress still needs // a signal outside a closed Chat destination. - activity.current?.({ type: "working", busy: frame.busy, history: true }); + activity.current?.({ + type: "working", + busy: frame.busy, + working: reportedWorking.current, + history: true, + }); }), wire.on("chat:message", frame => { if (loaded && !seen.has(frame.entry.id)) { - activity.current?.({ type: "message", busy: reportedBusy.current }); + activity.current?.({ + type: "message", + busy: reportedBusy.current, + working: reportedWorking.current, + }); } seen.add(frame.entry.id); dispatchTranscript({ kind: "message", entry: frame.entry }); @@ -813,10 +836,15 @@ export function Chat( dispatchTranscript({ kind: "tool", entryId: frame.entry, activity: frame.activity }); }), wire.on("chat:state", frame => { - if (loaded && reportedBusy.current !== frame.busy) { - activity.current?.({ type: "working", busy: frame.busy }); + let working = frame.busy && !frame.turn?.quiet; + if ( + loaded + && (reportedBusy.current !== frame.busy || reportedWorking.current !== working) + ) { + activity.current?.({ type: "working", busy: frame.busy, working }); } reportedBusy.current = frame.busy; + reportedWorking.current = working; setBusy(frame.busy); dispatchTranscript({ kind: "turn", turn: frame.turn }); setRuns(frame.runs); @@ -853,7 +881,11 @@ export function Chat( loaded && frame.frame.kind === "chat:message" && frame.frame.entry.author.kind !== "system" ) { - activity.current?.({ type: "message", busy: reportedBusy.current }); + activity.current?.({ + type: "message", + busy: reportedBusy.current, + working: reportedWorking.current, + }); } dispatchThreads({ kind: "in-thread", frame }); }), @@ -1516,7 +1548,7 @@ export function Chat( reactionIntents={intents} onRetry={retryMessage} quietFailures={quietFailures} - completedWork={transcript.completedWork} + completedWork={completedWork} suspendedWork={suspendedWork} handle={handle} live={transcriptReady} @@ -1683,7 +1715,7 @@ export function Chat( || busyRefused || (connectionNotice !== "none" && !draftCommand(draft.text) && !plannerBound)} onSubmit={submit} - run={agent && (busy || counts.active > 0) + run={agent && (offersStop(busy, transcript.turn, busyRefused) || counts.active > 0) ? { action: "stop", disabled: !composerReady && connectionNotice !== "none", diff --git a/apps/web/src/chat/threads/thread-panel.tsx b/apps/web/src/chat/threads/thread-panel.tsx index 2ee926091..f6db3f2c4 100644 --- a/apps/web/src/chat/threads/thread-panel.tsx +++ b/apps/web/src/chat/threads/thread-panel.tsx @@ -46,7 +46,7 @@ import { } from "../references"; import { runCounts, RunStack } from "../run-card"; import { ThreadRoot, Transcript } from "../transcript"; -import { progressOf } from "../transcript-state"; +import { offersStop, progressOf, shownWork } from "../transcript-state"; import { ThreadComposerBoundary } from "./composer-boundary"; import { useThreadReactions } from "./thread-reactions"; import "./thread-panel.css"; @@ -405,10 +405,14 @@ export function ThreadPanel( ); let counts = runCounts(view.runs); - let { working: workingTurn, suspended: suspendedWork } = progressOf( - view.transcript, - connected && view.loaded, - false, + let live = connected && view.loaded; + let { working: workingTurn, suspended: suspendedWork, settled: settledWork } = useMemo( + () => progressOf(view.transcript, live, false), + [view.transcript, live], + ); + let completedWork = useMemo( + () => shownWork(view.transcript.completedWork, settledWork), + [view.transcript.completedWork, settledWork], ); let replyCount = Math.max( summaryCount ?? 0, @@ -535,7 +539,7 @@ export function ThreadPanel( canEdit={composerReady} compacting={view.compacting} compactions={view.compactions} - completedWork={view.transcript.completedWork} + completedWork={completedWork} decisions={decisions} entries={withoutFailure(view.transcript.entries, failureNode ? failed?.entry : undefined)} handle={handle} @@ -750,7 +754,7 @@ export function ThreadPanel( } : undefined} rootRef={composerRoot} - run={agent && (view.busy || counts.active > 0) + run={agent && (offersStop(view.busy, view.transcript.turn, false) || counts.active > 0) ? { action: "stop", disabled: !composerReady, diff --git a/apps/web/src/chat/transcript-state.test.ts b/apps/web/src/chat/transcript-state.test.ts index 828246d83..71cc46256 100644 --- a/apps/web/src/chat/transcript-state.test.ts +++ b/apps/web/src/chat/transcript-state.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from "bun:test"; import { group } from "./model"; import { initialTranscript, + offersStop, progressOf, + shownWork, silentRestart, transcriptReducer, } from "./transcript-state"; @@ -176,6 +178,69 @@ describe("how a turn in progress is shown while its connection is lost", () => { }); }); +describe("a quiet turn: nothing left of it is for people to watch", () => { + let prompt = entry("prompt", { kind: "member", handle: "ana" }); + let answer = entry("answer", { kind: "agent" }, "An iterator yields one item at a time."); + + function rows( + entries: Chat.Entry[], + quiet: Chat.Turn, + completed = initialTranscript.completedWork, + ) { + let state = transcriptReducer(initialTranscript, { kind: "history", entries, turn: quiet }); + let shown = progressOf(state, true, false); + return { + shown, + messages: group(state.entries, shown.working, shownWork(completed, shown.settled)).flatMap( + item => item.kind === "messages" ? item.messages : [], + ), + }; + } + + it("draws a background job's turn as nothing: no row, no working message", () => { + // The answer belongs to the turn that ended; the job's turn begins after it. + let { shown, messages } = rows([prompt, answer], { ...turn, entryOffset: 2, quiet: true }); + expect(shown.working).toBeUndefined(); + expect(shown.settled).toBeUndefined(); + expect(messages.map(message => message.id)).toEqual(["prompt", "answer"]); + expect(messages.some(message => message.working)).toBe(false); + }); + + it("draws an answer whose check is still running as an ended turn, its tools kept", () => { + let tooled = { + ...answer, + tools: [{ id: "read", name: "read_plan", status: "done" as const, result: "Read it." }], + }; + let { shown, messages } = rows([prompt, tooled], { ...turn, quiet: true }); + expect(shown.working).toBeUndefined(); + expect(shown.settled).toEqual({ + turnId: "turn-1", + entryOffset: 1, + endOffset: 2, + anchorId: "answer", + }); + let shownAnswer = messages.find(message => message.id === "answer"); + expect(shownAnswer?.working).toBeUndefined(); + expect(shownAnswer?.tools).toMatchObject([{ id: "read", status: "done" }]); + }); + + it("goes on showing work once a repair step makes the turn work again", () => { + let { shown } = rows([prompt, answer], turn); + expect(shown.working?.id).toBe("turn-1"); + expect(shown.settled).toBeUndefined(); + }); + + it("offers Stop for a quiet turn only when a message was refused because of it", () => { + let quiet = { ...turn, quiet: true }; + expect(offersStop(true, turn, false)).toBe(true); + expect(offersStop(true, quiet, false)).toBe(false); + expect(offersStop(true, quiet, true)).toBe(true); + expect(offersStop(false, undefined, false)).toBe(false); + // A server that does not say which turn runs keeps its Stop. + expect(offersStop(true, undefined, false)).toBe(true); + }); +}); + describe("when a restart is waited out in silence", () => { it("begins with a loss the server said was a restart, and with no other", () => { expect(silentRestart(false, true, false, false)).toBe(true); diff --git a/apps/web/src/chat/transcript-state.ts b/apps/web/src/chat/transcript-state.ts index e779acda9..4f19bbb90 100644 --- a/apps/web/src/chat/transcript-state.ts +++ b/apps/web/src/chat/transcript-state.ts @@ -32,8 +32,20 @@ export function progressOf( state: TranscriptState, current: boolean, quiet: boolean, -): { working?: Chat.Turn; suspended?: CompletedWork } { - if (current || quiet) return { working: state.turn }; +): { working?: Chat.Turn; suspended?: CompletedWork; settled?: CompletedWork } { + if (current || quiet) { + if (!state.turn?.quiet) return { working: state.turn }; + // Nothing left of a quiet turn is for people to watch: it is drawn as the turn that ended. + if (!state.activeAnchorId) return {}; + return { + settled: { + turnId: state.turn.id, + entryOffset: state.turn.entryOffset, + endOffset: state.entries.length, + anchorId: state.activeAnchorId, + }, + }; + } if (!state.turn || !state.activeAnchorId) return {}; return { suspended: { @@ -61,6 +73,19 @@ export function silentRestart( return !current && !spoken && (was || restarting); } +/** + * Whether Chat offers Stop: while a turn people can watch runs, or while a quiet one runs and a + * message was just refused because of it, so the person can stop it to ask now. + */ +export function offersStop(busy: boolean, turn: Chat.Turn | undefined, refused: boolean): boolean { + return busy && (!turn?.quiet || refused); +} + +/** The ended turns to draw, the quiet one still running among them. */ +export function shownWork(completed: CompletedWork[], settled?: CompletedWork): CompletedWork[] { + return settled ? [...completed, settled] : completed; +} + export let initialTranscript: TranscriptState = { entries: [], completedWork: [] }; function anchor(entries: Chat.Entry[], turn: Chat.Turn): string | undefined { diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 367cc3426..e895ed19b 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -873,11 +873,23 @@ export function RoomWorkspace( let workspacePresentation = presentWorkspace(workspace, mode, view); let chatActive = workspacePresentation.chatVisible; // `loaded` once this socket's transcript arrived, so `busy` reflects the room. - let [chatActivity, setChatActivity] = useState({ unread: 0, busy: false, loaded: false }); + let [chatActivity, setChatActivity] = useState({ + unread: 0, + busy: false, + working: false, + loaded: false, + }); + // Chat's own mark says chopin is working only for a turn people watch; a quiet one (a background + // job, a reply's after-answer check) still holds the document's and its decisions' state. + let shownChatActivity = useMemo( + () => ({ unread: chatActivity.unread, busy: chatActivity.working }), + [chatActivity.unread, chatActivity.working], + ); let onChatActivity = useCallback( - (event: { type: "message" | "working"; busy: boolean; history?: true }) => { + (event: { type: "message" | "working"; busy: boolean; working: boolean; history?: true }) => { setChatActivity(current => ({ busy: event.busy, + working: event.working, loaded: current.loaded || !!event.history, unread: event.type === "message" && !chatActive ? current.unread + 1 @@ -2155,7 +2167,7 @@ export function RoomWorkspace( wire={wire} /> } - chatActivity={chatActivity} + chatActivity={shownChatActivity} documentActivity={documentActivity(documentWatch, chatActivity.busy)} header={phone ? ( diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index de8efc100..278933a89 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -1185,7 +1185,8 @@ running turn like one sent in Chat, and its `settled` hook runs once that is don note sent to Chopin in a child document is admitted by the root's Chat in the same way. What still waits for the running turn, in memory, is Chopin's own scheduling, which nobody typed -and which is neither saved nor shown: background jobs (such as keeping a title) and the turns the +and which is neither saved nor shown (its turn is sent as `quiet`, which Chat draws as no turn at +all, as it does a reply whose words are all out while its after-answer check runs): background jobs (such as keeping a title) and the turns the server writes for itself (answers to open decisions, relinking after a build, settling a prototyped passage, an ended workflow run, a readiness check, an initiative proposal). They start in the order they were asked once the running turn ends, a job that is no longer worth running is dropped diff --git a/e2e/chat-after-answer.e2e.ts b/e2e/chat-after-answer.e2e.ts new file mode 100644 index 000000000..b50f84f1a --- /dev/null +++ b/e2e/chat-after-answer.e2e.ts @@ -0,0 +1,214 @@ +/** + * After chopin's answer is out, Chat shows nothing more of the turn unless chopin does more: the + * check that runs after a reply, and a background job such as keeping the title, are quiet turns. + * They draw no working row and offer no Stop, unless a message was refused because of one. A repair + * step the check starts is work again. A scripted Planner holds each state for as long as a test + * looks at it. + */ + +import { chatInput, fillChat } from "./chat-input"; +import { expect, test } from "./room"; + +import type { Chat } from "../packages/protocol/index"; +import type { Locator, Page } from "@playwright/test"; + +function chatPane(page: Page) { + return page.getByRole("complementary", { includeHidden: true, name: "Chat" }); +} + +const BUSY = "chopin can't take messages while it works on this machine. Stop it to ask now."; + +async function scriptPlanner(page: Page) { + let routes: { send: (frame: Record) => void }[] = []; + let entries = 0; + let turns = 0; + let refuse = false; + let tell = (frame: Record) => routes.at(-1)?.send(frame); + let turn = (extra: Partial = {}): Chat.Turn => ({ + id: `turn-${turns}`, + handle: "ana", + started: 1_700_000_001, + entryOffset: entries, + responded: false, + ...extra, + }); + let state = (busy: boolean, current?: Chat.Turn) => + tell({ kind: "chat:state", ts: 0, busy, ...(current ? { turn: current } : {}) }); + await page.route("**/api/session", async route => { + let response = await route.fetch(); + await route.fulfill({ + response, + json: { ...await response.json() as Record, agent: true }, + }); + }); + await page.routeWebSocket("**/ws?**", route => { + let server = route.connectToServer(); + routes.push({ send: frame => route.send(JSON.stringify(frame)) }); + route.onMessage(message => { + if (typeof message !== "string") return server.send(message); + let frame = JSON.parse(message) as { + kind?: string; + rid?: string; + text?: string; + to?: string; + }; + if (frame.kind === "chat:send") { + if (refuse) { + return tell({ + kind: "session:error", + ts: 0, + rid: frame.rid, + code: "planner-busy", + message: BUSY, + }); + } + let id = `sent-${entries}`; + tell({ kind: "chat:send", ts: 0, rid: frame.rid, id }); + tell({ + kind: "chat:message", + ts: 0, + entry: { + id, + author: { kind: "member", handle: "ana" }, + text: frame.text ?? "", + ts: 1_700_000_000, + to: frame.to, + }, + }); + entries++; + turns++; + return state(true, turn()); + } + server.send(message); + }); + server.onMessage(message => route.send(message)); + }); + let current: Chat.Turn | undefined; + return { + /** The answer is all out: the turn now only checks it. */ + answer(text = "An iterator yields one item at a time.") { + current = turn({ entryOffset: entries, responded: true }); + tell({ + kind: "chat:message", + ts: 0, + entry: { id: `answer-${turns}`, author: { kind: "agent" }, text, ts: 1_700_000_002 }, + }); + entries++; + state(true, { ...current, entryOffset: current.entryOffset }); + }, + quiet() { + state(true, { ...current!, quiet: true }); + }, + /** The check found a question asked in Chat: the repair step is work again. */ + repair() { + state(true, { ...current! }); + }, + end() { + state(false); + }, + /** A background job's turn, after the answer. */ + job() { + turns++; + state(true, turn({ quiet: true })); + }, + refuse() { + refuse = true; + }, + }; +} + +async function ask(chat: Locator, text = "@chopin What is an iterator?") { + let draft = chatInput(chat); + await fillChat(draft, text); + await draft.press("Enter"); + await expect(chat.getByRole("button", { name: "Stop generating" })).toBeVisible(); +} + +function answer(chat: Locator) { + return chat.locator("[data-chat-message-id^='answer-']"); +} + +test("once the answer is out, its check shows nothing: no working row, no Stop, no start time", async ({ join, page }) => { + let planner = await scriptPlanner(page); + let chat = chatPane(await join("ana")); + await ask(chat); + planner.answer(); + await expect(answer(chat)).toHaveCount(1); + // While the turn works on, the answer says so, and Stop is there. + await expect(chat.locator(".chat-work[data-work-active='true']")).toHaveCount(1); + + planner.quiet(); + await expect(chat.locator(".chat-work")).toHaveCount(0); + await expect(chat.getByText("Getting oriented")).toHaveCount(0); + await expect(chat.getByText("Reviewing the next step")).toHaveCount(0); + await expect(chat.getByText(/^Started at/)).toHaveCount(0); + await expect(chat.getByRole("button", { name: "Stop generating" })).toHaveCount(0); + await expect(chat.locator("[data-send-stop]")).toHaveAttribute("data-send-stop", "send"); + + planner.end(); + await expect(answer(chat)).toHaveCount(1); + await expect(chat.locator(".chat-work")).toHaveCount(0); +}); + +test("a repair step the check starts is work again, shown in place on the answer", async ({ join, page }) => { + let planner = await scriptPlanner(page); + let chat = chatPane(await join("ana")); + await ask(chat); + planner.answer("Which scope do you want?"); + planner.quiet(); + await expect(chat.getByRole("button", { name: "Stop generating" })).toHaveCount(0); + + planner.repair(); + await expect(chat.locator(".chat-work[data-work-active='true']")).toHaveCount(1); + await expect(chat.getByRole("button", { name: "Stop generating" })).toBeVisible(); + // In place: still one chopin answer, and no second row for the repair. + await expect(answer(chat)).toHaveCount(1); + planner.end(); + await expect(chat.getByRole("button", { name: "Stop generating" })).toHaveCount(0); +}); + +test("a background job after the answer draws no row and no Stop, until a message is refused because of it", async ({ join, page }) => { + let planner = await scriptPlanner(page); + let chat = chatPane(await join("ana")); + await ask(chat); + planner.answer(); + planner.end(); + await expect(chat.getByRole("button", { name: "Stop generating" })).toHaveCount(0); + + planner.job(); + // Nothing new appears under the answer, and nothing offers to stop. + await page.waitForTimeout(300); + await expect(chat.getByText("Getting oriented")).toHaveCount(0); + await expect(chat.locator(".chat-work")).toHaveCount(0); + await expect(chat.getByText(/^Started at/)).toHaveCount(0); + await expect(chat.getByRole("button", { name: "Stop generating" })).toHaveCount(0); + + // A message the job refuses brings Stop, so it can be stopped to ask now. + planner.refuse(); + let draft = chatInput(chat); + await fillChat(draft, "@chopin And a Rust iterator adapter?"); + await draft.press("Enter"); + await expect(chat.getByText(BUSY)).toBeVisible(); + await expect(chat.getByRole("button", { name: "Stop generating" })).toBeVisible(); +}); + +test("on a phone, the Chat tab says chopin is working only while it is, not through a background job", async ({ join, page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + let planner = await scriptPlanner(page); + let joined = await join("ana"); + let tab = joined.getByRole("navigation", { name: "Workspace view" }) + .getByRole("button", { name: /^Chat/ }); + await tab.click(); + let chat = chatPane(joined); + await ask(chat); + await expect(tab).toHaveAccessibleName(/chopin working/); + planner.answer(); + planner.quiet(); + await expect(tab).not.toHaveAccessibleName(/working/); + await expect(joined.locator(".workspace-working-indicator")).toHaveCount(0); + planner.end(); + planner.job(); + await page.waitForTimeout(300); + await expect(tab).not.toHaveAccessibleName(/working/); + await expect(joined.locator(".workspace-working-indicator")).toHaveCount(0); +}); diff --git a/packages/protocol/chat.d.ts b/packages/protocol/chat.d.ts index 4fc0e9c9f..e59090b18 100644 --- a/packages/protocol/chat.d.ts +++ b/packages/protocol/chat.d.ts @@ -341,6 +341,13 @@ export declare namespace Chat { startedAt?: number; /** True after the Planner has sent non-empty prose. */ responded: boolean; + /** + * Nothing more of this turn is for people to watch: a background job's (keeping a title), or + * a reply whose words are all out while its after-answer check runs. Chat draws it as ended and + * offers no Stop for it unless a message was refused because of it. A repair step that the + * check starts is work again, and clears it. Absent on earlier servers. + */ + quiet?: boolean; }; /** One stage of a workflow run, in the order the run reached it. */ From 58f4b5d4c448530f378a57d74204d919daadc808 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 12 Nov 2026 16:19:43 -0800 Subject: [PATCH 2/2] Make a web address pasted into Chat a link, as Slack does A bare address in a Chat message stayed plain text: only Markdown links were links, so pasting a link to a section of the document left nothing to click. Chat now reads GitHub's literal links for addresses written with http:// or https://, in messages, threads and chopin's replies, on the same terms as the other GitHub parts it reads: only when the text holds such an address. The punctuation after an address stays the sentence's, code spans and blocks stay code, and the link goes through Chat's own link, so one into the document or another of its documents is followed in place (#866) and only another site opens a new tab. GitHub's www. and email literals are turned back into the text they were, so nothing becomes a link that was not written as an address, and no other scheme is ever linked. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline (subagent) Assistant-verification: bun test apps/web/src/chat/gfm.test.tsx apps/web/src/chat/markdown.test.ts apps/web/src/chat/message-copy.test.ts (new tests fail on 8634eefaf) Assistant-verification: e2e in-document-links: the new pasted-address test passes here and fails on 8634eefaf; the file's other tests pass on both Assistant-verification: bun run types; bun run ci; bun run build (initial JS 259,958 B raw / 82,480 B gzip, CSS 184,551 B raw / 32,672 B gzip, unchanged) User-preference: Things should be polished like the Slack UX Co-authored-by: Alex Lavaee --- apps/web/DESIGN.md | 2 +- apps/web/package.json | 2 + apps/web/src/chat/gfm.test.tsx | 67 +++++++++++++++++++++++-- apps/web/src/chat/gfm.ts | 79 ++++++++++++++++++++++++++---- apps/web/src/chat/markdown.test.ts | 28 +++++++++-- bun.lock | 2 + e2e/in-document-links.e2e.ts | 33 +++++++++++++ 7 files changed, 192 insertions(+), 21 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index ea2d87980..d8335f51f 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -210,7 +210,7 @@ Rectangular controls use a restrained four-step radius scale: small for grips an - **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. 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. +- **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_, and make a web address written out with `http://` or `https://` a link, as Slack does (the punctuation after it stays the sentence's, code stays code, and one into the document is followed in place like any link to it); footnotes, `www.` addresses and email addresses stay plain text. 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. - **Cursors:** The cursor says what a thing does, as Slack's does. Whatever a click acts on and is not text entry shows the pointer: buttons and icon buttons, links, menu and list rows, tabs, toggles and checkboxes with their labels, chips and reaction pills, toolbar buttons, the machine button, and the copied assistant-ui and shadcn elements (shadcn's menu and command rows ask for the arrow, and Chopin gives them the pointer by their `data-slot`, never by editing a copy). A picture that opens the viewer shows the zoom. Editable text shows the text cursor (a field computes `text`; the draft and the document keep the browser's `auto`, which is the I-beam over editable text), and static text keeps the browser's own cursor, never the pointer. A disabled control keeps the arrow (`not-allowed` only where a copy already asks for it). What drags shows the open hand (`grab`) at rest, a divider the resize cursor (`col-resize`), an image's corner its diagonal; while it is dragged the whole page shows the drag's cursor (`grabbing` for a grip), so it does not flicker to the text cursor or the arrow when the pointer outruns the handle: `holdDragCursor` lays one transparent layer carrying it over the window for exactly as long as the drag, because a rule giving every element the cursor restyles the whole page when it switches on. The rules are `src/cursors.css`, in the base layer so a component's own cursor wins; it loads with the tooltips, before anything is drawn, rather than in the entry stylesheet. `e2e/design/cursors.e2e.ts` and `e2e/cursors.e2e.ts` read the cursor under the mouse. - **Emoji:** Every emoji is the platform's own colour emoji, drawn as text and never as an image, as Slack's native emoji setting draws them. The text stack lists Apple Color Emoji, Segoe UI Emoji and Noto Color Emoji right after Inter (`--font-sans`), so a letter is always Inter's and an emoji never falls to a generic family that draws it as a black outline (Linux's `sans-serif` is DejaVu Sans); where only emoji are drawn (a reaction pill, the toolbar's quick reactions, the picker) `--font-emoji` puts those fonts first, then the text stack. In a message an emoji is larger than the words (the xl role beside the base, Slack's 22px beside 15px), on their baseline and without making the line taller, and a message of nothing but emoji, up to Slack's 23, draws them in the 2xl role (Slack's jumbomoji); a character with a text form written without the emoji selector (©, a plain ❤) stays text, and code keeps its characters. The emoji picker is Slack's in Chopin's colours: a 360 by 420 popover at its button (a sheet along the bottom edge on a phone, up to 480px tall) with the popover's radius, hairline and overlay shadow, appearing with a short fade that reduced motion leaves out; a search field with a magnifier, focused on open, that filters by name, keyword and Slack's short name and says _No emoji found_; a row of nine category tabs in Chopin's icons (Frequently used, Smileys and people, Animals and nature, Food and drink, Activities, Travel and places, Objects, Symbols, Flags) whose petrol bar follows the scroll and which scroll to their section, smoothly unless motion is reduced; sticky bold headings over nine columns of emoji in the xl role (Slack's 22px) in square cells with the medium radius, greyed under the pointer or the keyboard; and a footer that shows the emoji under the pointer or the focus in the 2xl role with its Slack name (`:+1:`, `:wave::skin-tone-4:`) beside the hand that opens the six skin tones, whose choice applies to the grid and is kept in the browser. Frequently used comes first: two rows of the emoji this person reacts with most, the latest among equals, kept in their browser, filled from Slack's own first picks (👍 ✅ 👀 🙌 🎉 😂 🙏 ❤️ 🔥). The keyboard reaches everything: Down from the field enters the grid, arrows move by column across sections and Up from the top row returns to the field, Enter picks, Left and Right move between the tabs, Tab moves through the field, the tabs, the grid and the hand and closes the picker past either end, and Escape closes it and returns focus to Add reaction. The catalogue (`emojibase-data`, MIT, with iamcal's names, which Slack uses) loads the first time a picker opens. diff --git a/apps/web/package.json b/apps/web/package.json index b220319bf..82b6fcbe5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,10 +34,12 @@ "emojibase-data": "17.0.0", "lexical": "catalog:mdx", "mdast-util-from-markdown": "2.0.2", + "mdast-util-gfm-autolink-literal": "2.1.0", "mdast-util-gfm-strikethrough": "2.0.0", "mdast-util-gfm-table": "2.0.0", "mdast-util-gfm-task-list-item": "2.0.0", "mdast-util-to-string": "4.0.0", + "micromark-extension-gfm-autolink-literal": "2.1.0", "micromark-extension-gfm-strikethrough": "2.1.0", "micromark-extension-gfm-table": "2.1.1", "micromark-extension-gfm-task-list-item": "2.1.0", diff --git a/apps/web/src/chat/gfm.test.tsx b/apps/web/src/chat/gfm.test.tsx index 0f32eb27c..8b16b58d6 100644 --- a/apps/web/src/chat/gfm.test.tsx +++ b/apps/web/src/chat/gfm.test.tsx @@ -103,6 +103,12 @@ const SOURCES = [ "[x] outside a list", // A table inside code, a quote, a list and a task list at once. "- [x] | A |\n | - |", + // Web addresses written out, beside the literals Chat leaves as text and links already written. + "see https://example.com/a_(b). and www.example.com, `https://code.example`, ana@example.com", + "[x](https://a.example) then https://b.example/path?q=1#part, and ", + "HTTPS://EXAMPLE.COM and http:// alone and javascript:alert(1) and https://x.example**bold**", + "```\nhttps://inside.example\n```\n\n> https://quoted.example\n\n- https://listed.example", + "www.example.com then https://example.com then mail@example.com then www.example.org", ]; /** Every prefix of a source, as a stream delivers it. */ @@ -110,6 +116,7 @@ const STREAMED = [ "Intro\n\n| Queue | Owner |\n| :-- | --: |\n| shared | platform |\n\n- [x] done\n- [ ] open\n\n~~gone~~ text\n", "a\n:-:\nb\n\n> | q |\n> | - |\n", "- [\r\n ] a\n\n> - [\n> ] b\r\n1. [\n x] c\n", + "Read https://example.com/docs/page#section-two, then www.example.com or ana@example.com.\n", ]; /** A small deterministic generator, so a failure names the same source every run. */ @@ -166,8 +173,10 @@ function sources(count: number): string[] { } test("a message takes the parts of GitHub's Markdown its text can use, and nothing else", () => { - expect(messageGfmRemarkPlugin("plain text, a `code` span and a [link](https://example.com)")) + expect(messageGfmRemarkPlugin("plain text, a `code` span and a [link](/docs/page)")) .toBeUndefined(); + expect(messageGfmRemarkPlugin("www.example.com and ana@example.com")).toBeUndefined(); + expect(messageGfmRemarkPlugin("see https://example.com")).toBeDefined(); expect(messageGfmRemarkPlugin("")).toBeUndefined(); expect(messageGfmRemarkPlugin("| a |\n| - |")).toBeDefined(); expect(messageGfmRemarkPlugin("| a |")).toBeUndefined(); @@ -219,14 +228,60 @@ test("a column with no pipe is a table the choice keeps, and a row of hyphens al expect(messageGfmRemarkPlugin("Note: a | b, 3 - 1 and :- are text")).toBeUndefined(); }); -test("what it reads is tables, strikethrough and task lists, and no footnote or bare address", () => { +test("what it reads is tables, strikethrough, task lists and web addresses, and no footnote", () => { let tables = draw(SOURCES[1]!, messageGfmRemarkPlugin(SOURCES[1]!)); expect(tables).toContain("gone"); expect(draw(SOURCES[5]!, messageGfmRemarkPlugin(SOURCES[5]!))).toContain('type="checkbox"'); - let footnote = draw(SOURCES[13]!, messageGfmRemarkPlugin(SOURCES[13]!)); - expect(footnote).not.toContain("Footnote[^1] and https://example.com and a@b.co

\n' + + "

[^1]: The note.

", + ); +}); + +/** What a source draws as, through the parts it takes: its tags, with only a link's address kept. */ +function shown(source: string): string { + return draw(source, messageGfmRemarkPlugin(source)) + .replace(/^]*>|<\/div>$/g, "") + .replace(/<(\w+)([^>]*)>/g, (_tag, name: string, attributes: string) => { + let href = / href="[^"]*"/.exec(attributes)?.[0] ?? ""; + return `<${name}${href}>`; + }); +} + +test("a web address written out is a link, as in Slack, and the punctuation after it is not", () => { + expect(shown("see https://example.com/a_(b). next")).toBe( + '

see https://example.com/a_(b). next

', + ); + expect(shown("Read https://example.com/docs/page#section-two, then stop")).toBe( + '

Read ' + + "https://example.com/docs/page#section-two, then stop

", + ); + expect(shown("HTTP://EXAMPLE.COM")).toBe( + '

HTTP://EXAMPLE.COM

', + ); +}); + +test("only an address with http:// or https:// is linked: www., email, other schemes and code stay text", () => { + expect(shown("https://a.example and www.example.com and ana@example.com")).toBe( + '

https://a.example and www.example.com and ana@example.com

', + ); + expect(shown("javascript:alert(1) https://ok.example")).toBe( + '

javascript:alert(1) https://ok.example

', + ); + expect(shown("`https://code.example` and http://")).toBe( + "

https://code.example and http://

", + ); + expect(shown("```\nhttps://inside.example\n```")).toContain( + "
https://inside.example\n
", + ); + expect(shown("```\nhttps://inside.example\n```")).not.toContain(" { + expect(shown("[the docs](https://a.example) and ")).toBe( + '

the docs and https://b.example

', + ); }); // What the choice was before its two scans were made linear, kept here as the reference they are @@ -245,6 +300,8 @@ function referenceChoice(source: string) { if (REFERENCE_DELIMITER_ROW.test(source)) marks.push(TABLE_ONLY); if (source.includes("~")) marks.push(STRIKETHROUGH_ONLY); if (REFERENCE_TASK_BOX.test(source)) marks.push(TASKS_ONLY); + // The web address's test was never quadratic, so it has no reference of its own. + if (/https?:\/\//i.test(source)) marks.push("https://"); return messageGfmRemarkPlugin(marks.join("\n\n")); } diff --git a/apps/web/src/chat/gfm.ts b/apps/web/src/chat/gfm.ts index afcf77c7c..23afc5744 100644 --- a/apps/web/src/chat/gfm.ts +++ b/apps/web/src/chat/gfm.ts @@ -1,6 +1,8 @@ +import { gfmAutolinkLiteralFromMarkdown } from "mdast-util-gfm-autolink-literal"; import { gfmStrikethroughFromMarkdown } from "mdast-util-gfm-strikethrough"; import { gfmTableFromMarkdown } from "mdast-util-gfm-table"; import { gfmTaskListItemFromMarkdown } from "mdast-util-gfm-task-list-item"; +import { gfmAutolinkLiteral } from "micromark-extension-gfm-autolink-literal"; import { gfmStrikethrough } from "micromark-extension-gfm-strikethrough"; import { gfmTable } from "micromark-extension-gfm-table"; import { gfmTaskListItem } from "micromark-extension-gfm-task-list-item"; @@ -17,6 +19,7 @@ export type MessageGfmExtensions = { const TABLE = 1; const STRIKETHROUGH = 2; const TASKS = 4; +const ADDRESSES = 8; /** * A table needs a delimiter row, and micromark reads one only if it holds a pipe or a colon: a row @@ -44,14 +47,64 @@ const TASK_BOX = /\[[\s>]*(?:[xX][\s>]*)?\]/; /** * Which parts of GitHub's Markdown a source can use, from marks without which the syntax cannot - * match: a delimiter row for a table, a tilde for strikethrough, and a bracket around a space, - * a line ending, `x` or `X` for a task box. A single tilde is enough, because the strikethrough - * syntax, even with single-tilde strikethrough off, changes how emphasis around a tilde parses. Each test only has to be necessary, never sufficient, - * since a part read for a source that has no use for it changes nothing. + * match: a delimiter row for a table, a tilde for strikethrough, a bracket around a space, a line + * ending, `x` or `X` for a task box, and `http://` or `https://` for a web address. A single tilde + * is enough, because the strikethrough syntax, even with single-tilde strikethrough off, changes how + * emphasis around a tilde parses. Each test only has to be necessary, never sufficient, since a + * part read for a source that has no use for it changes nothing. */ function partsOf(source: string): number { return (DELIMITER_ROW.test(source) ? TABLE : 0) | (source.includes("~") ? STRIKETHROUGH : 0) - | (TASK_BOX.test(source) ? TASKS : 0); + | (TASK_BOX.test(source) ? TASKS : 0) | (WEB_ADDRESS.test(source) ? ADDRESSES : 0); +} + +/** A web address written out, which is the only kind of bare text Chat makes a link of. */ +const WEB_ADDRESS = /https?:\/\//i; + +type Node = { + type: string; + value?: string; + url?: string; + children?: Node[]; + position?: { start: { offset?: number }; end: { offset?: number } }; +}; + +/** + * GitHub's literal links also read `www.example.com` and `ana@example.com`, which Chat leaves as + * the text they were written as: only an address written with `http://` or `https://` becomes a + * link, as Slack does. A literal is told from a written link (`[a](…)`, ``) by having + * no brackets around its text, so its text starts and ends where it does; one made after the parse + * has no position at all. The text a literal leaves is joined to the text beside it. + */ +function keepWebAddresses(tree: Node): void { + let visit = (node: Node) => { + if (!node.children) return; + let children: Node[] = []; + for (let child of node.children) { + let text = child.children?.length === 1 ? child.children[0] : undefined; + let literal = child.type === "link" && text?.type === "text" + && (!child.position || !text.position + || child.position.start.offset === text.position.start.offset + && child.position.end.offset === text.position.end.offset); + if (literal && !WEB_ADDRESS.test(text!.value?.slice(0, 8) ?? "")) { + let previous = children.at(-1); + if (previous?.type === "text") { + previous.value += text!.value ?? ""; + delete previous.position; + } else children.push({ type: "text", value: text!.value ?? "" }); + continue; + } + let previous = children.at(-1); + if (child.type === "text" && previous?.type === "text" && !previous.position) { + previous.value += child.value ?? ""; + continue; + } + visit(child); + children.push(child); + } + node.children = children; + }; + visit(tree); } function extensionsFor(parts: number): MessageGfmExtensions { @@ -69,6 +122,12 @@ function extensionsFor(parts: number): MessageGfmExtensions { extensions.push(gfmTaskListItem()); mdastExtensions.push(gfmTaskListItemFromMarkdown()); } + if (parts & ADDRESSES) { + extensions.push(gfmAutolinkLiteral()); + mdastExtensions.push(gfmAutolinkLiteralFromMarkdown(), { + transforms: [keepWebAddresses], + } as unknown as FromMarkdownExtension); + } return { extensions, mdastExtensions }; } @@ -98,9 +157,9 @@ export function messageGfmExtensions(source: string): MessageGfmExtensions | und /** * The parts of GitHub's Markdown that a Chat message reads: pipe tables, strikethrough written - * with two tildes, and task lists. remark-gfm also reads footnotes and bare URLs, and in Chat both - * were always plain text (a footnote would draw a second list of links and ids, and a bare address - * would become a link nobody wrote), so this takes only the three parts. + * with two tildes, task lists, and a web address written out (`https://…`), which becomes a link + * as it does in Slack. remark-gfm also reads footnotes, which in Chat stay plain text (they would + * draw a second list of links and ids), and `www.` and email literals (`keepWebAddresses`). * * It takes only those the source can use. The table's syntax is tried at the start of every line * of every message, and most messages have no delimiter row, so a source with none of the marks is @@ -115,5 +174,5 @@ export function messageGfmRemarkPlugin(source: string): Plugin | undefined { return held; } -/** All three parts whatever the source, for the test that the choice above never changes a tree. */ -export let allMessageGfmParts: Plugin = plugin(TABLE | STRIKETHROUGH | TASKS); +/** Every part whatever the source, for the test that the choice above never changes a tree. */ +export let allMessageGfmParts: Plugin = plugin(TABLE | STRIKETHROUGH | TASKS | ADDRESSES); diff --git a/apps/web/src/chat/markdown.test.ts b/apps/web/src/chat/markdown.test.ts index 9f7d43349..1eec28ff8 100644 --- a/apps/web/src/chat/markdown.test.ts +++ b/apps/web/src/chat/markdown.test.ts @@ -132,17 +132,18 @@ test("Chat reads GitHub's tables, strikethrough and task lists, and only those", expect(markup).toContain(`
  • `); }); -test("a footnote, a bare address and a www link stay the plain text they were in Chat", () => { +test("a web address written out is a link, as in Slack; a footnote, a www address and an email stay text", () => { let markup = markdown( "Claim[^1] and see https://example.com/page, www.example.com or ana@example.com.\n\n[^1]: The note.", ); - expect(markup).not.toContain("https://example.com/page, www.example.com or ana@example.com.`, + ); expect(markup).not.toContain(" { + let markup = markdown( + "`https://code.example` and javascript:alert(1) and data:text/html,x\n\n```\nhttps://block.example\n```", + ); + expect(markup).not.toContain(" { + let source = "@ana see https://example.com/x and #Score"; + let markup = markdown(source, [reference(source, "#Score")]); + expect(markup).toContain('href="https://example.com/x"'); + expect(markup).toContain('data-chat-reference="document"'); +}); + test("raw HTML in a Chat message is never drawn, an image tag included", () => { let markup = markdown(RAW_HTML_MESSAGE); diff --git a/bun.lock b/bun.lock index 9f727accf..34f5062ff 100644 --- a/bun.lock +++ b/bun.lock @@ -94,10 +94,12 @@ "emojibase-data": "17.0.0", "lexical": "catalog:mdx", "mdast-util-from-markdown": "2.0.2", + "mdast-util-gfm-autolink-literal": "2.1.0", "mdast-util-gfm-strikethrough": "2.0.0", "mdast-util-gfm-table": "2.0.0", "mdast-util-gfm-task-list-item": "2.0.0", "mdast-util-to-string": "4.0.0", + "micromark-extension-gfm-autolink-literal": "2.1.0", "micromark-extension-gfm-strikethrough": "2.1.0", "micromark-extension-gfm-table": "2.1.1", "micromark-extension-gfm-task-list-item": "2.1.0", diff --git a/e2e/in-document-links.e2e.ts b/e2e/in-document-links.e2e.ts index 4675c7212..267178b5a 100644 --- a/e2e/in-document-links.e2e.ts +++ b/e2e/in-document-links.e2e.ts @@ -266,6 +266,39 @@ test("links in Chat that point into the document go there in the same tab", asyn await expectAtTop(page, heading(page, "Deep section")); }); +test("a web address pasted into Chat is a link, and one into the document is followed in the same tab", async ({ baseURL, join, room }) => { + let { other } = await seedDocuments(baseURL!, room); + let page = await join("ana"); + let tabs = tabsOpened(page); + let path = new URL(page.url()).pathname; + let chat = page.getByRole("complementary", { name: "Chat", exact: true }); + let here = `${baseURL}${path}#über-café`; + let deep = `${baseURL}${other}#deep-section`; + await chatInput(chat).fill( + `Pasted: ${here}, then ${deep} and https://example.com/docs. Not www.example.com or \`https://code.example\`.`, + ); + await chat.getByRole("button", { name: "Send message" }).click(); + let message = chat.locator("[data-chat-message-id]").filter({ hasText: "Pasted:" }); + let link = (name: string) => message.getByRole("link", { name, exact: true }); + + // The comma and full stop after an address are the sentence's, not the link's. + await expect(message.getByRole("link")).toHaveCount(3); + await expect(link("https://example.com/docs")).toHaveAttribute("target", "_blank"); + await expect(link("https://example.com/docs")).toHaveAttribute("rel", /noopener/); + await expect(message).toContainText("Not www.example.com or https://code.example."); + await expect(message.locator("code")).toHaveText("https://code.example"); + + await expect(link(here)).not.toHaveAttribute("target", /./); + await link(here).click(); + await expect.poll(() => decodeURIComponent(new URL(page.url()).hash)).toBe("#über-café"); + await expectAtTop(page, heading(page, "Über café")); + + await link(deep).click(); + await expect(page).toHaveURL(`${other}#deep-section`); + await expectAtTop(page, heading(page, "Deep section")); + expect(tabs()).toBe(0); +}); + test("a reader's tap on a section link goes there in the same tab", async ({ baseURL, join, room }) => { await seedDocuments(baseURL!, room); let page = await join("ana", {