Skip to content

Plain words everywhere, one quiet offline state, and a machine named only when it is the document's own - #794

Merged
lavaman131 merged 24 commits into
mainfrom
plain-words-and-quiet-offline
Nov 7, 2026
Merged

lavaman131 merged 24 commits into
mainfrom
plain-words-and-quiet-offline

Conversation

@lavaman131

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #792 (quiet machine status, plain machine-first copy, one offline notice) and #793 (quiet restarts). It does four things, in twenty-three small commits:

  1. While the page's offline notice shows, the composer says nothing about replies being unavailable, draws no "Reconnecting" line for a /research the connection could not take, no "Message not sent" line for a send that failed (the connection's or a server's refusal), no machine state for a person with no machine button, and no word about a machine on a workflow run's card or in the page's live region for a screen reader, so a lost connection is one notice and not two. A refusal and a machine state come back, as they were, when the connection returns; the others are gone with it.
  2. Every string a person reads that Quiet the machine status, and make a lost connection calm #792 listed as left alone now speaks plainly: Chopin where it said the Planner, the machine where it said attached, detached or held. Codes, wire formats, routes and stored data are unchanged.
  3. Both of Restart the server without anyone noticing: ready and live checks, a quiet reconnect, and a reload after a deploy only when it is safe #793's loose ends: the idle dialog warm-up no longer raises a page error while the server is away, and a refusal ends a restart's quiet period cleanly.
  4. The machine status no longer names another person's machine, nor another of the person's own that the document does not run on, nor a machine a newer word no longer supports, keeps the last known state through a restart however long the machine takes to return, says when an offline machine was last seen, and lives in one place; Chat no longer says that the machine disconnected or came back.

Decision and Decisions stay Chopin's word for its tab, its cards and its controls. That was decided by the user against the research this work did (below). The branch contains no Decision to Question rename.

Key screenshots

Offline: the page's one notice, and nothing about replies beside the composer

A send the connection cut off, on a phone while offline: no line beside the composer

The machine button's menu for an offline machine: "studio is offline. Last seen 10:12 PM."

After the document moved to another machine: only the new machine is named

A run card while offline: no machine state beside the offline notice

A person without the machine button, online: the machine's state on the composer's top edge

What changed

1. One offline state

A document whose agent is off (AGENT=off) drew its own info strip above the composer ("Chopin unavailable") beside the page's offline notice, so two strips about "unavailable" showed at once and the composer was a different size online and offline.

  • While useOfflineNotice says the notice is up, the composer says nothing of availability. The strip comes back by itself with the connection, with nothing to say it did.
  • Online, it is told plainly and without a system name: Replies are off on this server., and after a message addressed to Chopin, Replies are off on this server. Your message went to the people here only.
  • A send the connection cut off used to draw "Message not sent. Check the connection and try again." with Retry above the composer, a second alert beside the offline notice that then stayed after the connection returned, until the person typed. The composer now keeps whether a failed send was the connection's, and draws no strip for one while the connection is down (the offline notice, and the moments before it shows, so a blip says nothing); the strip is also gone when the connection returns. The draft stays in the composer for the person to send again. How a message is sent, retried or refused, and its timing, are unchanged.
  • A server's refusal of a send (and a validation error, and the refusal that a machine is still being connected to) is told by its strip while the connection is up, but gives way to the offline notice while it shows: no strip is drawn, the stored failure is kept, and the same strip with the same words is back when the connection returns, until the person sends again. How a send is refused, retried, cleared or timed is unchanged, and the refusal about a machine still being connected to keeps its words, its wait and its clearing (they belong to branch stage1-invisible-deploys); only whether a failure is drawn beside the notice changed. New specs on desktop and a 390 px phone.
  • A person with no machine button (a teammate with no machine, or a phone whose top bar an open child has taken) reads the machine's state in a strip above the composer, which used to stay beside the offline notice, stale, since the page cannot hear the machine while it is offline. That strip is not drawn while the notice shows, and returns by itself, with the state then known, when the connection does. Online it stays: such a person has no other place to read the state (item 4). New specs on desktop and a 390 px phone.
  • A workflow run whose machine had dropped keeps the server's last frame through a lost connection, so its card used to go on saying "disconnected", "Last reported running. It carries on when the machine reconnects." and "Pause when the machine reconnects" beside the offline notice, on desktop and on a phone. While the notice shows, the card says nothing of the machine: it reads as the run last reported ("running", "paused", "waiting on Decisions"), with a plain Pause, no note, a still neutral ring instead of the warning one, its clock where it was, and its place and controls as they were (a Pause where it had one, no Resume it cannot deliver, its way to Decisions). The status word is not announced as it changes. The words come back with the connection, as the run's state then is. New specs on desktop and a 390 px phone.
  • The page's one live region for a machine's state (the sentence a screen reader hears as a state begins) kept the last sentence it had said beside the offline notice, and a state released after the socket had dropped (a list read started after a word, answering after the connection was lost, whose grace then ran out) was said beside it too. While the notice shows, the region is empty and says nothing new, whatever the state does meanwhile, so a screen reader hears the connection alone. It remembers what it last said, so a state that carried on through the loss is not said again when the connection returns, and a different one is said once. The run-card spec now also checks the region and the words "is offline", "Connecting", "Your work is kept" and "No machine" in the page's text, labels, titles and live regions, and two new specs cover a state that carried on through the loss and a state released after the socket dropped; all on desktop and a 390 px phone.
  • A /research pressed while offline used to draw "Reconnecting… try again in a moment" with Retry above the composer, a second strip about the same connection. While the offline notice shows, that line is not drawn (the draft stays in the composer), and when the connection returns the stored block is cleared, so it neither comes back as a stale strip nor reappears in a later blip. A research request refused for any other reason is told as before. New specs on desktop and a 390 px phone.
  • The two outage specs that compare the composer's size before and after ran against a server whose agent is off, and the always-present strip hid exactly this. They now turn the agent on. New specs show exactly one notice on desktop and on a 390 px phone.

2. Plain words in the strings #792 left alone

The rule is the user's: no Planner, attach/attached/detach/detached, held or @chopin in what a person reads, except where the person types the mention themselves, and no Decisions inside text about a machine and its connection.

  • Chat's system lines from the server: the lines that record a person's own action ("@mona stopped Chopin and paused its workflows.", "@mona asked Chopin to stop. Its machine is disconnected, so its workflows will pause when it reconnects."), and the turn failures that reach Chat ("Chopin's reply failed. Try again.", "Chopin's owner is unavailable", "The machine's connection dropped during this reply. Its workflows and open questions are kept; ask again once it has reconnected."). Chat has no line for the machine disconnecting or coming back (item 4).
  • The agent connection's refusals and failures a person can see: "Connecting to this document needs push or administration access to ...", "mona is connected to this document. A document has one agent connected at a time.", "This document's connection was released, so mona is no longer connected. Connect again to resume.", the sign-in and paired-machine variants, and "The machine did not start the session." and its siblings. The sentence that ends the three "connected" variants says "one agent connected at a time", not "one connection": many people are connected to a document, and what is single is its agent. Error codes and close codes are unchanged.
  • Run cards: "It carries on when the machine reconnects", "Pause when the machine reconnects", and for a disconnected run that was waiting on Decisions, "Last reported waiting for people. Answers reach it when the machine reconnects." (that note is text about a machine's connection).
  • Composer hints and the empty Chat: "Sends to Chopin, which will reply", "Sent to people here. Type @ to ask Chopin", "Use @ to ask Chopin, or # to point at a document."
  • Busy labels: "Chopin is working", "Chat, Chopin working", "Document, Chopin writing"; the agent's face and "Chopin suggested" in a card's evidence; "The background job could not be retried."
  • The Machines page and Build plan's setup: "Can reply" and the sharing sentence ("... replies, builds and runs then happen here ..."); the setup popover says "Connect a machine", "Agent command" and "Open your agent and connect this document."
  • Design audit captions, apps/web/DESIGN.md and the docs that quote any of these.

One pattern serves every test, in @chopin/protocol/plain-words. INTERNAL bans the Planner, attach and detach in every form, held and @chopin in anything a person reads. INTERNAL_IN_STATUS also bans Decisions and applies only to text about a machine and its connection. status-copy.test.tsx applies the strict one to every status, strip, notice, refusal and Build reason it already walked, and the general one to everything else this change touches. Its self-test shows that the general pattern flags "Planner" and not "Decisions", and the strict one flags both. The server tests use expectPlain, expectPlainStatus and their Lines variants on the lines and refusals they already assert exactly. There is no second checker.

3. #793's loose ends

  • dialog-prefetch: the sidebar's idle warm-up of its lazy dialogs and the Search list was an uncaught dynamic import. While no server answers it failed with Failed to fetch dynamically imported module and nothing on the page showed it. It is caught where it is started, one promise covers the later chunks too, and the restart e2e no longer carves it out: both cases that read page errors now assert there are none.
  • A refusal inside the restart's quiet period (a 403 Chopin marked as its own) set the status to denied, but the quiet timer still raised onOutage when it ran out and restarting stayed true. A refusal now clears both. New case in wire-quiet.test.ts, red first on the old code (restarting stayed true, and the outage arrived at 5 s).

4. Status defects measured on a hosted deployment after a restart

After a reconnect, the machine button and the composer's top edge named another person's machine for about a second and a half before the document's own, and said "no machine" for a moment first; and through a deploy that took the machine minutes to return, the page said "No machine is connected." after 1.5 s.

  • The name comes from the document's own machine and no other: the one the machine button names (the person's choice, else the one Default machine uses now). A machine that is not the document's, a teammate's or another of the person's own, is never named, and a teammate's is told only as "@login's machine". While the person's machines are loading, or the two snapshots disagree, the status says nothing rather than guess. The machine list the page polls is also checked against the page's own connection: it names a machine for the account whose Planner it describes (its login, or the viewer's own where the machine is theirs), and that name is lent only to a word from the same account. A list read before another account's Planner took the document over used to lend its machine's name to that account's dropped, starting and failing Planner ("Box A is offline", "Connecting to Box A", "Can't connect to Box A", none of it true of them); and a list read before the person set the document to run on Box B, still describing their own Box A, did the same to their own Planner. Both now name nothing until the list agrees. The list is also never older than the word it names a machine for: the wire carries no machine id, so the page reads the list again on every word, and a word that is starting or has dropped names nothing, by name or by login, until a read that began after it has answered. Meanwhile the status says nothing of the machine, however long that takes: what it said of an older word does not stay, so a document set to run on Box C no longer reads "Connecting to Box B" from a list older than the word, and a read that fails or a hidden tab leaves it empty, never stale. What it had shown is remembered through that wait only so that the same state, naming the same machine, returning once the machine is named is drawn at once (it does not wait out its grace again). It is never drawn in place of a different state: any other state that arrives after the wait is drawn by its own rules, so a machine being connected to, or no machine yet, draws nothing until it has lasted its 1.5 s, and a machine that had dropped is not redrawn for that time (a document moved from Box B to Box C read "Runs on Box C. Box B is offline." for the length of the grace, then "Connecting to Box C"), while a state a person can act on, and every clearing, is drawn at once. A transition with no wait keeps the last state through the next one's grace, as before. The page's one live region falls silent in the gap and does not say that state again: it says a state once as it is entered, and again only after the Planner was healthy or gone, which is how a word that repeats a state with one more failed attempt is announced once, not twice. A list that agrees with itself is not enough, since it can have been read before the Planner moved. The machine is named by its id being the document's own, never by who owns it.
  • The moment: "Connecting to ..." and "No machine is connected." are said only once they have lasted as long as a lost connection does before the page speaks (1.5 s). The last known state stays until then, so the states a restart passes through are never shown or announced. What a person can act on, and every clearing, goes at once, and nothing confirms recovery.
  • Through a reconnect or a deploy the page keeps the last known state until it has a fresh one, however long the machine takes. A server that has just started tells none to everyone who connects until a machine returns, so the first none after the connection comes back is the new server's placeholder, not news of the machine. The page keeps what it showed (healthy, away, whatever it was) until another state arrives; a none told after any other state (a machine removed while the page is connected) and a page's very first none are still said. It uses the wire's existing signals (the reconnecting status and each Planner word) and changes no wire; only the status is held, and the Planner's real state is read as before. A restart's passing states and a six-second none show nothing new on desktop or a 390 px phone.
  • Chat says nothing of the machine's connection. The lines "The machine disconnected. Its workflows and open questions are kept, ..." and "The machine reconnected and carries on." are gone, with the sentence about a Stop it had not confirmed that rode on the first: the machine button, its menu and the run cards carry that state, and nothing confirms it came back. What stays are the lines that record a person's own action (Stop, Pause, Resume), which say what will happen when the machine reconnects and so confirm nothing, and a reply's own failure. Run cards, sessions and open questions are published as before.
  • Last seen: an offline machine says when it was last seen from the machine's own time: "Build box is offline. Last seen 10:31 AM." ("Your work is kept." remains where no time is known).
  • Where it lives: the machine button and its menu carry every state. The composer's top edge carries machine state only for a person with no machine button: no machine paired or shared, a title bar that does not show one, or a phone whose top bar an open child has taken. Such a person has nowhere else to read it, so the strip is a fallback and draws the reason in full, except while the offline notice shows, when it says nothing (item 1). For everyone else it says nothing, so a state is told in one place, as a contact's is in a chat.
  • Deliberately not done: the 30 s a message to a machine still being connected to waits before it is refused, and the red refusal strip ("Still connecting to @mona's machine. Try again in a moment.") staying after the machine connects. Both belong to the Stage 1 run on branch stage1-invisible-deploys, which removes that refusal and replaces it with queued messages that carry delivery marks. This change leaves the strip's words, its wait, its clearing and the send path unchanged (the strip is only not drawn while the offline notice shows, item 1), and changes no behaviour of how a refusal is handled, of how the transcript renders or of the wire's messages: only the words of some refusals and lines (item 2), and the browser's reconnect logic in item 3.

The messaging standard followed

The user's instruction: Chopin's Chat, machine status and connection handling should behave like WhatsApp and Slack. The machine that runs a document's agent is the other person in a chat; the person's own connection is WhatsApp Web's own connection. Applied here:

  • Presence in one place, as WhatsApp's chat header carries it ("online", otherwise "last seen" with a time; https://faq.whatsapp.com/419827870318306): the machine button says nothing extra while connected, says "offline, last seen" with a time when away, and shows "connecting" only after a grace period.
  • No flicker. Through a reconnect the page keeps the last known state until it has a fresh one, however long that takes, never flashing "No machine is connected" or another machine's name.
  • The name comes from the document's own machine, the one the button names, and when it is not known yet, nothing is said.
  • The person's own connection is one notice, like WhatsApp Web's "Computer not connected" (https://faq.whatsapp.com/852892549070029) or Slack's one grey banner (https://slack.com/help/articles/205138367). Nothing else repeats it.
  • Anything tied to a state clears when the state clears, with no "Back online": Chat no longer says the machine reconnected, and a research request's "Reconnecting" line and a cut-off send's "Message not sent" line go with the connection that caused them, while a server's refusal of a send and a machine's state wait out the offline notice and come back as they were.
  • The composer's top edge only for a person with no machine button, because without it they have nowhere else to read the state, and not beside the offline notice, since the page cannot hear the machine then.

Not in this change: the first rule of the standard, that a sent message is never refused because the other side is away but waits with a delivery mark (https://faq.whatsapp.com/5155925751185676). That is the queued-message work on branch stage1-invisible-deploys, which this change does not pre-empt.

The Decisions tab: kept, against the research

The brief asked for the tab to be named by what Linear, Notion, GitHub and Figma call open questions awaiting a person's answer. The research found Questions, and the tab and its cards were renamed to it. The user then ruled that Decision and Decisions stay Chopin's product noun (tab, cards, "Save decision", "Decision changed", counts, accessible names, docs, tests). The rename was undone, and this branch has no Decision to Question rename. The research stands here as a record, overruled:

"Needs input" is a state, and Chopin's tab also holds answered cards, so "Questions" was the noun all four share. Chopin keeps Decision by choice. What follows from it is in the pattern: Decisions is banned only inside machine and connection status text, where it tells a person nothing about the machine ("open questions are kept", "waiting for people"), and is Chopin's word everywhere else.

Strings left alone, and why

  • Tool names, error codes (planner-not-attached, ...), wire kinds and fields, routes, URLs, stored data, CSS classes, data-*, the dialect's Decision component, log lines.
  • What the agent reads: prompts, tool descriptions and results, MCP tool text, job and decision follow-up instructions, the transcript it is shown, task instructions.
  • Operator and CLI output: startup lines, HARNESS= messages, chopin connect flags and help, docs/chopin-cli*.md, the protocol-violation and state errors a client's own bug raises, the text told to a machine's connector when Chopin stops running a document there (printed in the owner's terminal), and the terminal setup guide behind "Setup guide".
  • Background-job failure reasons, and "held for review" in the message-analysis overview, which only a development diagnostic (VITE_MESSAGE_ANALYSIS) shows.
  • "Decision" as the product's word outside machine and connection text, including the investigation's own "Record decision" form, the device authorization's "Could not save your decision" and the sign-in page's plain-English "decision".
  • The diagram gallery's wireframe sample (sample content in a development gallery) and e2e/source/*.native.ts (updated by search; no configuration here runs them).

Screenshots

Desktop and a 390 px phone for every changed surface, with each surface's shots opened and checked against apps/web/DESIGN.md (this round: the machine button after the move to Box C, the offline notice with the machine's state said before it, and the run card beside the offline notice, at both widths; the others at least for the files named first in each line, in the earlier commits). Not committed. Taken from the built client with CHOPIN_SHOTS, plus the design audit page. The empty Chat's, the busy labels' and the Machines page's "Can reply" shots come from temporary browser specs that are not part of this change, because no committed spec photographs those states; no defect was found.

  • Offline notice alone, and the replies-off strip online: agent-off-desktop-offline.png, agent-off-desktop-online.png, agent-off-phone-offline.png, agent-off-phone-online.png.
  • The machine button's states and menus: planner-starting-*, planner-connecting-after-grace-*, planner-names-own-machine-*, planner-names-no-machine-*, planner-failing-*, planner-none-button-*, planner-attached-*, each as -desktop.png and -phone.png, and for last seen machine-offline-last-seen-desktop.png and machine-offline-last-seen-phone.png.
  • The composer's top edge for a person with no machine button: planner-starting-teammate-*, planner-failing-teammate-*, planner-none-*, link-drop-disconnected-desktop.png, link-drop-disconnected-chat-desktop.png, link-drop-disconnected-phone.png; and the same person's page while offline, with the one notice over a bare composer, and once the connection is back, for each state the strip tells: no-button-{connecting,failing,away}-{offline,online}-{desktop,phone}.png.
  • A disconnected run card's note: the link-drop-disconnected-* files above; and the same run while the connection is lost (the one notice, the card reading as last reported with a plain Pause, no note) and once it is back (the words returned): run-card-offline-desktop.png, run-card-offline-phone.png, run-card-online-desktop.png, run-card-online-phone.png.
  • The empty Chat's hint: empty-chat-hint-desktop.png, empty-chat-hint-phone.png.
  • Busy labels: busy-labels-open-desktop.png, busy-labels-closed-desktop.png, busy-labels-document-phone.png (the Chat tab's spinner and its accessible name "Chat, Chopin working").
  • Machines page: can-reply-machines-table.png ("Can reply" under an agent), can-reply-phone-machine-detail.png ("GitHub Copilot CLI, can reply" and the sharing sentence), add-server-settings.png (the sharing sentence in the add-machine page).
  • The machine button through a long none, and with a stale list: planner-slow-return-desktop.png, planner-slow-return-phone.png (six seconds after a restart, nothing new on the page), planner-stale-list-desktop.png, planner-stale-list-phone.png, and with a list that still describes another of the person's own machines, planner-stale-own-machine-desktop.png, planner-stale-own-machine-phone.png, and with a list read before the socket's word, planner-slow-list-desktop.png, planner-slow-list-phone.png (the button reading only where the document runs while the newer read is held back, with no state), and after the document moved from Box B, where the Planner had dropped, to Box C: planner-after-move-grace-desktop.png, planner-after-move-grace-phone.png (the button reading only "Runs on Box C", with its own green dot, while the machine being connected to waits out its grace, taken by a temporary copy of the committed case with one added screenshot step) and planner-settled-after-move-desktop.png, planner-settled-after-move-phone.png (the spinner once it has lasted).
  • Chat and the run card through a dropped connection, without the removed lines: link-drop-connected-desktop.png, link-drop-disconnected-desktop.png, link-drop-disconnected-chat-desktop.png, link-drop-disconnected-phone.png, link-drop-reconnected-desktop.png, link-drop-reconnected-chat-desktop.png, link-drop-reconnected-phone.png (the run card and the composer's strip say it, Chat does not, and nothing confirms the return).
  • The page's one offline notice over a bare composer, with the machine's state said just before the connection was lost and nothing of it drawn now: planner-silent-offline-desktop.png, planner-silent-offline-phone.png.
  • A /research draft while offline, with the page's one notice and a bare composer: research-offline-desktop-offline.png, research-offline-phone-offline.png.
  • A send the connection cut off, with the page's one notice and a bare composer holding the draft, and the same once the connection is back: cut-send-desktop-offline.png, cut-send-desktop-online.png, cut-send-phone-offline.png, cut-send-phone-online.png; and a server's refusal strip, which gives way to the page's one notice during a loss and is back, unchanged, with the connection: refused-send-desktop-online.png, refused-send-desktop-offline.png, refused-send-phone-online.png, refused-send-phone-offline.png.
  • Build plan's setup popover ("Connect a machine", "Open your agent and connect this document."): the production popover opened on the audit's build blockers plate, audit-build-blockers-setup-desktop.png and audit-build-blockers-setup-phone.png. The browser fixtures have no document that offers it, so there is no shot of it in the running app.
  • Design audit plates (one file per viewport-sized step down each plate): audit-chat-*, audit-workflow-runs-*, audit-navigation-*, audit-tabs-*, audit-typography-*, audit-icons-*, audit-loading-* and audit-build-blockers-*, each -desktop-N.png and -phone-N.png.

Verification

On the final head (tree 07fef13ce254), with the client rebuilt from it. Every browser run cited for a claim that a change works was made on that head with CI=1, so a port clash fails instead of reusing a server of another checkout. The red-first observations, also made with CI=1 and --retries=0 but on a client built from the code before a change, show only that a case fails for the reason it describes; they are labeled as such and are not evidence that anything works.

  • bun run fix: no changes. bun run types: passed. bun run ci: passed (dprint, oxlint with 72 warnings and 0 errors, as on the base, tokens, design contract with 330 exceptions, design record, Impeccable with no new findings).
  • GIT_CONFIG_GLOBAL=/dev/null bun test apps/web packages scripts e2e/focus-color.test.ts apps/server: 6999 passed, 9 skipped, 0 failed.
  • CI=1 GIT_CONFIG_GLOBAL=/dev/null bun run e2e connection chat-mentions chat-phone chat-composer planner-attachment planner-picker machines machine-choice restart responsive-activity setup-agents hosted smoke implementation-launcher decision-evidence chat-references on the rebuilt client, once: 228 passed, 1 skipped (the live coding-agent build, which needs a live agent), 0 failed and 2 flaky that passed on retry, machine-choice "populated sidebar Default machine fits ..." at 1862x1039 and at 1280x600, each on a 10 s timeout waiting for the new project's link in the sidebar while the suite ran on 128 workers. Re-run alone with --retries=0 on the same client, the 10 cases that title matches passed 10 of 10.
  • bun run design:behavior (CI=1): 22 passed on the first run (no cold dev-server miss).
  • Initial JavaScript, against apps/web/bundle-budget.ts: 258,951 B raw and 81,994 B gzip in a local build; in docker build --no-cache --target build (the Dockerfile's client stage, Linux gzip) 258,947 B raw and 82,779 B gzip, against limits of 259,500 B and 83,000 B (221 B of gzip headroom). The limit is unchanged, and the code of the last commits lands in the lazily loaded workspace chunk: the entry chunk is the same size as before them. The temporary image was removed.
  • Red first, each seen failing for the reason it describes with CI=1 and --retries=0 on a client built from the code before the change (these are the observations of the failing case, not claims that the change works): the machine strip beside the offline notice (browser, desktop and 390 px phone: the composer's strip was drawn beside the notice, "Expected 0, Received 1"; the unit case received the strip where none was expected), a refusal beside the notice (browser: two alerts where one was expected; the unit case received the refusal while the notice showed), a stale list naming another of the person's own machines (browser: "Runs on Box B. Connecting to Box A" where "Runs on Box B" was expected; the unit case received "Box A" where null was expected), a list read before the socket's word (browser, desktop and 390 px phone: "Runs on Box B. Box B is offline. Your work is kept." where the status was not to name Box B from that list; the unit case received "Box B" where null was expected), a run card beside the offline notice (browser, desktop and 390 px phone: the card still read "disconnected" under the one alert, so "plan-review, running" was not found; the unit case found "disconnected" and the note on an offline card), and a status held for an older word (browser, desktop and 390 px phone: "Runs on Box B. Connecting to Box B" where "Runs on Box B" was expected after the document moved to Box C). Taking the hold away alone failed the existing case "a Planner its machine keeps failing to start ... is announced once" with "Connecting to studio" said twice, and a client that withheld the status but forgot what it had shown and kept the live region's text through the gap failed that case and the case for a list naming a teammate's machine, which is why the announcement rule, the remembered state and the silent gap exist. A status redrawn after the gap (browser, desktop and 390 px phone, on the client of the commit before: after the list named Box C the button read "Runs on Box C. Box B is offline. Your work is kept." for the 1.5 s of the grace, where nothing naming Box B was to be read; the unit cases, run against the earlier behaviour, received the disconnected state where nothing was to be drawn). The live region beside the offline notice (browser, desktop and phone, on the client of the commit before; all six cases failed: the region still said "@mona's machine is offline. Your work is kept." beside the notice in the run-card case and "Box B is offline. Your work is kept." in the case of a state that had dropped, and in the case of a state released after the socket dropped it said "Connecting to Box C" 1,550 ms after the read answered, beside the notice, with the machine button gone; the unit cases received the sentence in the region where it was to be empty).
  • One finding about the tests themselves: an existing browser case for a reconnect said its states to a socket that had already closed, because its helper counted every earlier load of the page as a reconnection, so it could never have shown a flicker. The helper now waits for the new connection's greeting, and that case runs as a real reconnect. An existing case, "a disconnect rejects a pending send", asserted the very strip this change removes; it now asserts there is none, and its reduced-motion check moved to a case that refuses the send. And the last-seen case ended by stopping its route while a read was still in flight, which made it fail once in teardown with "Route is already handled"; it now waits for its routes, as the two cases before it do. The run-card case's list of words about the machine also left out "is offline", "Connecting", "Your work is kept" and "No machine", and it never read the live region, which is how a stale sentence there went unseen; one list now covers the page's text, labels, titles, tooltips and live regions.
  • Not run: bun run design:browser (Linux visual baselines are not regenerated; captions this changes may be in the Conversation, Workflow runs, navigation and view-tabs baselines), the full runtime Docker image, bun run test:postgres (the shared attachment contract's expected texts are updated and pass against the memory adapter), the conversation-plan browser specs (their own Docker configuration), the rest of the browser suite, and GitHub's checks (nothing was pushed).

Residual risks and deferred

  • The red refusal strip "Still connecting to @mona's machine. Try again in a moment." still appears after a message sent to a machine being connected to, and stays after the machine connects; the 30 s wait before it stays too. They belong to the Stage 1 run on branch stage1-invisible-deploys, which removes that refusal and replaces it with queued messages that carry delivery marks. This change leaves the strip's words, its wait, its clearing and the send path unchanged; it only does not draw the strip while the offline notice shows.
  • A machine removed while the page was disconnected is told none first by the new server, which cannot be told from its placeholder, so the page keeps its last known state until another state arrives or it reloads. The machine list the page polls is not held across a restart: it is a fresh answer from the new server and reads as offline until the machine returns.
  • "Last seen" is for a machine that is offline. A dropped agent on a machine that stays connected has no timestamp on the wire's disconnected state, so it still says "Your work is kept."; adding one is a protocol change this work does not make.
  • The design audit's presence plate shows the offline specimen with "Your work is kept." and has no "Last seen" specimen of its own; the last-seen words are covered by the status tests and the browser specs.
  • The link's own keptStop flag (a Stop the client had not confirmed when its link dropped) is now read only by its tests, since the Chat sentence that used it is gone.
  • destinationCue hints are tested but rendered by no component today.
  • A child document open beside its parent still draws its own offline notice (pre-existing, from Quiet the machine status, and make a lost connection calm #792).
  • A document whose agent dropped while its machine is still connected says "Box A is offline. Your work is kept." of the machine, because the wire's disconnected state does not say which of the two dropped. Telling them apart is a protocol change this work does not make.
  • The status names only the machine the button names, so a document whose Planner dropped from a machine the button does not name says nothing of it: its work kept on an earlier machine after the person chose another (the server keeps it there until it ends), or, with no choice, a dropped machine that is offline while Default machine names another or none. For the last the button reads "Runs on: no machine connected" and no "Last seen" is said until the machine is the one the button names again. The page cannot tell that from a stale list, because the wire carries no machine id (closing it needs the id on the wire), so it says nothing there. The same wait, one round trip to the machine list after each word, delays the machine's name on a word by that long, and the status says nothing of the machine until then, however long a failing read takes.
  • A person with no machine button reads machine state above the composer while online, as the user ruled (they have no other place to read it); it is hidden while the offline notice shows and returns with the connection, so the strip is shown online and hidden offline, and a person with the button reads it on the button alone.
  • A send the connection cut off is not sent again by itself: the draft stays in the composer and the person sends it again once the page is back. Sending it for them, with a delivery mark, is the queued-message work on branch stage1-invisible-deploys.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: bun run fix made no changes, bun run types passed, and bun run ci passed on the final head: dprint, oxlint (72 warnings, 0 errors, as on the base), tokens, the design contract (330 exceptions), the design record and Impeccable show no new findings
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test apps/web packages scripts e2e/focus-color.test.ts apps/server passed on the final head: 6999 pass, 9 skip, 0 fail
Assistant-verification: CI=1 GIT_CONFIG_GLOBAL=/dev/null bun run e2e connection chat-mentions chat-phone chat-composer planner-attachment planner-picker machines machine-choice restart responsive-activity setup-agents hosted smoke implementation-launcher decision-evidence chat-references on a client rebuilt from the final head, once: 228 passed, 1 skipped (the live coding-agent build), 0 failed, 2 flaky that passed on retry (machine-choice "populated sidebar Default machine fits ..." at 1862x1039 and 1280x600, each on a 10 s timeout under the suite's load); the 10 cases that title matches passed 10 of 10 with --retries=0 on that client
Assistant-verification: CI=1 bun run design:behavior passed 22 on the final head
Assistant-verification: initial JavaScript measured 258,951 B raw / 81,994 B gzip in a local build and 258,947 B raw / 82,779 B gzip in docker build --no-cache --target build on the final head, against limits of 259,500 B and 83,000 B
Assistant-verification: red first (CI=1, --retries=0, on a client built from the code before each change, showing only that a case fails for the reason it describes): the machine strip beside the offline notice, a refusal beside it, a stale list naming another of the person's own machines, a list read before the socket's word, a run card beside the offline notice, a status held for an older word, a status redrawn after the gap, and the live region beside the offline notice, each in the browser on desktop and a 390 px phone and in a unit case
Assistant-verification: screenshots of the machine button after the document moved to Box C, the offline notice with the machine's state said before it, and the run card with the offline notice and with the connection back, on desktop and a 390 px phone, were retaken on the final head, opened and checked against apps/web/DESIGN.md; no defect found. The earlier commits' shots were opened and checked when those commits were made
Assistant-verification: not run: bun run design:browser, the full runtime Docker image, bun run test:postgres, the conversation-plan browser specs, the rest of the browser suite, and GitHub's checks (nothing was pushed)
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee lavaman131@github.com
Assistant-duration: unmeasured

…ebar's idle dialog warm-up so a page raises no error while the server is away

Two loose ends from review of the quiet restarts: the restart end-to-end case tolerated one console-only error, and a refusal inside the quiet period left its timer running.

The sidebar warms its lazy dialogs and the Search list when the page is idle, by importing `dialog-prefetch`. While no server answers, that import fails and nothing caught it, so the page raised an uncaught `Failed to fetch dynamically imported module` error that nothing on the page showed. The warm-up is now caught where it is started. `prefetchDialogs` returns one promise for the three dialog chunks and the Search list, so a chunk that fails after the module has loaded is caught in the same place instead of surfacing on its own. The restart case in `e2e/restart.e2e.ts` no longer allows the error: the two cases that read page errors now assert there are none, and the carve-out for that chunk is gone. The reviewed hash of `project-sidebar.tsx` in the design contract is renewed; the one dynamic owner in it, `NavigationIcon`'s class, is untouched.

A restart's quiet period lasts `RESTART_QUIET_MS` after a close with 1012. If the probe that asks why an attempt failed got a refusal inside it (a 403 that Chopin marked as its own), the status became denied at once, but the quiet timer still raised `onOutage` when it ran out and `restarting` stayed true. Nothing was shown, since a denial is not an outage, but whatever read `restarting` kept its longer grace for a connection that was never coming back. A refusal now clears the quiet timer and `restarting`, so no outage is raised for it and a later loss counts as any other. The new case in `wire-quiet.test.ts` fails on the unfixed code twice over: `restarting` stays true, and the outage arrives at 5 s.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: bun test apps/web/src/wire-quiet.test.ts failed before the fix and passed after: the new refusal case saw restarting stay true, and, with that assertion removed, saw {failures: 0} raised at 5 s; bun test wire-quiet, wire and wire-loss then passed 52 of 52
Assistant-verification: bun run e2e e2e/restart.e2e.ts -g "a restart under an open tab" failed before the fix with the page error "Failed to fetch dynamically imported module .../assets/dialog-prefetch-*.js" under the new assertion of no page errors, and the whole file passed 8 of 8 after it, on a client rebuilt from this tree
Assistant-verification: bun run types passed: every package and the e2e project type-check
Assistant-verification: bun run fix then bun run ci passed: 72 lint warnings, as on the base, 0 errors; tokens, the design contract with the renewed project-sidebar.tsx hash, the design record and Impeccable show no new findings
Assistant-verification: bun run build, inside that e2e run, gave initial JavaScript of 258,951 B raw / 81,995 B gzip, against limits of 259,500 and 83,000 B (the base measures 258,951 B raw / 81,980 B gzip on this machine)
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
Co-authored-by: Alex Lavaee <lavaman131@github.com>
… own, say nothing of a machine being connected to until it has lasted a moment, and say when an offline machine was last seen

Measured on a hosted deployment, a document with a remote Planner showed a broken state for minutes after every restart: the machine button read "No machine is connected" for a moment, then named a teammate's machine, a machine the document does not run on, and only about a second and a half later its own. These three changes keep that from being shown, in the one place machine state lives, the machine button (and, for a person with no button, the composer's top edge, as before).

The name. `plannerRunsOn` looked the machine the server said the Planner was starting on up in the person's whole list, a teammate's shared machines included, so a machine the document was not set to run on was named whenever the two snapshots disagreed. It now names a machine of the person's own, or the one the document's button names, and never another teammate's: that is "@login's machine", as the status copy has always said. A new `plannerMachineName` says nothing at all (`null`) while the person's machines are still loading, and while the list names a teammate's machine for a Planner the person's own account is the one starting, which is the two snapshots disagreeing: the status waits until the list agrees. A person with no machines of their own, or an agent client of their own, is still told by login.

The moment. A state that is only a restart's passing, a machine being connected to or none yet, is now said only once it has lasted as long as a lost connection does before the page speaks (`CONNECTION_GRACE`, 1.5 s). The last known state stays until then, so a restart that passes through no machine, then a machine being connected to, then the Planner, in a little over a second, shows nothing and announces nothing. What a person can act on (a machine that cannot be connected to, a machine away) is said at once, and every state clearing goes at once with nothing to say it did. `useSettledPlannerStatus` holds this; its decision is two pure functions, `plannerGrace` and `plannerShownAtOnce`.

The time. A machine that is away says when it was last seen, as a contact's last seen is said: "Build box is offline. Last seen 10:31 AM.", with the date on another day, from the machine's own last-seen time while it is offline. Where the machine is connected and only the Planner dropped, or no time is known, it still says "Your work is kept." The words are plain and `status-copy.test.tsx` walks them, "not known yet" and the time included.

Left out on purpose, as the queued-message work replaces them: the 30 s that a message to a machine still being connected to waits before it is refused, and the red refusal strip above the composer staying after the machine connects. Nothing in the send path, the refusal or the transcript is touched. The existing check that a refused message keeps its error line after the machine attaches is as it was.

The browser cases drive the real page against a real server, with the page's socket and the machine list carried by the test where a restart's timing has to be exact: the states a restart passes through are never said (a 50 ms sampler of the button's name, and the live region), a machine still being connected to is said only after the moment and clears on its own, an offline machine says when it was last seen in its button, its menu and the live region, and the status never names a machine that is not the document's own, on desktop and a 390 px phone each.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: bun test apps/web/src/machine-choice.test.ts and apps/web/src/chat/planner-status.test.ts failed before the fix (the list lent a teammate's machine its name: Received "desk"; a status for an unknown machine was said as a login) and passed after; bun test apps/web packages scripts e2e/focus-color.test.ts passed on the final tree: 2706 pass, 3 skip, 0 fail, with the plain-words test extended to "not known yet" and the last-seen time
Assistant-verification: bun run e2e e2e/planner-picker.e2e.ts on a client built before the fix failed the name case with the button reading "Runs on: no machine connected. Connecting to Build box", and, with the settling and the last-seen time taken out again, failed the restart case with twenty samples of "Runs on studio. No machine is connected." and the last-seen case with "studio is offline. Your work is kept."; all passed with them in, on desktop and phone
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun run e2e planner-picker planner-attachment machine-choice machines connection chat-composer chat-commands chat-mentions chat-references chat-phone chat-anchoring planner-decisions run-card hosted restart implementation-launcher passed on a client built from this tree: 174 passed, 1 skipped, 0 failed, none retried
Assistant-verification: bun run types passed; bun run fix then bun run ci passed: 72 lint warnings as on the base, 0 errors, and the design contract, design record and Impeccable show no new findings
Assistant-verification: bun run build, inside that e2e run, gave initial JavaScript of 258,951 B raw / 81,985 B gzip on this machine, against limits of 259,500 and 83,000 B (the base measures 81,980 B gzip here and 82,789 B in the container's build, about 810 B more)
Assistant-verification: screenshot review passed: the machine button with the status waiting, then connecting, and offline with its menu and sheet showing "Last seen", on desktop and a 390 px phone, opened and checked against DESIGN.md
Assistant-verification: not run: design:browser, the Docker image build, the full bun test, bun run test:postgres, and GitHub's checks (nothing was pushed)
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…page's offline notice shows, tell it in plain words the rest of the time, and keep the outage specs honest about the composer's size

A lost connection is one notice, the page's. On a server whose agent is off, the composer's own info strip ("Chopin unavailable") sat above the composer beside it, so a person saw two strips about "unavailable" at once and the composer was a different size online and offline.

The strip is not about the connection, so it is not removed: a document whose agent is off while the page is online is still told so, now in plain words that name no system ("Replies are off on this server."), and the line after a message addressed to Chopin says the same ("Replies are off on this server. Your message went to the people here only."). While the page's offline notice is up, which is exactly when `useOfflineNotice` says it is, the composer says nothing of availability, and the strip returns by itself when the connection does, with nothing to say it did. `RoomWorkspace` passes that state to `Chat` as `offline`; `repliesOffNotice(offline)` is the one place it is decided.

What is left in the composer while offline is a failed send's own strip, and the research request's "Reconnecting" line, which answer something the person just did rather than describe the connection. A person with a remote machine's state in the strip keeps it: that is the machine's state, not the connection's, and it is the one carrier for a person with no machine button.

The two outage specs that compare the composer's box before and after (desktop and phone) ran against a server whose agent is off, so the always-present strip hid exactly this: they now turn the agent on, and would fail on any strip the connection added. New specs, on desktop and a 390 px phone, show the page's one notice with nothing else in Chat that is an alert, a status, a connection word or an availability word, the strip's plain words before, and the same words back after.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: bun run e2e connection -g "one notice" on a client built before the fix failed both new specs for the right reason: the offline notice showed and the composer's strip still showed ("Chopin unavailable", expected 0 status regions, received 1), on desktop and on a phone
Assistant-verification: bun test apps/web passed: 991 pass, 2 skip, 0 fail, with repliesOffNotice and the strip's words added to status-copy.test.tsx's plain-words walk
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun run e2e connection chat-mentions chat-phone chat-composer passed on a client built from this tree: 34 passed, 0 failed, none retried
Assistant-verification: bun run types passed: every package and the e2e project; dprint check passed and oxlint reported 0 errors
Assistant-verification: screenshot review passed: the phone's offline notice with a bare composer under it (CHOPIN_SHOTS=/tmp/flakes/b-shots), opened and checked against DESIGN.md
Assistant-verification: not run: bun run ci as a whole (the design-contract step fails today only on apps/web/src/chat/composer.tsx, a sibling change's uncommitted file; none of this commit's files), the Docker image build, bun run test:postgres, and GitHub's checks (nothing was pushed)
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…e, and leave the composer's top edge for a person who has no machine button

Machine state lives in one place, as a contact's does in a chat: the machine button, its name and the top of its menu. The composer's top edge used to repeat one state for a person who has the button, a machine that cannot be connected to, as a warning with a link, while the button already carried its reason and the same link. With the button, `plannerPlacement` now leaves the composer alone for every state, as it always did for the rest.

The strip stays, and only, for a person who has no machine button: no machine paired or shared, a title bar that does not show one, or a phone whose top bar an open child has taken. They have nowhere else to read the state, so the strip carries every state for them, and the reason for a machine that cannot be connected to is drawn in full beneath its sentence, exactly as the machine gave it. `PlannerNotice.showDetail`, which said whether those lines were drawn or hidden for a screen reader, was only ever false for the person who now has no strip, so it is gone, and the composer draws its lines. The page's one live region still says each state once, so nothing is lost to a screen reader. The design audit's conversation plate drops its "with the machine button" specimen, which can no longer show a strip, and keeps the no-button ones.

A browser case now closes the page's own connection as a restart does (1012), lets it reconnect, and has the new server's first words arrive as a deploy gives them (no machine, a machine being connected to, the Planner back), sampling the machine button every 50 ms: the last known state, a healthy Planner that says nothing, stands throughout, the page says no machine and no connecting, raises no offline notice, and announces nothing, on desktop and a 390 px phone. The existing cases for a machine that keeps failing to start now assert that the composer's top edge is left alone for the person with the button, and still carries everything for the one without. docs describe both places.

The reviewed hash of `composer.tsx` in the design contract is renewed: its dynamic classes are the shared presence hook's finite state classes, untouched.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: bun test planner-status failed before the change on the old placement (a strip for a machine that cannot be connected to, with the button) and passed after; bun test apps/web/src/chat, status-copy, design-audit, machine-choice and scripts passed on this tree: 446 pass, 1 skip, 0 fail
Assistant-verification: bun run e2e planner-picker -g "through a reconnect" on a client with the settling of the machine state taken out failed on desktop and phone with the states a restart passes through in the button's name, and passed with it in, on desktop and phone
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun run e2e planner-picker planner-attachment machine-choice machines on a client built from this tree: 76 passed, 1 failed (machine-choice "machine menus fit after creating projects" at 1920x1080: the Add project dialog was still visible after Escape; 6 of 6 repeats of that case passed on the same build, so one flake, untouched by this change), none retried
Assistant-verification: bun run types passed; bun run fix then bun run ci passed: 72 lint warnings as on the base, 0 errors, design contract with the renewed composer.tsx hash (330 exceptions), design record and Impeccable show no new findings
Assistant-verification: bun run build, inside that e2e run, gave initial JavaScript of 258,951 B raw / 81,984 B gzip on this machine, against limits of 259,500 and 83,000 B
Assistant-verification: not run: design:browser, the Docker image build, the full bun test, bun run test:postgres, and GitHub's checks (nothing was pushed)
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…the browser, in plain words: Chopin and the machine where they said the Planner, and no Decisions in what they say of a machine's connection

The standard is #792's: no Planner, attach or detach, held, Decisions or @chopin in what a person reads. #792 applied it to every status, strip and refusal it wrote and listed these as left alone. This is the server's half of that list, and the lines the same code paths bring with them.

Chat's system lines now name the agent as Chopin, and the machine when the machine is what happened. A Stop says "@mona stopped Chopin and paused its workflows.", "@mona asked Chopin to stop. Its machine is disconnected, so its workflows will pause when it reconnects." and "@mona resumed Chopin's workflows."; a pause held for the machine's return says "It will pause when the machine reconnects."; a dropped connection says "The machine disconnected. Its workflows and open questions are kept, answers will reach it when it reconnects, ..." and "The machine reconnected and carries on."; a reply cut off by the drop says "The machine's connection dropped during this reply. Its workflows and open questions are kept; ask again once it has reconnected." Those two lines are about a machine's connection, where Decisions is jargon (#792's rule for that text), so they say questions; everything else keeps Decision as the product's word. No meaning, order or condition changed.

The same turn's failures reach Chat as a line too, so they say it the same way: "Chopin's reply failed. Try again.", "Chopin could not verify its Chat response. Try again.", "Chopin could not move its questions into inline decisions. Try again.", "Chopin's owner is unavailable", "Chopin's session is unavailable", "Chopin's session changed while opening", and the tool approval and boundary lines. A credential refresh says "Chopin's session was restarted".

Where a composer error strip shows a refused send, it says "the reply queue is full", and a refused task draft "Chopin is not running". Build matched the second of these by its old text, so it matches either now (a browser and a server are not always the same version for a minute during a deploy) and still gives its own plain copy. What a decision card says back when an action is refused already spoke of the decision ("This decision is no longer open", "Only a settled decision has a why") and is unchanged; its tests now apply the same check so a Planner term cannot come back into it.

Left alone on purpose, and why: tool names, error codes (`planner-not-attached` and the rest), wire kinds and fields, routes, stored data and log lines; what the agent reads (its prompts, tool descriptions and tool results, the transcript it is shown, the MCP tool text, the jobs' and the decision follow-up's instructions); operator output (config.ts's startup lines, `HARNESS=` messages); the reasons a background job fails, which only the developer diagnostics popover (`VITE_MESSAGE_ANALYSIS`) shows; and the text told to a machine's connector when it stops running a document (`experiments/routes.ts`), which the connector prints in the owner's terminal.

The banned-term pattern moves to `@chopin/protocol/plain-words` as two: `INTERNAL` (the Planner, attach and detach in every form, held, `@chopin`) for everything a person reads, and `INTERNAL_IN_STATUS`, which adds `\bdecisions?\b`, for text about a machine and its connection, where Decision tells a person nothing about the machine. Decision and Decisions are Chopin's word for its cards and its tab and are not banned anywhere else. `status-copy.test.tsx` imports both and its self-test shows that the first flags "Planner" and not "Decisions", and the second flags both. `testing/plain-words.ts` gives the server tests `expectPlain` and `expectPlainLines` (for a line anyone reads), and `expectPlainStatus` and `expectPlainStatusLines` (for the Chat lines about a machine and its connection), which the Chat, remote-Planner, task-draft, queue, reply-policy, question and why tests now apply to the lines they already assert exactly, so a new system line cannot bring a term back. No second checker.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test apps/server passed on this tree: 4263 pass, 6 skip, 0 fail, with the Chat, remote-Planner, task-draft, queue, reply-policy, question and why tests applying expectPlain, expectPlainLines, expectPlainStatus and expectPlainStatusLines to the lines and refusals they already assert exactly
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test apps/web packages scripts e2e/focus-color.test.ts passed: 2708 pass, 3 skip, 0 fail, with Build's refusals extended to the new server texts and the self-test of both patterns (the first flags "Planner" and not "Decisions", the second flags both)
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun run e2e planner-attachment passed on a client built from this tree: 8 passed, 0 failed, including the dropped-link case that reads the new Chat lines
Assistant-verification: bun run types passed: every package and the e2e project; bun run ci passed: dprint, oxlint (72 warnings as on the base, 0 errors), tokens, the design contract (330 exceptions), the design record and Impeccable show no new findings
Assistant-verification: not run: bun run test:postgres (the shared attachment contract is updated in the next commit and passes against the memory adapter), the Docker image build, and GitHub's checks (nothing was pushed)
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
… a machine and a connection rather than a Planner that is attached, detached or held

A person can read what the agent connection says. A paired machine's backend prints a refusal, the machine reports the last line it printed as the reason its document "can't connect" (shown exactly as the machine gave it, on the machine button, its menu and, for a person with no button, the composer's strip), and a failed or dropped turn reaches Chat as a line. These still said the Planner was attached, detached or held, or released, and that "a document has one Planner at a time".

The refusals keep their codes, close codes and conditions (`repository-forbidden`, `sign-in-required`, `access-revoked`, `planner-attached`, `document-archived`) and change only the words:

- "Connecting to this document needs push or administration access to owner/repo." (was "Attaching as this document's Planner needs ...")
- "mona is connected to this document. A document has one connection at a time.", "... and its connection dropped. Its work is kept until it reconnects. ...", and "mona disconnected from this document and keeps it until <time> unless they connect again. ..." (was "attached as this document's Planner", "detached from this document's Planner and holds it until ... unless they reattach", "one Planner at a time")
- "This document's connection was released, so mona is no longer connected. Connect again to resume.", "A repository writer reset this document's connection, ...", "This document was archived or deleted, so its connection was released."
- "Sign in to Chopin at <origin> in a browser as mona, then connect again. Repository tools act with that sign-in.", the signed-out and paired-machine-ended variants ("... that connected mona to this document ended, so the connection was released ... then connect again"; "The paired machine ... that connected mona to this document was disconnected from Chopin or must be paired again, ...").
- "This credential was issued for another document." and "The document's owner changed while connecting."

The turn failures say the machine: "The machine did not start the session.", "The machine refused the session: <why>", "The machine disconnected during the turn.", "The machine stopped the turn.", "The machine's turn failed.", "The machine can no longer act for this document.", "The machine is disconnected, so its runs cannot be resumed yet.", "The machine refused.", "The machine did not answer <action>.", "The machine redefined Chopin's <tools>.", and "No machine is connected to this document." The tool boundary line says "Chopin's tool boundary failure: <tool>", as Chat's does.

Left alone: what a client author reads in the protocol, which is the protocol's own vocabulary (`attach`, `detach`, `attached`, the error codes, "Send attach first ...", "This link is already attached", the state errors a client's bug raises); the `HARNESS=remote` operator messages; and the reasons told to a machine's connector when Chopin stops running a document there, which the connector prints in the owner's terminal. `docs/agent-connection.md` keeps `attach` as the name of the message and quotes the new refusal text; every refusal a link test asserts now also has to pass the shared plain-words check for text about a machine and its connection (`expectPlainStatus`, which bans Decisions too), as the storage contract's refusals are asserted to say machine words.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test apps/server passed on this tree: 4263 pass, 6 skip, 0 fail, with every refusal the link tests assert (link.test.ts, machine-link.test.ts) and the shared attachment contract carrying the new words, and the link tests' refusals passing the shared plain-words check for text about a machine
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun run e2e planner-attachment passed on a client built from this tree: 8 passed, 0 failed
Assistant-verification: bun run types passed: every package and the e2e project; bun run ci passed: dprint, oxlint (72 warnings as on the base, 0 errors), tokens, the design contract (330 exceptions), the design record and Impeccable show no new findings
Assistant-verification: not run: bun run test:postgres (the attachment contract this changes is shared with the PostgreSQL adapter and passes against the memory one), the Docker image build, and GitHub's checks (nothing was pushed)
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…cards, the composer's picker hints, the agent's face and the background jobs

Another of #792's left-alone lists, in the page this time. The agent is Chopin everywhere else a person meets it (its messages are signed Chopin, "Chopin is working on the request", "Chopin couldn't reply"), and the machine it runs on is the machine, so these now say the same:

- Busy labels: the document's status says "Chopin is working"; the Chat tab, its accessible name and the Chat toggle say "Chat, Chopin working" and "Chopin working"; a document being written says "Document, Chopin writing".
- Run cards: a run that lost its machine says "Last reported running. It carries on when the machine reconnects.", "Last reported waiting for people. Answers reach it when the machine reconnects." or "Last reported paused. Resume it once the machine reconnects."; its Pause control is named "Pause <run> when the machine reconnects". That note is text about a machine's connection, where Decisions is jargon (#792's rule), so a run that was waiting on Decisions says it waited for people there; the card's own status and button keep the product's word ("waiting on Decisions", "Waiting on 2 Decisions"). `onShowDecisions` and the rest of the code keep their names.
- The composer's picker hints say "Sends to Chopin, which will reply" and "Sent to people here. Type @ to ask Chopin". "Room" is Chopin's word for the live server; a person never reads it, and the hint no longer prints `@chopin`: it teaches the `@` that opens the mention list, where the person picks Chopin and the mention is typed for them. The empty Chat says the same: "Use @ to ask Chopin, or # to point at a document." The `@chopin` mention a person types, the picker's own row and `Chat.Send.to = "planner"` are untouched.
- The agent's face is named Chopin (accessible name and tooltip), as are the name on a suggestion in a card's evidence ("Chopin suggested", and the author of a quote) and a card's source tooltip.
- The jobs panel of message analysis, a development diagnostic that shows only with `VITE_MESSAGE_ANALYSIS`, says "Background jobs" for what it called "Planner jobs", and "The background job could not be retried. Try again when connected." is one constant, `JOB_RETRY_FAILED`, in the panel and where the retry is made, so the two cannot differ. A card that nobody answered says "Nobody answered this question, so Chopin used its best judgement for this decision."

`status-copy.test.tsx` walks every one of them with the shared banned-term pattern, as the leak check always did: every destination's accessible name busy or not, unread or not, the document's status while Chopin works, the agent's face, the picker hints and the empty Chat as the transcript draws it, every status of a workflow run connected and away with its note and its controls as the card draws them, the evidence popover and the jobs panel. The words a person reads anywhere are held to `INTERNAL` (the Planner, attach and detach, held, `@chopin`), and a disconnected run's note, which is about a machine's connection, to `INTERNAL_IN_STATUS`, which also bans Decisions. The design contract's reviewed hashes of `workspace.tsx`, `markers.tsx`, `face.tsx` and `question-view.tsx` are renewed after reading each diff: all four edits are literal words, and none touches a class or a colour.

Left alone, and why: the dialect's `Decision` component and every code name, wire kind and stored field; `Chat.Send.to`; the "Chopin" persona strings that already read well; and the diagram gallery's wireframe sample, which is sample content in a development gallery. The conversation-plan browser specs that quote the jobs panel are updated, but that suite runs in its own Docker configuration, which this run did not start.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test apps/web packages scripts e2e/focus-color.test.ts passed on this tree: 2711 pass, 3 skip, 0 fail, with status-copy.test.tsx's new walks of the busy labels, the agent's face, the hints, the empty Chat, every run status connected and away, the evidence popover and the jobs panel
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun run e2e responsive-activity planner-attachment decision-evidence decision-prose smoke chat-mentions passed on a client built from this tree: 42 passed, 0 failed
Assistant-verification: bun run types passed: every package and the e2e project; bun run ci passed: dprint, oxlint (72 warnings as on the base, 0 errors), tokens, the design contract (330 exceptions, the four hashes renewed), the design record and Impeccable show no new findings
Assistant-verification: not run: the conversation-plan browser specs (their own Docker configuration), design:behavior and the audit plates for this change (the plates follow in the docs change), the Docker image build, and GitHub's checks (nothing was pushed)
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…uild plan opens said the Planner: "Connect a machine", "Agent command", and a machine that can reply

The last of #792's left-alone list that a person reads. When no machine can reply, Build plan offers to connect one. Its trigger said "Connect Chopin", its field "Planner command" (with "Command that starts your Planner" in it) and its first line "Open your Planner and attach this document." They now say the machine-first words the rest of that flow already uses ("Connect a machine to this document to build"): the trigger and the loading placeholder in its place say "Connect a machine", the field "Agent command" with "Command that starts your agent", and the first line "Open your agent and connect this document." The four are one constant, `SETUP`, so the popover and the test read the same words, and the design audit's Build blockers plate finds the trigger by its new name. The terminal setup guide behind "Setup guide" is a CLI for a custom backend, and stays as it is.

The Machines page says what a machine does for a person instead of what it runs for Chopin. A machine that can answer in documents says "Can reply" under its agent in the table and "can reply" in its detail ("GitHub Copilot CLI, can reply"), where it said "Planner" and "runs Planners". The sentence that tells a person what sharing a machine means ("Teammates see this machine ... That document's replies, builds and runs then happen here, as you, with your GitHub account, in repositories you can push to; replies also happen here on Default machine when its creator has no machine of their own. ... Turned off, a teammate's reply or build finishes the work it is on first.") says it the same way on the machine's switch, in the add-machine dialog and on the pairing page.

`status-copy.test.tsx` walks the four setup words, the sharing sentence and the Machines table as it draws a machine that can reply, with the shared banned-term pattern for text about a machine; the leak check's "Connect Chopin" assertion becomes "Connect a machine".

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test apps/web passed: 995 pass, 0 fail, with the setup and the Machines table added to the walk of machine text
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun run e2e connection chat-mentions chat-phone chat-composer planner-attachment responsive-activity decision-evidence decision-prose machines machine-choice planner-picker setup-agents restart hosted smoke implementation-launcher passed on a client built from this tree: 199 passed, 1 skipped, 0 failed, none retried
Assistant-verification: bun run design:behavior passed: 22 passed, including the Build blockers plate that opens the setup by its new name, at two widths
Assistant-verification: bun run types, dprint check and the design contract passed; oxlint reported 0 errors
Assistant-verification: bun run build, inside that e2e run, gave initial JavaScript of 258,951 B raw / 81,990 B gzip on this machine, against limits of 259,500 and 83,000 B (the base measures 81,980 B gzip here, so the container's measure is about 82,800 B)
Assistant-verification: not run: the Docker image build, bun run test:postgres, and GitHub's checks (nothing was pushed)
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
… that changed: Chopin and the machine where they said the Planner

#792 left two design audit captions saying Planner, and the audit's specimen labels and icon names followed the same habit; the plates are the reviewer's view of every surface this series changes, so they carry the same words.

The Conversation plate's description says "Member, Chopin, tool activity, ...", the Workflow runs plate "Workflows Chopin launched: ..., and the runs of a machine whose connection dropped", the navigation plate's specimen "Chat current, Chopin working with 2 unread", the document view's specimen "Chopin writing the document", the typography sample "Several people and Chopin share one document", and the two run-card icons are listed as "Workflow stop" and "Workflow resume" in the icon catalogue (their asset names are unchanged). The Decisions plate, the Decisions tab specimens and every other Decision word are untouched: Decision is Chopin's word for its cards and its tab.

`apps/web/DESIGN.md` says what the words rule covers now: text about a machine and its connection leaves out the Planner, attaching and Decisions, as it did; every other word a person reads leaves out the Planner, attaching, holding and `@chopin`, the busy labels, a run card's notes, the composer's picker hints and every refusal included; and Decision stays Chopin's word for its cards and its tab. `docs/hosted-agent.md` says the Chat line about a dropped link says the machine disconnected. The design contract's reviewed hashes of `controls.tsx`, `icons.tsx` and `foundations.tsx` (a typography producer) are renewed after reading each diff: a caption, a specimen label and an icon's name, no class or size.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: bun run fix then bun run ci passed on this tree: dprint, oxlint (72 warnings as on the base, 0 errors), tokens, the design contract (330 exceptions, three hashes renewed), the design record and Impeccable (no new findings)
Assistant-verification: bun run design:behavior passed on a tree with these plate edits: 22 passed at two widths; screenshots of the plates at 1440 px and 390 px (Conversation, Workflow runs, navigation, the view tabs, typography, icons, loading, and the Build blockers plate with the setup open) were opened and checked against DESIGN.md
Assistant-verification: not run: design:browser (the Linux visual baselines are not regenerated; the captions this changes may be in the Conversation, Workflow runs, navigation and view-tabs baselines), the Docker image build, and GitHub's checks (nothing was pushed)
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…cribes the same account's Planner as the page's own connection, and say nothing while the two disagree

Review found that the machine's name came from the machine list the page polls over HTTP without checking that the list described the same Planner as the page's own, fresher `session:planner` word. A list read before another account's Planner took the document over (a teammate's Box A being started for alice) lent that name to whatever the socket said next: a Planner of bob's that dropped read "Box A is offline. Your work is kept.", one being started "Connecting to Box A", one that cannot start "Can't connect to Box A", none of it true of bob's.

`plannerMachineName` now lends the list's name only when the runner the list describes is the account the socket names: its login, or the viewer's own where the list leaves it empty because the machine is theirs (the server names the owner of a teammate's machine and leaves the viewer's own unnamed), compared without regard to case. Where they differ it says nothing (`null`) for a Planner being started or dropped, as it already does while the machines load, and the status waits until the list agrees; the page reads the list again whenever the socket's word changes. A viewer with no machines of their own, or an agent client of their own, is still told by login, a teammate's machine is still named only when the document runs on it, and a word that names no machine anyway (attached, none, held) is unaffected.

The browser case drives a real page, on desktop and a 390 px phone. The machine list is carried by the test with page.route, as the neighbouring cases do: it still describes casey's Build box being started, which the document runs on. The real server's Planner words are kept from the page (`mute` on `carried`) and the test says the socket's own: bob's Planner dropped, being started, and unable to start, each held for 1.8 s, past the moment a machine being connected to waits. The button's name stays "Runs on Build box", nothing is announced and no strip is drawn; when the list catches up, the machine of the account the socket names is named again. `docs/hosted-agent.md` and the design guide say it.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: bun test apps/web/src/machine-choice.test.ts failed before the fix with Received "Box A" where null was expected (a list of alice's Planner lent its machine's name to bob's dropped Planner), and passed after, with the three probes (dropped, being started, failing) and the viewer's own machine polled with no login
Assistant-verification: bun run e2e planner-picker -g "describes another account's Planner" on a client built before the fix failed on desktop and on a 390 px phone with the button reading "Runs on Build box. Build box is offline. Your work is kept." where "Runs on Build box" was expected, and both passed on the tree of the fourth commit of this series
Assistant-verification: the checks that cover the whole series (bun run fix, bun run types, bun run ci, the unit suites, the browser specs, design:behavior, the container's bundle, screenshots) ran once on the tree of the fourth commit of this series, which includes this commit: see that commit
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
… machine takes to return: the none a new server tells first is its placeholder, not news of the machine

Through a deploy the page said "No machine is connected." after 1.5 s, though a real machine took minutes to return. A server that has just started tells `none` to everyone who connects (first with `session:planner` on open, and again whenever its state is announced) until a machine returns, so the first `none` after a connection comes back is the new server's placeholder, not a fresh word about the machine. The user's rule: through a reconnect or a deploy the page keeps the last known machine state until it has a fresh one, and never flashes "No machine is connected.", an empty state or another machine's name.

The mechanism is the narrowest the wire's existing signals allow, and changes no wire. `RoomWorkspace` already hears `onStatus("reconnecting")` when a connection is lost and every `session:planner` word. Two pure functions in planner-settle, `plannerConnectionLost` and `plannerWordHeard`, keep a three-valued view per socket: `unknown` (nothing told, or the last word was `none`), `known` (the last word was a state) and `returning` (lost after a state, and only `none` heard since). A `none` that leaves the view `returning` is flagged as a placeholder on the attachment, and `useSettledPlannerStatus(status, held)` keeps whatever is shown, in whichever state, while the flag stands, arming no timer; the first word that is not `none` ends it, after which the usual rules hold: a machine being connected to is said once it lasts, and a clearing goes at once. A `none` told after any other state, a machine removed while the page is connected, is still said as before, and a page whose first word is `none`, or whose last word was, says it as it does now. The raw attachment is untouched, so Build plan and the rest still read the real state; only the status is held.

Stated trade-off: a machine removed while the page was disconnected is told `none` first, and cannot be told from the placeholder, so it is not said until another state arrives or the page reloads. The HTTP machine list is not held either: it is a fresh answer from the new server, which reads as offline until the machine returns. Both are left as they are.

The browser case, on desktop and a 390 px phone: the page's socket is closed with 1012 and reconnects, the real server's Planner words are kept from the page, and the test says what a new server says, `none`, for six seconds; then the machine's state arrives (connecting, then back, and the words go), and then a machine removed while the page stays connected is said. A 50 ms sampler of the button's name, the live region, the composer's strip and the offline notice sees only the last known state throughout. While writing it, the existing reconnect case turned out to have said its states to a closed socket: `carried().restart` waited for a second socket, but the page's earlier loads had already made two, so what that case said could never have shown. `restart` now waits for the new connection's greeting, and that case mutes the real server as the new one does. `docs/hosted-agent.md` and the design guide say it.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: bun test apps/web/src/chat/planner-settle.test.ts failed first on the missing decision functions (Export named 'plannerConnectionLost' not found) and passes with the three-valued view and the held status covered: a none after a loss is a placeholder only for a page that knew a state, only until another state comes, and never for a page that knew nothing or only none
Assistant-verification: bun run e2e planner-picker -g "keeps the machine away for seconds" on a client built before the fix failed on desktop and on a 390 px phone with 91 of the 50 ms samples reading "Runs on studio. No machine is connected." (the first attempt, whose restart never reached the new connection, passed on the old client for the wrong reason, which is why `restart` now waits for it); both passed on the tree of the fourth commit of this series
Assistant-verification: the checks that cover the whole series (bun run fix, bun run types, bun run ci, the unit suites, the browser specs, design:behavior, the container's bundle, screenshots) ran once on the tree of the fourth commit of this series, which includes this commit: see that commit
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…e machine button, its menu and the run cards carry that state, and nothing confirms it came back

The machine's state lives in one place, the machine button with its menu and the run cards, and nothing confirms recovery: what describes a problem goes when the problem does. `linkNotice` still wrote two lines into Chat from a retained session's link: "The machine disconnected. Its workflows and open questions are kept, answers will reach it when it reconnects, and nothing ends until it does unless someone presses Stop." (with a further sentence about a Stop it had not confirmed) and "The machine reconnected and carries on." Both are removed, and so is the call that posted the first when a session was retained while its link was already down. The link's watcher still republishes the run cards, which carry `disconnected` and its note. `PlannerSession.keptStop`, read only by the removed sentence, goes with it; the link's own flag stays, since its tests read it.

Kept, and read for words that confirm recovery (none does): the lines that record a person's own action (stopped Chopin and paused its workflows; asked it to stop, and its workflows will pause when the machine reconnects; paused or resumed a run; stopped it and withdrew its open question), and the per-reply failure ("The machine's connection dropped during this reply. Its workflows and open questions are kept; ask again once it has reconnected."), which is about a reply, not about presence. A Stop pressed before the server noticed the drop already says in its own line that it has not been confirmed and that the workflows pause when the machine reconnects, so nothing the person needs went with the removed sentence.

Nothing stored changes except that line: run cards, sessions and open questions are published as before, and persistence still precedes publication. The server tests now assert that the transcript is exactly what it was before a drop and again before a return, and that a Stop pressed before the drop was noticed ends with its own line; the link-drop browser case asserts that Chat has no line of either kind while the card and the composer's strip say it, and again after the machine returns, and its screenshots are retaken. `docs/agent-connection.md` and `docs/hosted-agent.md` say that Chat adds no line about the connection.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: bun test apps/server/src/chat/remote-planner.test.ts failed before the change in the two cases that assert the transcript (an extra line, "The machine disconnected. Its workflows and open questions are kept, ... A Stop it had not confirmed is kept and will be delivered when it reconnects.") and passes after; bun test apps/server/src/chat apps/server/src/harness passed: 1109 pass, 0 fail
Assistant-verification: bun run e2e planner-attachment (the link-drop case) on the old server failed with one Chat line matching /The machine (disconnected|reconnected)/ where none was expected, and passed on the tree of the fourth commit of this series
Assistant-verification: the checks that cover the whole series (bun run fix, bun run types, bun run ci, the unit suites, the browser specs, design:behavior, the container's bundle, screenshots) ran once on the tree of the fourth commit of this series, which includes this commit: see that commit
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…could not take while the page's offline notice shows, and let it go when the connection returns

A /research pressed while the page was offline drew "Reconnecting… try again in a moment" with Retry above the composer, beside the page's offline notice: a second strip about the same connection. The person's own connection is one notice, and what a state says goes when the state does. While `offline` is true, the prop that already hides the replies-off strip, the composer shows no line for a research request that failed as disconnected; the draft stays in the composer for when the connection is back, and when the connection returns the stored block is cleared, so it neither comes back as a stale strip nor reappears in a later blip. A research request refused for any other reason is told as before, and nothing in the send path changes.

The browser case, on desktop and a 390 px phone, with the agent on so that no other strip stands in the composer: offline, /research with a brief and Enter leaves the draft in place, with no composer strip, no "Reconnecting" text, no Retry and one alert on the page; online again, still no strip.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: bun run e2e connection -g "pressed while offline" on a client built before the change failed with one composer strip where none was expected (the research request's own "Reconnecting" line with its Retry, the only strip that can stand there with the agent on), and both new cases passed on this tree
Assistant-verification: bun run fix made no changes, bun run types passed, and bun run ci passed on this tree: dprint, oxlint (72 warnings, 0 errors, as on the base), tokens, the design contract (330 exceptions), the design record and Impeccable with no new findings
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test apps/web packages scripts e2e/focus-color.test.ts apps/server passed on this tree: 6978 pass, 9 skip, 0 fail
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun run e2e connection chat-mentions chat-phone chat-composer planner-attachment planner-picker machines machine-choice restart responsive-activity setup-agents hosted smoke implementation-launcher decision-evidence on a client rebuilt from this tree: 204 passed, 1 skipped (the live coding-agent build), 1 failed on the first run and passing on retry: planner-picker "names the document's own machine or says nothing" on desktop never reached its first page (its editor was not ready within 20 s, before any code of this change ran), and both its desktop and phone runs passed on the retry
Assistant-verification: bun run design:behavior passed 22 on the second run; the first run's four wide-project cases failed on the cold development server (the audit page's heading not found within 10 s) while the same cases passed in the narrow project
Assistant-verification: docker build --target build (the Dockerfile's client stage, Linux gzip) measured initial JavaScript at 258,947 B raw / 82,788 B gzip against limits of 259,500 B and 83,000 B (the limit is unchanged; this series adds its code to the lazily loaded workspace chunk), and the temporary image was removed
Assistant-verification: screenshots of the changed surfaces on desktop and a 390 px phone (the machine button through six seconds of none, with the stale list, the link-drop Chat and run card without the removed lines, and the offline notice alone over a /research draft) were opened and checked against apps/web/DESIGN.md; no defect found
Assistant-verification: not run: bun run design:browser, the full runtime Docker image, bun run test:postgres, the conversation-plan browser specs, and GitHub's checks (nothing was pushed)
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…e the connection is down, and let it go when the connection returns

A send that an ordinary connection loss cut off rejects with "connection lost", and the composer drew "Message not sent. Check the connection and try again." with Retry above itself. While the page's offline notice showed, that was a second alert about the same connection, and the strip then stayed after the connection returned until the person typed or sent again.

The composer now keeps whether a failed send was the connection's alongside its text (`sendFailure`, which `boundedChatError` still wraps), and `shownSendFailure` decides what is drawn: a send the connection cut off has no strip of its own while the connection is down, which covers the offline notice and also the moments before it shows, so a blip says nothing; and an effect on `connected` drops it when the connection returns, as it already does for a research request the connection could not take. Every other failure is drawn as before and is never cleared by the connection: a server's refusal, a validation error, and the refusal that a document's machine is still being connected to, whose strip and wait belong to another branch and stay exactly as they are. The send path itself (how a message is sent, retried or refused, and its timing) is untouched, and the draft stays in the composer, so the person sends it again when the page is back.

Unit tests cover which errors are the connection's (a lost connection, a disposed or not-connected wire, a non-error) and which are not (refusals, the machine refusal with its code, a deleted document), and the decision for each. Browser cases on desktop and a 390 px phone cut a Chat send, keep reconnects away and assert, sampled from the cut until the notice has been up a while, that no "Message not sent" line is ever drawn, that the offline notice is the page's only alert and the draft is kept, and that after the connection returns there is no strip and the same draft goes. Two more cases, desktop and phone, refuse a send as a server would and assert that its strip stays through the loss and the return, and goes only when the person sends again.

One existing case asserted the old behaviour: "a disconnect rejects a pending send" waited for the connection-lost strip after a plain blip. It now asserts that the connection comes back, no strip is drawn and the draft stays focused, and its reduced-motion check, which only needed an alert to look at, moves to a case that refuses the send.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: bun run e2e connection -g "send the connection cut off|refusal of a send" on a client built from the unchanged chat.tsx and references.ts failed both cut-send cases (desktop and 390 px phone) with the strip present from the moment of the cut ("Message not sent. Check the connection and try again." where none was expected), and passed both refusal cases, which guard what must not change; on this tree bun run e2e connection chat-references passed 26 of 26, including the changed disconnect case and the reduced-motion refusal case
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test apps/web packages scripts e2e/focus-color.test.ts passed on this tree: 2717 pass, 3 skip, 0 fail; the new references.test.ts cases failed first on the missing sendFailure and shownSendFailure exports
Assistant-verification: bun run fix made no changes, bun run types passed, and bun run ci passed on this tree: dprint, oxlint (72 warnings, 0 errors, as on the base), tokens, the design contract (330 exceptions), the design record and Impeccable with no new findings
Assistant-verification: screenshots of the offline state with a cut send, on desktop and a 390 px phone (the page's one notice over the composer with its draft and no strip), were opened and checked against apps/web/DESIGN.md; no defect found
Assistant-verification: not run for this commit alone: the wider browser suite, design:behavior, the container bundle build and the server tests, which run once on the final commit of the series
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…tion, in the refusal a second agent client gets

The refusal that goes with `planner-attached` ended all three of its variants with "A document has one connection at a time.", which is false: any number of people, each with a browser, are connected to one document. What is single is the agent the document has, which the old text called its Planner. The refusal now says "A document has one agent connected at a time." in all three: "mona is connected to this document. ...", "mona is connected to this document, and its connection dropped. Its work is kept until it reconnects. ..." and "mona disconnected from this document and keeps it until <time> unless they connect again. ...". "Agent" is a word the page and the docs already use ("Agent command"), and it passes the shared plain-words pattern for text about a machine and its connection. The error code, the close code and the wire format are unchanged.

The other refusals and failures that the plain-words change of the agent connection wrote were read for the same mistake of saying connection or machine where the old text said something narrower. One changed meaning: the line logged when another account's claim wins the race for a document's agent owner said "The document's owner changed while connecting", which reads as the document's creator; it says "The document's agent owner changed" again. It is only logged, since the person is told "The document could not be checked." The others keep their meaning and stay: "mona is connected to this document" is followed by the sentence that says it is the agent; "its connection" and "this document's connection was released" are the agent's, as "so mona is no longer connected" says in the same sentence; "reset this document's connection" names what a repository writer's reset ends; the sign-in, paired-machine and archived variants, "This credential was issued for another document.", "The connected account can no longer act for this document.", "No machine is connected to this document.", and the turn failures that say the machine.

Every quote is updated: the link tests assert the first variant whole and the other two by their start and end, a resumable link's drop asserts the middle variant whole and runs it through the plain-words check for a machine's connection as the link tests' refusals already do, the shared attachment contract asserts the first whole and the third by pattern, and `docs/agent-connection.md` quotes all three.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test of apps/server/src/harness/remote and apps/server/src/storage with the changed assertions and the old attachments.ts failed 5 tests (the first variant in a link test and in the contract, the third in two link tests, and the middle one after a resumable link's drop), each receiving "A document has one connection at a time."; with the new text bun test apps/server/src/harness/remote apps/server/src/storage apps/server/src/chat/remote-planner.test.ts passed: 383 pass, 0 fail
Assistant-verification: bun run fix made no changes, bun run types passed, and bun run ci passed on this tree: dprint, oxlint (72 warnings, 0 errors, as on the base), tokens, the design contract (330 exceptions), the design record and Impeccable with no new findings
Assistant-verification: grep of apps, packages, e2e, docs and scripts for "one connection at a time" finds nothing after this change
Assistant-verification: not run for this commit alone: the full server tests, bun run test:postgres (the attachment contract this changes is shared with the PostgreSQL adapter and passes against the memory one), the browser suite, and the container bundle build, which run once on the final commit of the series
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…n with no machine button, while the page's offline notice shows, and let it return with the connection

A person with no machine button (a teammate who has no machine, or a phone whose top bar an open child has taken) reads the machine's state in the strip on the composer's top edge: "Connecting to @mona's machine", "@mona's machine is offline. Your work is kept.", "No machine is connected." or "Can't connect to @mona's machine." with its reason. It was drawn whatever the connection did, so while the page's offline notice showed, the composer still carried one of those above the draft, a second thing about the connection and a stale one, since the page cannot hear the machine while it is offline. Reviewers saw it beside "You're offline" on desktop and on a phone.

`plannerPlacement`, the one decision about where the machine's state is told, now takes whether the offline notice shows and draws no strip while it does, as the composer's strip about replies being off already says nothing then. The machine button is unchanged. The strip comes back by itself, with whatever state the page then knows, when the notice goes, and nothing says it did. Online it stays as it was, because such a person has no other place to read the state. The design guide and docs/hosted-agent.md say so.

A unit case covers every state, with and without the button, with the notice and without it. A browser case on desktop and a 390 px phone takes the connection away and keeps reconnects away for a person with no button, for a machine being connected to, one that cannot be, and one that is offline, and asserts that the offline notice is the page's only alert, that no strip and none of those words is above the composer, and that the strip with the same words is back once the connection returns. `carried` in planner-picker.e2e.ts gains `lose` and `back` for it.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: bun test apps/web/src/chat/planner-status.test.ts failed before the change (the strip was received where none was expected) and passed after, and bun test apps/web passed on this tree: 1001 pass, 2 skip, 0 fail
Assistant-verification: CI=1 bun run e2e connection planner-picker --retries=0 -g with the three new cases, on a client built from the unchanged source, failed both no-button cases (desktop and a 390 px phone) at the assertion that no composer strip is drawn beside the offline notice (Expected 0, Received 1, with the notice the page's only alert); on a client built from the tree of the third commit of this series both passed
Assistant-verification: bun run types passed and bun run ci passed on this tree: dprint, oxlint (72 warnings, 0 errors, as on the base), tokens, the design contract (330 exceptions), the design record and Impeccable with no new findings
Assistant-verification: screenshots of a person with no machine button, offline and after the connection returned, for a machine that cannot be connected to, on desktop and a 390 px phone (the offline notice over a bare composer, and the strip with the same words back, with nothing confirming it), were opened and checked against apps/web/DESIGN.md; no defect found
Assistant-verification: not run for this commit alone: the wider browser suite, design:behavior, the container bundle build and the server tests, which run once on the final commit of the series
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…line notice shows, a server's refusal included, and tell the refusal again, unchanged, when the connection returns

The previous commit for this surface hid the strip of a send the connection cut off while the connection is down, and left every other failure drawn. So a server's refusal ("Message not sent: Unavailable", a full reply queue, the refusal that a machine is still being connected to) stayed as a red alert beside the page's offline notice, and the browser case asserted the two together. Two alerts about a lost connection is what the notice exists to prevent, and the person can do nothing about the refusal while the page cannot reach the server.

`shownSendFailure` now also takes whether the offline notice shows and draws no failure at all while it does. The stored failure is not cleared by this: a connection-lost failure still clears when the connection returns and is hidden while it is down (so a blip, which shows no notice, says nothing), and any other failure is drawn again, with its own words and its Retry, once the notice is gone, until the person sends again, as before. Nothing about how a send is made, refused, retried, timed or cleared changed, including the refusal that a machine is still being connected to, whose words, wait and clearing stay with the branch that replaces it; only whether it is drawn beside the notice.

The unit case now covers the cut-off send, a server's refusal and the machine refusal across connected, down and notice showing. `expectRefusalStays` in connection.e2e.ts is rewritten as `expectRefusalGivesWay`: one alert while the refusal is told online, exactly one alert (the offline notice) and no strip or Retry in Chat while offline with the draft kept, then the same strip with the same words and the single alert after the connection returns, and gone when the person sends again; desktop and a 390 px phone. The design guide says it.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: bun test apps/web/src/chat/references.test.ts failed before the change (a server's refusal was received while the offline notice showed) and passed after, and bun test apps/web passed on this tree: 1001 pass, 2 skip, 0 fail
Assistant-verification: CI=1 bun run e2e connection planner-picker --retries=0 -g with the three new cases, on a client built from the unchanged source, failed both refusal cases (desktop and a 390 px phone) with two alerts where one was expected while offline (the refusal strip beside the offline notice); on a client built from the tree of the third commit of this series both passed, with the same text back after the connection returned
Assistant-verification: bun run types passed and bun run ci passed on this tree: dprint, oxlint (72 warnings, 0 errors, as on the base), tokens, the design contract (330 exceptions), the design record and Impeccable with no new findings
Assistant-verification: screenshots of a refused send online and offline on desktop and a 390 px phone (the strip with Retry online; the page's one notice over a bare composer holding the draft offline) were opened and checked against apps/web/DESIGN.md; no defect found
Assistant-verification: not run for this commit alone: the wider browser suite, design:behavior, the container bundle build and the server tests, which run once on the final commit of the series
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…cument runs on, and say nothing while the machine list still describes another of the person's own machines

Review found that the status named any machine of the person's own that the list said the Planner was on, not only the document's. A document set to run on Box B, with a list read just before that still describing the person's own Box A being started, and a page whose connection said the person's own Planner was starting or dropped, read "Connecting to Box A" or "Box A is offline. Your work is kept." on the machine button until the next read of the list answered. The account check from the earlier commit passed, because both words were the person's own.

`plannerRunsOn` (and so `plannerLastSeen`) now names a machine only when it is the document's own, the one the machine button names: `shownMachine`, the person's choice, else the one Default machine uses now, which is also what a teammate's machine already needed. `plannerMachineName` says nothing (`null`) where the list's machine is a machine the document does not run on, a teammate's or another of the person's own, for the person's own account, until a read of the list agrees; the status is still told by login for another account's Planner, for a viewer with no machines or an agent client of their own, and nothing is said while the list loads. The page already reads the list again on every change of the Planner's state. The wire carries no machine id, so this is client-only, and no wire format changed. One consequence is recorded rather than hidden: a document with no choice whose Default machine has dropped and gone offline, while Automatic names another machine or none, says nothing of that machine until the button names it, instead of its name and last seen.

Unit cases probe Box A and Box B starting and dropped, with the choice and with Automatic, for each word the socket can say, and the list catching up; the existing probes now give their document the choice the machine they name needs. A browser case on desktop and a 390 px phone has a person with two machines of their own, the document set to run on Box B, and the list carried by the test with page.route (as the neighbouring cases do) still describing Box A starting: for starting, dropped and failing words held past the moment a machine being connected to waits, the button stays "Runs on Box B" with nothing announced or drawn, and when the list catches up it says "Connecting to Box B" and "Box B is offline. Your work is kept.". The design guide and docs/hosted-agent.md say it.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: bun test apps/web/src/machine-choice.test.ts failed before the change with Received "Box A" where null was expected (and "laptop" and another machine's last seen time where nothing was expected) and passed after, and bun test apps/web passed on this tree: 1002 pass, 2 skip, 0 fail
Assistant-verification: CI=1 bun run e2e connection planner-picker --retries=0 -g with the three new cases, on a client built from the unchanged source, failed both stale-list cases (desktop and a 390 px phone) with the button reading "Runs on Box B. Connecting to Box A" where "Runs on Box B" was expected; on a client built from this tree both passed with --retries=0 (an earlier attempt failed after its last assertion, in the case's own teardown, where a read of the list was still on its way when the test's server was stopped; the case now waits for it with page.unrouteAll)
Assistant-verification: bun run fix made no changes, bun run types passed, and bun run ci passed on this tree: dprint, oxlint (72 warnings, 0 errors, as on the base), tokens, the design contract (330 exceptions), the design record and Impeccable with no new findings
Assistant-verification: screenshots of the machine button with a list that still describes another of the person's own machines, on desktop and a 390 px phone (the quiet button, no spinner, no strip, nothing else changed), were opened and checked against apps/web/DESIGN.md; no defect found
Assistant-verification: not run for this commit alone: the wider browser suite, design:behavior, the container bundle build and the server tests, which run once on the final commit of the series
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…e socket's last word, and keep what the status said until one has answered

The previous commit named a machine only when its id was the one the document runs on, but the list it read that id from can be older than the socket's word. The socket says the Planner is starting or has dropped and carries no machine id, so the page asks the server's list which machine that is, and that list can have been read before the word: a document set to run on Box C after a list that still said Box B was read, with a word then arriving, read "Box B is offline. Your work is kept." on the machine button until the next read answered. The list agrees with itself, so no check of the list against itself could catch it, and an id match is only as true as the read it comes from.

The page now keeps the order in which the socket's words arrive and the reads of the list begin (`nextReading`, one counter shared by both). `useMachineChoice` says where the read behind its state began (`read`), each word is stamped when it arrives, and `plannerMachineName` takes whether the list is current: a read that began after the word. While it is not, a word that is starting or has dropped names nothing, by name or by login, until a read that began after the word has answered; the page already reads the list again on every word, so this is a round trip, and with no list at all (machines not asked for) it is told by login as before. The status itself keeps what it showed while it waits (`waitsForList` holds `useSettledPlannerStatus`, as the placeholder of a restart already does): a first state is not said until then, but a state that was said, with a list read after its own word, is neither cleared nor said again, since a word that repeats the state with one more failed attempt would otherwise empty the status for a round trip and announce "Connecting to studio" a second time. A word that names no machine (attached, none, held) still goes at once. Names are still only ever the one whose id the list gives as the document's own, never by who owns a machine, and nothing about the wire changed.

The unit cases give a list that agrees with itself on Box B, for each word the socket can say, as current and as older than the word, and the words that wait for the list and the ones that do not. The browser case, on desktop and a 390 px phone, has a person with two machines of their own: Box B is named after a word and a current list, then the document is set to run on Box C while the next read is held back by the test with page.route, a word that the held list cannot answer arrives, and for 1.8 s the button keeps saying what it said ("Connecting to Box B") with nothing new announced or drawn and Box B never said to be offline; when the read answers it says "Box C is offline. Your work is kept.". `reading` in planner-picker.e2e.ts gains `hold` for it. The design guide and docs/hosted-agent.md say it.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: the new cases in apps/web/src/machine-choice.test.ts passed on this tree and failed on the previous commit's machine-choice.ts with Received "Box B" where null was expected (a list read before the word naming the machine it agrees with itself on)
Assistant-verification: CI=1 bun run e2e planner-picker --retries=0 -g with the lag case, on a client built from the previous commit, failed both viewports (desktop and a 390 px phone) with Received "Runs on Box B. Box B is offline. Your work is kept." (Box B named from a list read before the word); a first version of this change that cleared the status while it waited made the existing case "a Planner its machine keeps failing to start says why ... and is announced once" fail on all three attempts of its first run with "Connecting to studio" announced twice, which is why the status keeps what it said
Assistant-verification: CI=1 GIT_CONFIG_GLOBAL=/dev/null bun run e2e connection chat-mentions chat-phone chat-composer planner-attachment planner-picker machines machine-choice restart responsive-activity setup-agents hosted smoke implementation-launcher decision-evidence chat-references on a client rebuilt from this tree: 218 passed, 1 skipped (the live coding-agent build), 0 failed, 4 flaky that passed on retry (hosted "a stale outgoing route", implementation-launcher "on a phone, tapping Build", machine-choice "populated sidebar Default machine fits" at 1862x1039, planner-picker "anyone who can edit a document chooses a teammate's shared machine"); the four, re-run with --retries=0 on that client, passed 13 of 13 in four runs, and the planner-picker one (a race in the case itself: it reads the connector's poll right after a menu click whose button updates before the choice reaches the server) failed once under 13 parallel workers and passed 6 of 6 alone
Assistant-verification: bun run fix made no changes, bun run types passed, and bun run ci passed on this tree: dprint, oxlint (72 warnings, 0 errors, as on the base), tokens, the design contract (330 exceptions), the design record and Impeccable with no new findings
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test apps/web packages scripts e2e/focus-color.test.ts apps/server passed on this tree: 6984 pass, 9 skip, 0 fail
Assistant-verification: bun run design:behavior passed 22 on two runs on this tree (a first run after a container build failed one case, "wide table retains its own scroll lane", at the dev server's cold start: 21 passed, 1 failed)
Assistant-verification: initial JavaScript measured 258,951 B raw / 81,992 B gzip in a local build and 258,947 B raw / 82,789 B gzip in docker build --target build, against limits of 259,500 B and 83,000 B (211 B of gzip headroom, limit untouched)
Assistant-verification: screenshots of the offline notice over a bare composer for a person with no machine button and the strip back after the connection returned, a refused send online and offline, and the machine button with a list that still describes another of the person's machines and with a slow list, on desktop and a 390 px phone, were opened and checked against apps/web/DESIGN.md; no defect found
Assistant-verification: not run: bun run design:browser, the full runtime Docker image, bun run test:postgres, the conversation-plan browser specs, the rest of the browser suite, and GitHub's checks (nothing was pushed)
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…otice shows, and let the words come back with the connection

A run whose machine had dropped keeps the server's last frame through a lost connection, so its card went on saying "disconnected", "Last reported running. It carries on when the machine reconnects." and "Pause <run> when the machine reconnects" beside the page's one offline notice, on desktop and on a phone. The ruling for that notice is that nothing else about the connection, a machine's state or a send's failure is shown anywhere, for every viewer, and the earlier commits applied it to the composer's strips only.

`RunStack` now takes `offline`, which `Chat` passes from the same flag its other gates use. While it is set, a run whose machine dropped reads as the run it last reported: its status word is that report ("running", "paused", "waiting on Decisions"), its accessible name has no "disconnected, was", its Pause is the plain "Pause <run>", it draws no note, and its mark is the still, neutral ring instead of the warning-coloured one. The card keeps its identity and controls as before (its place in the order, a Pause where it had one, no Resume it cannot deliver, its way to Decisions), its clock stays at the last report, and the status word is not announced as it changes, so a screen reader is not told "running" as the connection goes. Nothing is stored or sent; the words come back with the connection, as the run's state then is, and a run whose machine is there reads the same either way.

The unit cases render a card connected and offline for a running, a waiting and a paused run whose machine dropped and for one whose machine is there, and check that none of "disconnected", "reconnect" or "Last reported" is left offline and that the controls are. `status-copy.test.tsx` walks every status connected and offline and asserts that no word about the machine is on an offline card. The browser case, on desktop and a 390 px phone, drops a real agent connection under a run, then loses the page's own connection: exactly one alert (the offline notice), the card reads "plan-review, running" with its Pause, no word about the machine in the page's text, names or titles; when the connection returns the same words come back ("disconnected, was running", the note, "Pause plan-review when the machine reconnects", the composer's strip) because the machine is still away, and they go when it returns. The design guide and docs/hosted-agent.md say it.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: the new case in apps/web/src/chat/run-card.test.tsx failed on the previous commit's run-card.tsx (the offline card still read "disconnected" and carried the note) and passes on this tree; apps/web/src/status-copy.test.tsx passes
Assistant-verification: CI=1 bun run e2e planner-attachment --retries=0 -g with the new case, on a client built from the previous commit, failed on both viewports (desktop and a 390 px phone) with the offline card still not reading "plan-review, running" (its status word was still "disconnected") beside the one alert
Assistant-verification: CI=1 bun run e2e planner-attachment --retries=0 on a client built from this tree: 10 passed, 0 failed, including the new case on both viewports and the existing case for a Planner whose connection drops
Assistant-verification: bun run fix made no changes, bun run types passed, and bun run ci passed on this tree: dprint, oxlint (72 warnings, 0 errors, as on the base), tokens, the design contract (330 exceptions), the design record and Impeccable with no new findings
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test apps/web passed on this tree: 1005 pass, 2 skip, 0 fail
Assistant-verification: not run on this tree alone: the rest of the browser suite, bun run design:behavior, the container bundle build and screenshots (the final head's checks are in the last commit's message); nothing was pushed
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…achine list, instead of what the status said of an older word, and keep the announcement from repeating a state that carried on through the wait

The previous commit held the machine's status while a starting or dropped word waited for a list read after it, so for one round trip, or for as long as a read took when it failed or the tab was hidden, the page went on saying what it had said of the word before. After the document was set to run on Box C and the socket said its Planner had dropped, the machine button still read "Runs on Box B. Connecting to Box B" for as long as the read took: a name from a list older than the word, which is what the rule against naming a machine from a list older than the state forbids, and a hold with no bound.

The status now says nothing of the machine while a word waits (`useSettledPlannerStatus` takes `withheld`, from `plannerWithheld`: the word is starting or has dropped and the machine is not known). Nothing is drawn of an older word, nothing stale can remain if a read fails, and the button reads only where the document runs. `waitsForList` is gone. The restart's placeholder hold is unchanged. What it had shown is remembered through the wait, so the same state returning once the machine is named is drawn at once, and does not wait out its grace of 1.5 s again.

The hold had been added so that clearing the status and saying the same state again would not announce "Connecting to studio" twice (a word that repeats the state with one more failed attempt empties the status for a round trip). That belongs to the announcement, not to what is drawn: `plannerSpeech` is the one rule for the live region, saying a state once as it is entered. A withheld status is a gap, not the state ending: the region falls silent, holding nothing stale, and remembers the state it last said, so the same state entered again after it is not said again, while a different one is said once it is named, and one entered after the Planner was healthy or gone is said again.

The unit cases cover `plannerWithheld` for every word, `plannerSpeech` over sequences (a repeated state through a gap, the document moving to another machine, a different state after a long gap, a first state never said, and the states that are said again), the region's text through a gap, `plannerDrawn` and the grace that is not waited again, and the composition that a word waiting for a newer read is drawn as nothing at once. The browser case for a list read before the socket's word now asserts that the button reads only "Runs on Box B", with no state, no strip and nothing new announced, for 1.8 s and then 3 s more with the read held back (as a failing read would leave it), and names Box C when it answers, on desktop and a 390 px phone; the case for a list naming a teammate's machine asserts the state is said once and not again when the list catches up; the last-seen case ends by waiting for its routes as the two before it do, after one run failed in its teardown with "Route is already handled". The design guide and docs/hosted-agent.md say it.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: CI=1 bun run e2e planner-picker --retries=0 -g with the list-read-before-the-word case, on a client built from the previous commit, failed both viewports (desktop and a 390 px phone) with Received "Runs on Box B. Connecting to Box B" where "Runs on Box B" was expected (the name of Box B from a list older than the word)
Assistant-verification: CI=1 bun run e2e planner-picker --retries=0 -g with that case and the "announced once" cases, on a client with the hold removed and the old announcement rule, failed "a Planner its machine keeps failing to start ... is announced once" with Received ["Connecting to studio", "Connecting to studio"], which is why the announcement rule changed; a first full planner-picker run on a client that withheld the status but forgot what it had shown and left the region's text in place through the gap failed that case again (the grace was waited twice) and the case for a list naming a teammate's machine on both viewports (it expects the region to be empty in the gap), which is why what was shown is remembered and the region falls silent in the gap
Assistant-verification: CI=1 bun run e2e planner-picker --retries=0 on a client built from this tree: 35 passed, 0 failed
Assistant-verification: the new cases in apps/web/src/chat/planner-status.test.ts failed to load on the previous commit (plannerSpeech and plannerWithheld did not exist) and, with `withheld` ignored by plannerSpeech, the gap case failed
Assistant-verification: bun run fix made no changes, bun run types passed, and bun run ci passed on this tree: dprint, oxlint (72 warnings, 0 errors, as on the base), tokens, the design contract (330 exceptions), the design record and Impeccable with no new findings
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test apps/web passed on this tree: 1010 pass, 2 skip, 0 fail
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test apps/web packages scripts e2e/focus-color.test.ts apps/server passed on this tree: 6990 pass, 9 skip, 0 fail
Assistant-verification: CI=1 GIT_CONFIG_GLOBAL=/dev/null bun run e2e connection chat-mentions chat-phone chat-composer planner-attachment planner-picker machines machine-choice restart responsive-activity setup-agents hosted smoke implementation-launcher decision-evidence chat-references on a client rebuilt from this tree, twice: 216 passed, 1 skipped (the live coding-agent build), 0 failed, 8 flaky that passed on retry; then 217 passed, 1 skipped, 0 failed, 7 flaky (connection "a send pressed during a blip", hosted "superseded WebSocket metadata", implementation-launcher "on a phone, tapping Build", machine-choice phone navigation, workspace menus at 600 and sidebar Default machine at four sizes, each on a 10 s timeout under the suite's load); the 17 cases those names match passed 17 of 17 three times with --retries=0 on that client, the same full run on the client of the previous commit failed exactly the six cases this change adds or changes and had nine flaky cases of its own (the machine-choice and implementation-launcher ones among them), and the connection case could not be made to fail alone or under load on either client (24 of 24, and 140 of 140 with two other specs, on each); why it needed a retry here twice is not established
Assistant-verification: bun run design:behavior (CI=1) passed 22 on this tree
Assistant-verification: initial JavaScript measured 258,951 B raw / 81,990 B gzip in a local build and 258,947 B raw / 82,789 B gzip in docker build --target build on this tree, against limits of 259,500 B and 83,000 B (211 B of gzip headroom, limit untouched)
Assistant-verification: screenshots of the run card with the offline notice and with the connection back, and of the machine button while the newer list is awaited, on desktop and a 390 px phone, were retaken on this tree, opened and checked against apps/web/DESIGN.md; no defect found
Assistant-verification: not run: bun run design:browser, the full runtime Docker image, bun run test:postgres, the conversation-plan browser specs, the rest of the browser suite, and GitHub's checks (nothing was pushed)
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…achine's status, instead of redrawing what was shown before the gap, and let only the same state skip its grace

The previous commit keeps what the machine's status had shown through a gap, while a word waits for a newer read of the machine list and nothing is drawn, so that the same state returning once the machine is named does not wait out its grace of 1.5 s again. `plannerDrawn` returned that remembered state whenever the next state had a grace (a machine being connected to, or none yet) and was of another kind, so it was drawn in place of a different state. When the Planner had dropped on Box B, and the person then moved the document to Box C and the socket said its machine was starting, the status was withheld while the list was read; once the read named Box C the button read "Runs on Box C. Box B is offline. Your work is kept." for the 1.5 s of the grace and then "Connecting to Box C". That names a machine the current state no longer supports, and redraws a state that had been cleared after a gap in which nothing was drawn. A browser sampler measured it from 15 to 1,542 ms on a desktop and from 9 to 1,551 ms on a 390 px phone.

What the status remembers through a gap is now used only to skip the grace for the same state returning, of the same kind and naming the same machine (`plannerShownAtOnce` takes `gapped` and compares the kind and the sentence). It is never drawn in place of a different state. A state that waits (connecting, no machine yet) draws nothing until it has lasted its grace, and a state with no grace (the machine has dropped, cannot be started, has stepped away, or the status clears) is drawn at once, as it always was. A transition with no gap is unchanged: the last state stays through the grace of the next, as it does through a reconnect's placeholder. `plannerRemembered` is the hook's memory as a pure function, so a gap marks what was shown as one that may return and any state drawn ends the gap, and `useSettledPlannerStatus` is only its wiring.

The unit cases cover a change of kind across the gap (disconnected to connecting, away to no machine, connecting to disconnected, and connecting to connecting on another machine), the same state returning at once however long the gap, what is remembered not outliving the next state drawn, and a no-gap transition and the placeholder hold keeping the last state. The old assertion that a connecting state after a drop draws the drop is now stated for a transition with no gap. The browser case, on desktop and a 390 px phone, drops the Planner on Box B, moves the document to Box C, holds the list read back, releases it and samples the button, its dot, the composer's strip and the live region every 50 ms for over two seconds: from the moment the list names Box C nothing names Box B, the button reads only "Runs on Box C" until the machine being connected to has lasted its grace, and then "Runs on Box C. Connecting to Box C", said once. The design guide and docs/hosted-agent.md say it.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: the new cases in apps/web/src/chat/planner-settle.test.ts failed against the previous commit's behaviour (its planner-settle.ts with a `plannerRemembered` that never marks a gap added to it, since the function did not exist): 3 failed, 9 passed, each with Received {kind: "disconnected", text: "Box B is offline. Your work is kept."} where nothing was to be drawn; 12 pass on this tree
Assistant-verification: CI=1 bun run e2e planner-picker --retries=0 -g with the new case, on a client built from the previous commit, failed on both viewports (desktop and a 390 px phone): the samples taken after the list named Box C read "Runs on Box C. Box B is offline. Your work is kept." for 1.5 s, where none was to name Box B
Assistant-verification: CI=1 bun run e2e planner-picker --retries=0 on a client built from this tree's sources (before dprint's whitespace-only reformat of planner-settle.ts): 37 passed, 0 failed, including the new case on both viewports
Assistant-verification: bun run fix made no further changes, bun run types passed, and bun run ci passed on this tree: dprint, oxlint (72 warnings, 0 errors, as on the base), tokens, the design contract (330 exceptions), the design record and Impeccable with no new findings
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test apps/web passed on this tree: 1017 pass, 2 skip, 0 fail
Assistant-verification: not run on this tree alone: the rest of the browser suite, bun run design:behavior, the container bundle build and screenshots (the final head's checks are in the last commit's message); nothing was pushed
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…line notice shows, and remember what it last said, so a state that carried on through the loss is not said again

The ruling for the offline notice is that nothing else about the connection, a machine's state or a send's failure is shown anywhere, for every viewer, a screen reader included. The earlier commits applied it to the composer's strips and to a run's card, but the page's one live region for a machine's state, which a screen reader reads, had no offline input. It kept the last sentence it had said ("@mona's machine is offline. Your work is kept.") while the notice was the only alert, on desktop and on a 390 px phone. And a list read started after a starting word could answer after the socket had dropped and release a status that had been withheld; its grace then ran out beside the notice and the region said "Connecting to Box C" there.

`PlannerAnnouncement` and `plannerSpeech` now take the page's `offline` flag, the same `offline.open` the other gates read. While it is set the region is empty and says nothing new, whatever the status does meanwhile. It remembers the state it last said, as it does through a gap, so a state that carried on through the loss is not said again when the connection returns, and a different one, such as a state released while the page was offline, is said once, as a state entered then. A state that is gone by the time the connection returns is forgotten, so it is said again if it recurs.

The unit cases drive `plannerSpeech` with the notice showing and not: the sentence is cleared at once, nothing is said for as long as the notice shows, a state released from a wait beside it (the exact call that returned "Connecting to Box C") says nothing and keeps the state last said, the same state is not said again on return, another machine's name for it is still the same state, a different state is said once, and a state that went healthy is said again when it recurs. In the browser, on desktop and a 390 px phone, the run-card case now also asserts that the live region is empty and has been told nothing new while the notice shows, and that none of "is offline", "Connecting", "Your work is kept" or "No machine" (the words the case's regex left out) is in the page's text, in any label, title or tooltip, or in any live region. Two new cases cover the other symptoms: a state that dropped, then the connection lost and returned, is not said again and a different state afterwards is said once; and a state released after the socket dropped is not said beside the notice and is said once when the connection returns. The design guide and docs/hosted-agent.md say it.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run e8affc11-a52c-4d2e-b034-52b742b22f77)
Assistant-verification: the new cases in apps/web/src/chat/planner-status.test.ts failed on the previous commit (plannerSpeech ignored the offline flag): 2 failed, 23 passed, each with the region's text still holding "Box B is offline. Your work is kept." where it was to be empty; 25 pass on this tree
Assistant-verification: CI=1 bun run e2e planner-attachment planner-picker --retries=0 -g with the run-card case and the two new cases, on a client built from the previous commit, failed 6 of 6 (desktop and a 390 px phone): the live region still said "@mona's machine is offline. Your work is kept." beside the notice in the run-card case and "Box B is offline. Your work is kept." in the first new case, and in the second the region said "Connecting to Box C" 1,550 ms after the read answered, beside the notice, with the button gone
Assistant-verification: CI=1 bun run e2e planner-picker planner-attachment --retries=0 on a client built from this tree: 51 passed, 0 failed
Assistant-verification: bun run fix made no changes, bun run types passed, and bun run ci passed on this tree: dprint, oxlint (72 warnings, 0 errors, as on the base), tokens, the design contract (330 exceptions), the design record and Impeccable with no new findings
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test apps/web packages scripts e2e/focus-color.test.ts apps/server passed on this tree: 6999 pass, 9 skip, 0 fail
Assistant-verification: CI=1 GIT_CONFIG_GLOBAL=/dev/null bun run e2e connection chat-mentions chat-phone chat-composer planner-attachment planner-picker machines machine-choice restart responsive-activity setup-agents hosted smoke implementation-launcher decision-evidence chat-references on a client rebuilt from this tree, once: 228 passed, 1 skipped (the live coding-agent build), 0 failed, 2 flaky that passed on retry (machine-choice "populated sidebar Default machine fits ..." at 1862x1039 and 1280x600, each on a 10 s timeout waiting for a new project's sidebar link under 128 workers); the 10 cases that title matches passed 10 of 10 with --retries=0 on that client
Assistant-verification: CI=1 bun run design:behavior passed 22 on this tree
Assistant-verification: initial JavaScript measured 258,951 B raw / 81,994 B gzip in a local build and 258,947 B raw / 82,779 B gzip in docker build --no-cache --target build on this tree, against limits of 259,500 B and 83,000 B (221 B of gzip headroom, limit untouched)
Assistant-verification: screenshots of the machine button after the document moved to Box C (while the grace runs and once it has passed), the offline notice with the machine's state said before it, and the run card beside the offline notice, on desktop and a 390 px phone, were retaken on this tree, opened and checked against apps/web/DESIGN.md; no defect found
Assistant-verification: not run: bun run design:browser, the full runtime Docker image, bun run test:postgres, the conversation-plan browser specs, the rest of the browser suite, and GitHub's checks (nothing was pushed)
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
User-preference: For product behavior questions, use the industry standard for popular projects
User-preference: Behave like WhatsApp and Slack for messaging and status
Co-authored-by: Alex Lavaee <lavaman131@github.com>
… kept draft, as the composer now offers, instead of a Retry the composer no longer draws

The case "losing a send acknowledgement and retrying after reconnect never duplicates the message or initiative" clicked the Retry of the composer's "Message not sent" strip. A send the connection cut off no longer draws that strip: its text stays in the composer, as the child-document case in the same file already resends it. The case now checks the draft is kept and that no Retry is drawn, presses Enter, and still asserts the second send carries the same request id, one message and at most one initiative.

Assistant-model: Claude Opus 5.5
Assistant-workflow: inline
Assistant-verification: GitHub e2e shard 12/12 failed on 5731a53: conversation-initiative.e2e.ts:161 timed out clicking the composer's Retry on all three tries
Assistant-verification: local CI=1 run not completed: another worktree held the e2e ports, and CI=1 refused to reuse its server
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
Co-authored-by: Alex Lavaee <lavaman131@github.com>
@lavaman131
lavaman131 merged commit adb17d0 into main Nov 7, 2026
24 checks passed
@lavaman131
lavaman131 deleted the plain-words-and-quiet-offline branch November 7, 2026 08:16
lavaman131 added a commit that referenced this pull request Nov 8, 2026
…proxy that holds requests, and keep a machine shown as connected and asked for its Planner through it, which that test showed neither was

Nothing had run the whole hand-over end to end: the unit and PostgreSQL tests cover each part, and `e2e/restart.e2e.ts` stops and starts one server. `e2e/handover.e2e.ts` starts server A, which holds the writer lease, and server B, which waits beside it in standby, puts a proxy in front, and sends A SIGTERM under open tabs. The page is sampled every 25 ms and must say nothing of the connection or the machine, never reload, keep what it holds, and keep offering what it offered when the hand-over began, part by part: Build plan, what Chat says before anyone has spoken, what the composer lets a person do, the document's lock, and a comment being written with its offer to send it to Chopin; the first request the proxy answers after the lease row is released is timed and the test fails above the design's 750 ms budget.

Its cases: a person types without stopping, with an unsent message in the composer, while a paired machine's Planner is attached (the machine's button never leaves connected, every word arrives once and in order, an MCP session still works, a missing asset is a 404); messages to the Planner before, during the turn wait and after the cut, and a comment sent to it, answered once and in the order accepted, for the sender, a teammate's tab and a tab opened afterwards; a message sent from an open child, which keeps the child; a message sent while the machine's Planner is away through the whole cut, which shows Sent and is Delivered once, with one reply, when it is back; a comment being written, which is still open with its words and its offer to Chopin after the deploy, and a comment started after it is offered the same; and a Planner attached under its owner's sign-in with a question left open, with the owner's tab in front, really in the background, frozen for a moment, and with a member's answer given before the client is back, in each of which the client's own question ends answered, with the answer, and no turn of its own follows; and one whose client never resumes, whose answer reaches the Planner once as a turn when it attaches again; and a question answered while the old process waits for the turn that asked it, which reaches that turn and starts no second one.

The proxy is `e2e/holding-proxy.ts`, which does what the reference Caddyfile does (readiness checks every 250 ms, a request held up to 5 s, a refused dial tried again every 100 ms, a WebSocket kept until a side closes it), because CI cannot be assumed to have Caddy or a container; `e2e/holding-proxy.test.ts` holds it to that, and `CHOPIN_E2E_PROXY=caddy` runs the reference proxy in its container instead. Both pass, and both are held to the 750 ms budget.

Measured, release to first answer (the budget is 750 ms, the tab's grace 1.5 s), on the stack rebased onto the plain-words change (adb17d0), this commit's tree: 33 hand-overs behind the in-test proxy in three runs of `handover.e2e.ts` with `message-delivery.e2e.ts`, all passing, 128 to 434 ms, median 243, and 11 more in the whole browser-suite run, 118 to 366 ms. Before the rebase, measured on the stack as first built and not repeated since: 88 hand-overs in eight consecutive runs, 131 to 390 ms; 22 behind the reference Caddy in two runs, 117 to 361 ms; not one of about 332 to 1,134 requests per hand-over answered anything but 2xx. The owner's login, which the Planner's client waits for, came back after the release in 378 to 730 ms with the tab in front (taken back on the client's second to fourth try), 731 to 933 ms (and 1.74 s once) with the tab really in the background, and 3.03 to 3.25 s, 29 to 253 ms after it thawed, with it frozen for 3 s after the release: all inside the 10 s window.

The test found two things the unit tests could not, fixed here. The new process answered the machine connector's first Planner poll with no Planner to run, because which documents are in use lived in the old process, so the connector, as the real one does, stopped the backend and the document showed the machine as stepped away; each restored machine hold now counts as the document being in use. And a page request held through the cut is answered before the connector has opened its connection again, which listed no machine for about 60 ms in about one run in five; a machine whose Planner backend is attached, or expected back within its window, is now listed as connected and is the document's machine, and a unit test fails without it. A flake in an earlier test, which looked for the instant the lease is free and could miss it on a busy machine, now takes a new owner as the release seen late.

The hidden-tab case is real now. Playwright makes every page it drives look focused and in front, which also keeps its timers at full speed, so a tab it launched cannot be hidden however many pages are in front of it. `e2e/background-browser.ts` starts a headless Chromium itself, drives it over the DevTools protocol with Playwright's overrides off (`noDefaults`), and puts a second page in front: the owner's tab then reads `hidden` and its 25 ms timer fires about once a second (the test checks both before the deploy), and `Page.setWebLifecycleState` freezes it as the browser does a tab it has left alone. No display is needed. A frozen tab sends nothing, so the owner's login, and with it the client's `resume`, waits for the tab to run again: a freeze longer than the window is the design's bounded exception. The browser also ends the sockets of a page it freezes (they close 1006, deploy or none) and the tab learns of it when it runs again. A hidden or frozen page counts the grace of a lost connection only while it can be looked at (the first commit of the stack), so the tab says nothing of being offline, and the test holds it to what it holds every tab to, except that what it offers reads as it is until its sockets are open again (Build plan is gone meanwhile): the allowance for the notice and "Reconnected" that the case once had is gone. A second case freezes a hidden tab and thaws it with its connection away for longer than the grace and expects neither the notice nor the lock, then shows the tab while it is still away and expects the notice. Chromium's intensive throttling of a tab hidden for more than five minutes was not measured. A question a client holds open is resolved as the design says: the client resumes with it still waiting and its own request receives the answer, once, with no turn of its own (a fourth case has the client never resume, and the answer reaches the Planner once as a turn when it attaches again).

Review round 2 adds the case of a message sent while the old process is cutting. A person's page presses Enter without a pause, one message after another, from before the deploy to after it. The cut takes a few milliseconds when nothing holds it, so the test holds the lock every fenced write of the writer lease takes (`pg_advisory_lock`, as `#assertLease` takes it) from a connection of its own, before SIGTERM and while nothing is being written; the old process then stops taking requests, stands at the commit of its cut with its sockets open, and the page keeps on sending for 1.5 s. Every message ends marked Delivered, once, in order, with one reply and a saved row that ended; none says Couldn't send at any moment (the page's probe now also watches for the line a message that could not be sent carries); the first answer comes within the budget. With the old process answering such a frame with a refusal instead of nothing, the test fails with Couldn't send; with what the process does now it passed in eight of eight runs, 19 to 21 messages pressed while the cut stood still.
Review round 5. `e2e/holding-proxy.test.ts` took the port after a live upstream's for a server that starts late, and could find it taken (`EADDRINUSE`; the test as it was failed once in 45 runs, three at a time). Every port now comes from the system: the proxy listens on port 0 and says where (`HoldingProxy.port`; the upstreams are read each time they are used, so one that starts late is given its port when it has), an upstream is started on port 0, and a slot that is not there is a held listener that hangs up on every connection, so that nothing else can take its port meanwhile.

Final review. The test asserted only the gap from the release to the proxy's first answer, which a slow socket upgrade or admission does not move, and the tab's quiet reconnect (five seconds) would hide a reconnect that took up to that long. The page's probe now records, for every socket the cut closes with 1012, when it opened again, and the time from the `session:restarting` frame to the next `session:hello`, and `expectGap(done, ...tabs)` fails any of them above the tab's grace (1.5 s) for every open tab of every hand-over in the file, besides the proxy's 750 ms. A tab left in the background or frozen by the browser is not held to it, because the browser holds its timers and ends its sockets, which is not the deploy; its teammate's tab is. Measured in the whole browser suite, 12 hand-overs: each socket the cut closed opened again 179 to 979 ms after its close (31 sockets, median 579), the new process's hello came 206 to 950 ms after the restart frame (against the tab's 1.5 s), and the proxy's first answer 131 to 396 ms after the release (against 750 ms), with no request answered anything but 2xx.

Review round 7. The probe paired each reopening with the oldest close of the same path, so two sockets of one path (a document and an open child) that came back in another order than they closed were paired crosswise: a root closing at 0 ms and opening at 1,600 ms with a child closing at 100 ms and opening at 250 ms was read as gaps of 250 and 1,500 ms, both within the grace, though the root's was 1,600; and the restart-to-hello time was the first restart frame to the first hello of any socket. The page now only records what each socket did (an open, a close with 1012, a `session:restarting` frame, a `session:hello` frame), each with the kind of socket that did it (its path and its document, which a reconnect keeps), and `e2e/reconnect-gaps.ts` pairs them, per kind, outside the browser: the gap of a kind is from its first close to its last opening, so sockets that cannot be told apart are held to the slowest, never the fastest, and a kind that did not open again, or that greets and was not greeted again, fails. `e2e/reconnect-gaps.test.ts` holds the pairing to that scenario, and to the cases of a socket that never opens, a hello that is slow or never comes, and the sidebar's socket, which says no hello.

Design: zero-downtime upgrades (`docs/zero-downtime-upgrades-design.md`, design branch), "Stage 1: invisible restarts on one server", "Tests", "New end-to-end test (does not exist today)", and "Remote Planner budget".

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run 5b88ff0d-ea65-4f3f-8695-3698899c6eb2)
Assistant-verification: bun run fix passed: formatted, 0 oxlint errors (73 existing warnings, none from these files)
Assistant-verification: bun run types passed: every package and e2e
Assistant-verification: bun run ci passed: dprint, oxlint, tokens, design contract, design record and Impeccable show no new findings
Assistant-verification: bun test e2e/holding-proxy.test.ts passed: 5 pass, 0 fail
Assistant-verification: git rebase onto origin/main adb17d0 (#794): one textual conflict, apps/server/src/chat/service.ts (the queue-full wording), resolved by hand; bun run types and bun run ci pass on each of the ten commits, and the tests each commit touches pass on a fresh database per commit (two timing flakes passed on rerun)
Assistant-verification: planner-dispatch machine-presence test failed with the routes and attachments changes stashed, and passed with them
Assistant-verification: handover.e2e.ts failed before the in-use fix (the connector was told to run no Planners and the page said the machine stepped away), and passed after
Assistant-verification: playwright e2e/sidebar-decision-counts.e2e.ts passed 13 of 13 on 831a348, failed 4 of 13 on this stack before the first commit's fix to its socket glob (measured before the rebase), and passes 13 of 13 in each of three runs on the rebased head
Assistant-verification: bun test (whole repository, a git config that does not sign the fixture commits the connector tests make, a fresh disposable PostgreSQL) on this commit's tree: 7687 pass, 17 skip, 0 fail, 903 files; bun test apps/server/src/storage/postgres on a fresh database: 177 pass, 0 fail; bun run fix (no diff), bun run types and bun run ci pass
Assistant-verification: whole browser suite on the rebased head (the client built as CI builds it, CI=1, four workers, no retries, nothing else on the ports), four runs: 962 passed, 6 skipped, 1 failed (columns.e2e.ts:132, 12 of 12 alone); 961 passed, 6 skipped, 2 failed (document-creation.e2e.ts:137, "Response has been disposed" in the test's own route, 12 of 12 alone; sidebar-decision-counts.e2e.ts:755, 13 of 13 in each of three runs of its file); 963 passed, 6 skipped, 0 failed; and on this commit's tree, with the Chat change that lets a message's "Couldn't send" give way to the offline notice, 964 passed, 6 skipped, 0 failed
Assistant-verification: playwright e2e/demo-collaborators.e2e.ts, the whole file with CI=1, no retries, one worker: failed in 9 of 16 runs on the stack without the first commit's fix and in 2 of 16 on 831a348; passed in 12 of 12 with it (measured before the rebase)
Assistant-verification: docker build --no-cache of this commit's tree passed: initial JavaScript 259037 B raw / 82839 B gzip as the container measures it, against a budget of 259500 B / 83000 B (the budget file is unchanged)
Assistant-verification: bun run design:behavior with CI=1 on this commit's tree: 22 passed; the development startup and diagram gallery suites were not repeated after the rebase; design:test, which CI does not run, fails its screenshot comparisons on 831a348 as well
Assistant-verification: playwright e2e/handover.e2e.ts and e2e/message-delivery.e2e.ts with CI=1, no retries, one worker, databases of its own, three repeats on this commit's tree: 60 passed, the first answer 128 to 434 ms after the release (median 243, 33 hand-overs), 0 requests not answered 2xx
Assistant-verification: playwright e2e/handover.e2e.ts failed all nine cases then in it (Build plan gone, Chat's hint changed, the composer's Mention docs unavailable) with the client as it was before the fix in the first commit of the stack, and passed with it; its frozen-tab case allows nothing of being offline
Assistant-verification: a real background tab (headless Chromium started by e2e/background-browser.ts, Playwright's overrides off) read hidden and fired a 25 ms timer 4 times in 4 s; in Playwright's own browser, headless or headed, minimised, in a second tab or window, with its throttling flags removed and with focus emulation off, it read visible and fired it 160 times
Assistant-verification: playwright e2e/handover.e2e.ts, the case of messages sent while the old process is cutting, with CI=1, no retries, one worker: 8 passed in 8 runs (19 to 21 messages pressed while the cut stood still, the first answer 211 to 342 ms after the release); it failed with the old process answering a chat:send frame after the cut began with a refusal (Couldn't send shown on a message)
Assistant-verification: bun test e2e/holding-proxy.test.ts passed 20 runs in a row, 20 runs two at a time and 45 runs three at a time, with none failing; the test as it was before this change failed 1 of 45 runs three at a time with `Failed to start server. Is port ... in use?` (EADDRINUSE)
Assistant-verification: playwright e2e/handover.e2e.ts, the first case, with CI=1 and the tab's restart delay set to 2.5 s in the built client: the spec of the previous commit passed (the proxy's first answer 298 ms) and this one failed (the sockets opened again after 2504 and 2520 ms, hello 2526 ms after the restart frame)
Assistant-verification: playwright e2e/handover.e2e.ts with CI=1, no code changed, at this commit: the first case and the case of a message from an open child passed (sockets opened again after 245 to 866 ms, hello after 264 to 394 ms, the proxy's first answer 246 and 225 ms); the child case, which has two sockets of one path, failed before the probe paired each socket's close with its own open
Assistant-verification: bun test e2e/reconnect-gaps.test.ts (6 tests) passes; the reviewer's scenario, run through the pairing as the page did it before (the oldest close of the path), reads 250 and 1,500 ms, both within the 1.5 s, where the root's real gap is 1,600 ms, and through the new pairing reads 1,600 and 150 ms and fails
Assistant-verification: playwright e2e/handover.e2e.ts with CI=1, two workers, no retries, at this commit: 12 passed in 3.7 minutes, the proxy's first answer 128 to 361 ms after the release, each socket the cut closed opened again 216 to 952 ms after its close (15 tab readings), the hello 234 to 964 ms after the restart frame
Assistant-verification: bun run types and bun run ci pass at this commit, checked out on its own after review round 8, and the test files it changes with the hand-over window's, planner-hold-restart and connection-notice tests, e2e/holding-proxy.test.ts and e2e/reconnect-gaps.test.ts among them (186 tests in 7 files, a fresh PostgreSQL 17 database, the fixtures' own git commits not signed) pass (the checks ran on this tree before this message was rewritten; the tree is the same)
Assistant-verification: playwright e2e/handover.e2e.ts, the new case of a hidden tab frozen and thawed with its connection away for longer than the grace, with CI=1, no retries, one worker: it failed with the client of the previous head (`locked`, `not editable` and `offline notice` at the first tick after the grace) and passes with the grace counted only while the page can be looked at, then shows the notice once the tab is shown while still away
Assistant-verification: playwright e2e/handover.e2e.ts, the frozen-tab case of the owner's Planner with the allowance for the notice and "Reconnected" taken out, 20 repeats with CI=1, one worker, no retries, beside two whole runs of bun test: 20 passed (15.2 minutes); 12 of 12 and 20 of 20 had passed alone with the allowance, and the one whole-suite failure that led here read `locked`, `not editable` and `offline notice` on the frozen tab
Assistant-verification: playwright e2e/handover.e2e.ts, the whole file, at this commit with CI=1, two workers, no retries: 13 passed (4.3 minutes)
User-preference: Deploys must be strictly invisible to users
User-preference: For product behavior questions, use the industry standard for popular projects
Co-authored-by: Alex Lavaee <lavaman131@github.com>
lavaman131 added a commit that referenced this pull request Nov 8, 2026
…proxy that holds requests, and keep a machine shown as connected and asked for its Planner through it, which that test showed neither was

Nothing had run the whole hand-over end to end: the unit and PostgreSQL tests cover each part, and `e2e/restart.e2e.ts` stops and starts one server. `e2e/handover.e2e.ts` starts server A, which holds the writer lease, and server B, which waits beside it in standby, puts a proxy in front, and sends A SIGTERM under open tabs. The page is sampled every 25 ms and must say nothing of the connection or the machine, never reload, keep what it holds, and keep offering what it offered when the hand-over began, part by part: Build plan, what Chat says before anyone has spoken, what the composer lets a person do, the document's lock, and a comment being written with its offer to send it to Chopin; the first request the proxy answers after the lease row is released is timed and the test fails above the design's 750 ms budget.

Its cases: a person types without stopping, with an unsent message in the composer, while a paired machine's Planner is attached (the machine's button never leaves connected, every word arrives once and in order, an MCP session still works, a missing asset is a 404); messages to the Planner before, during the turn wait and after the cut, and a comment sent to it, answered once and in the order accepted, for the sender, a teammate's tab and a tab opened afterwards; a message sent from an open child, which keeps the child; a message sent while the machine's Planner is away through the whole cut, which shows Sent and is Delivered once, with one reply, when it is back; a comment being written, which is still open with its words and its offer to Chopin after the deploy, and a comment started after it is offered the same; and a Planner attached under its owner's sign-in with a question left open, with the owner's tab in front, really in the background, frozen for a moment, and with a member's answer given before the client is back, in each of which the client's own question ends answered, with the answer, and no turn of its own follows; and one whose client never resumes, whose answer reaches the Planner once as a turn when it attaches again; and a question answered while the old process waits for the turn that asked it, which reaches that turn and starts no second one.

The proxy is `e2e/holding-proxy.ts`, which does what the reference Caddyfile does (readiness checks every 250 ms, a request held up to 5 s, a refused dial tried again every 100 ms, a WebSocket kept until a side closes it), because CI cannot be assumed to have Caddy or a container; `e2e/holding-proxy.test.ts` holds it to that, and `CHOPIN_E2E_PROXY=caddy` runs the reference proxy in its container instead. Both pass, and both are held to the 750 ms budget.

Measured, release to first answer (the budget is 750 ms, the tab's grace 1.5 s), on the stack rebased onto the plain-words change (adb17d0), this commit's tree: 33 hand-overs behind the in-test proxy in three runs of `handover.e2e.ts` with `message-delivery.e2e.ts`, all passing, 128 to 434 ms, median 243, and 11 more in the whole browser-suite run, 118 to 366 ms. Before the rebase, measured on the stack as first built and not repeated since: 88 hand-overs in eight consecutive runs, 131 to 390 ms; 22 behind the reference Caddy in two runs, 117 to 361 ms; not one of about 332 to 1,134 requests per hand-over answered anything but 2xx. The owner's login, which the Planner's client waits for, came back after the release in 378 to 730 ms with the tab in front (taken back on the client's second to fourth try), 731 to 933 ms (and 1.74 s once) with the tab really in the background, and 3.03 to 3.25 s, 29 to 253 ms after it thawed, with it frozen for 3 s after the release: all inside the 10 s window.

The test found two things the unit tests could not, fixed here. The new process answered the machine connector's first Planner poll with no Planner to run, because which documents are in use lived in the old process, so the connector, as the real one does, stopped the backend and the document showed the machine as stepped away; each restored machine hold now counts as the document being in use. And a page request held through the cut is answered before the connector has opened its connection again, which listed no machine for about 60 ms in about one run in five; a machine whose Planner backend is attached, or expected back within its window, is now listed as connected and is the document's machine, and a unit test fails without it. A flake in an earlier test, which looked for the instant the lease is free and could miss it on a busy machine, now takes a new owner as the release seen late.

The hidden-tab case is real now. Playwright makes every page it drives look focused and in front, which also keeps its timers at full speed, so a tab it launched cannot be hidden however many pages are in front of it. `e2e/background-browser.ts` starts a headless Chromium itself, drives it over the DevTools protocol with Playwright's overrides off (`noDefaults`), and puts a second page in front: the owner's tab then reads `hidden` and its 25 ms timer fires about once a second (the test checks both before the deploy), and `Page.setWebLifecycleState` freezes it as the browser does a tab it has left alone. No display is needed. A frozen tab sends nothing, so the owner's login, and with it the client's `resume`, waits for the tab to run again: a freeze longer than the window is the design's bounded exception. The browser also ends the sockets of a page it freezes (they close 1006, deploy or none) and the tab learns of it when it runs again. A hidden or frozen page counts the grace of a lost connection only while it can be looked at (the first commit of the stack), so the tab says nothing of being offline, and the test holds it to what it holds every tab to, except that what it offers reads as it is until its sockets are open again (Build plan is gone meanwhile): the allowance for the notice and "Reconnected" that the case once had is gone. A second case freezes a hidden tab and thaws it with its connection away for longer than the grace and expects neither the notice nor the lock, then shows the tab while it is still away and expects the notice. Chromium's intensive throttling of a tab hidden for more than five minutes was not measured. A question a client holds open is resolved as the design says: the client resumes with it still waiting and its own request receives the answer, once, with no turn of its own (a fourth case has the client never resume, and the answer reaches the Planner once as a turn when it attaches again).

Review round 2 adds the case of a message sent while the old process is cutting. A person's page presses Enter without a pause, one message after another, from before the deploy to after it. The cut takes a few milliseconds when nothing holds it, so the test holds the lock every fenced write of the writer lease takes (`pg_advisory_lock`, as `#assertLease` takes it) from a connection of its own, before SIGTERM and while nothing is being written; the old process then stops taking requests, stands at the commit of its cut with its sockets open, and the page keeps on sending for 1.5 s. Every message ends marked Delivered, once, in order, with one reply and a saved row that ended; none says Couldn't send at any moment (the page's probe now also watches for the line a message that could not be sent carries); the first answer comes within the budget. With the old process answering such a frame with a refusal instead of nothing, the test fails with Couldn't send; with what the process does now it passed in eight of eight runs, 19 to 21 messages pressed while the cut stood still.
Review round 5. `e2e/holding-proxy.test.ts` took the port after a live upstream's for a server that starts late, and could find it taken (`EADDRINUSE`; the test as it was failed once in 45 runs, three at a time). Every port now comes from the system: the proxy listens on port 0 and says where (`HoldingProxy.port`; the upstreams are read each time they are used, so one that starts late is given its port when it has), an upstream is started on port 0, and a slot that is not there is a held listener that hangs up on every connection, so that nothing else can take its port meanwhile.

Final review. The test asserted only the gap from the release to the proxy's first answer, which a slow socket upgrade or admission does not move, and the tab's quiet reconnect (five seconds) would hide a reconnect that took up to that long. The page's probe now records, for every socket the cut closes with 1012, when it opened again, and the time from the `session:restarting` frame to the next `session:hello`, and `expectGap(done, ...tabs)` fails any of them above the tab's grace (1.5 s) for every open tab of every hand-over in the file, besides the proxy's 750 ms. A tab left in the background or frozen by the browser is not held to it, because the browser holds its timers and ends its sockets, which is not the deploy; its teammate's tab is. Measured in the whole browser suite, 12 hand-overs: each socket the cut closed opened again 179 to 979 ms after its close (31 sockets, median 579), the new process's hello came 206 to 950 ms after the restart frame (against the tab's 1.5 s), and the proxy's first answer 131 to 396 ms after the release (against 750 ms), with no request answered anything but 2xx.

Review round 7. The probe paired each reopening with the oldest close of the same path, so two sockets of one path (a document and an open child) that came back in another order than they closed were paired crosswise: a root closing at 0 ms and opening at 1,600 ms with a child closing at 100 ms and opening at 250 ms was read as gaps of 250 and 1,500 ms, both within the grace, though the root's was 1,600; and the restart-to-hello time was the first restart frame to the first hello of any socket. The page now only records what each socket did (an open, a close with 1012, a `session:restarting` frame, a `session:hello` frame), each with the kind of socket that did it (its path and its document, which a reconnect keeps), and `e2e/reconnect-gaps.ts` pairs them, per kind, outside the browser: the gap of a kind is from its first close to its last opening, so sockets that cannot be told apart are held to the slowest, never the fastest, and a kind that did not open again, or that greets and was not greeted again, fails. `e2e/reconnect-gaps.test.ts` holds the pairing to that scenario, and to the cases of a socket that never opens, a hello that is slow or never comes, and the sidebar's socket, which says no hello.

Design: zero-downtime upgrades (`docs/zero-downtime-upgrades-design.md`, design branch), "Stage 1: invisible restarts on one server", "Tests", "New end-to-end test (does not exist today)", and "Remote Planner budget".

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run 5b88ff0d-ea65-4f3f-8695-3698899c6eb2)
Assistant-verification: bun run fix passed: formatted, 0 oxlint errors (73 existing warnings, none from these files)
Assistant-verification: bun run types passed: every package and e2e
Assistant-verification: bun run ci passed: dprint, oxlint, tokens, design contract, design record and Impeccable show no new findings
Assistant-verification: bun test e2e/holding-proxy.test.ts passed: 5 pass, 0 fail
Assistant-verification: git rebase onto origin/main adb17d0 (#794): one textual conflict, apps/server/src/chat/service.ts (the queue-full wording), resolved by hand; bun run types and bun run ci pass on each of the ten commits, and the tests each commit touches pass on a fresh database per commit (two timing flakes passed on rerun)
Assistant-verification: planner-dispatch machine-presence test failed with the routes and attachments changes stashed, and passed with them
Assistant-verification: handover.e2e.ts failed before the in-use fix (the connector was told to run no Planners and the page said the machine stepped away), and passed after
Assistant-verification: playwright e2e/sidebar-decision-counts.e2e.ts passed 13 of 13 on 831a348, failed 4 of 13 on this stack before the first commit's fix to its socket glob (measured before the rebase), and passes 13 of 13 in each of three runs on the rebased head
Assistant-verification: bun test (whole repository, a git config that does not sign the fixture commits the connector tests make, a fresh disposable PostgreSQL) on this commit's tree: 7687 pass, 17 skip, 0 fail, 903 files; bun test apps/server/src/storage/postgres on a fresh database: 177 pass, 0 fail; bun run fix (no diff), bun run types and bun run ci pass
Assistant-verification: whole browser suite on the rebased head (the client built as CI builds it, CI=1, four workers, no retries, nothing else on the ports), four runs: 962 passed, 6 skipped, 1 failed (columns.e2e.ts:132, 12 of 12 alone); 961 passed, 6 skipped, 2 failed (document-creation.e2e.ts:137, "Response has been disposed" in the test's own route, 12 of 12 alone; sidebar-decision-counts.e2e.ts:755, 13 of 13 in each of three runs of its file); 963 passed, 6 skipped, 0 failed; and on this commit's tree, with the Chat change that lets a message's "Couldn't send" give way to the offline notice, 964 passed, 6 skipped, 0 failed
Assistant-verification: playwright e2e/demo-collaborators.e2e.ts, the whole file with CI=1, no retries, one worker: failed in 9 of 16 runs on the stack without the first commit's fix and in 2 of 16 on 831a348; passed in 12 of 12 with it (measured before the rebase)
Assistant-verification: docker build --no-cache of this commit's tree passed: initial JavaScript 259037 B raw / 82839 B gzip as the container measures it, against a budget of 259500 B / 83000 B (the budget file is unchanged)
Assistant-verification: bun run design:behavior with CI=1 on this commit's tree: 22 passed; the development startup and diagram gallery suites were not repeated after the rebase; design:test, which CI does not run, fails its screenshot comparisons on 831a348 as well
Assistant-verification: playwright e2e/handover.e2e.ts and e2e/message-delivery.e2e.ts with CI=1, no retries, one worker, databases of its own, three repeats on this commit's tree: 60 passed, the first answer 128 to 434 ms after the release (median 243, 33 hand-overs), 0 requests not answered 2xx
Assistant-verification: playwright e2e/handover.e2e.ts failed all nine cases then in it (Build plan gone, Chat's hint changed, the composer's Mention docs unavailable) with the client as it was before the fix in the first commit of the stack, and passed with it; its frozen-tab case allows nothing of being offline
Assistant-verification: a real background tab (headless Chromium started by e2e/background-browser.ts, Playwright's overrides off) read hidden and fired a 25 ms timer 4 times in 4 s; in Playwright's own browser, headless or headed, minimised, in a second tab or window, with its throttling flags removed and with focus emulation off, it read visible and fired it 160 times
Assistant-verification: playwright e2e/handover.e2e.ts, the case of messages sent while the old process is cutting, with CI=1, no retries, one worker: 8 passed in 8 runs (19 to 21 messages pressed while the cut stood still, the first answer 211 to 342 ms after the release); it failed with the old process answering a chat:send frame after the cut began with a refusal (Couldn't send shown on a message)
Assistant-verification: bun test e2e/holding-proxy.test.ts passed 20 runs in a row, 20 runs two at a time and 45 runs three at a time, with none failing; the test as it was before this change failed 1 of 45 runs three at a time with `Failed to start server. Is port ... in use?` (EADDRINUSE)
Assistant-verification: playwright e2e/handover.e2e.ts, the first case, with CI=1 and the tab's restart delay set to 2.5 s in the built client: the spec of the previous commit passed (the proxy's first answer 298 ms) and this one failed (the sockets opened again after 2504 and 2520 ms, hello 2526 ms after the restart frame)
Assistant-verification: playwright e2e/handover.e2e.ts with CI=1, no code changed, at this commit: the first case and the case of a message from an open child passed (sockets opened again after 245 to 866 ms, hello after 264 to 394 ms, the proxy's first answer 246 and 225 ms); the child case, which has two sockets of one path, failed before the probe paired each socket's close with its own open
Assistant-verification: bun test e2e/reconnect-gaps.test.ts (6 tests) passes; the reviewer's scenario, run through the pairing as the page did it before (the oldest close of the path), reads 250 and 1,500 ms, both within the 1.5 s, where the root's real gap is 1,600 ms, and through the new pairing reads 1,600 and 150 ms and fails
Assistant-verification: playwright e2e/handover.e2e.ts with CI=1, two workers, no retries, at this commit: 12 passed in 3.7 minutes, the proxy's first answer 128 to 361 ms after the release, each socket the cut closed opened again 216 to 952 ms after its close (15 tab readings), the hello 234 to 964 ms after the restart frame
Assistant-verification: bun run types and bun run ci pass at this commit, checked out on its own after review round 8, and the test files it changes with the hand-over window's, planner-hold-restart and connection-notice tests, e2e/holding-proxy.test.ts and e2e/reconnect-gaps.test.ts among them (186 tests in 7 files, a fresh PostgreSQL 17 database, the fixtures' own git commits not signed) pass (the checks ran on this tree before this message was rewritten; the tree is the same)
Assistant-verification: playwright e2e/handover.e2e.ts, the new case of a hidden tab frozen and thawed with its connection away for longer than the grace, with CI=1, no retries, one worker: it failed with the client of the previous head (`locked`, `not editable` and `offline notice` at the first tick after the grace) and passes with the grace counted only while the page can be looked at, then shows the notice once the tab is shown while still away
Assistant-verification: playwright e2e/handover.e2e.ts, the frozen-tab case of the owner's Planner with the allowance for the notice and "Reconnected" taken out, 20 repeats with CI=1, one worker, no retries, beside two whole runs of bun test: 20 passed (15.2 minutes); 12 of 12 and 20 of 20 had passed alone with the allowance, and the one whole-suite failure that led here read `locked`, `not editable` and `offline notice` on the frozen tab
Assistant-verification: playwright e2e/handover.e2e.ts, the whole file, at this commit with CI=1, two workers, no retries: 13 passed (4.3 minutes)
User-preference: Deploys must be strictly invisible to users
User-preference: For product behavior questions, use the industry standard for popular projects
Co-authored-by: Alex Lavaee <lavaman131@github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant