diff --git a/apps/server/src/chat/remote-planner.test.ts b/apps/server/src/chat/remote-planner.test.ts index 8c0df0536..562844a87 100644 --- a/apps/server/src/chat/remote-planner.test.ts +++ b/apps/server/src/chat/remote-planner.test.ts @@ -184,10 +184,8 @@ test("with no attached Planner, @chopin and invoke_planner are refused with how saves++; await persist(chat); }; - let message = - "No Planner is attached to this document. To attach one, connect a Planner client to " - + `${room.url} over the agent connection from a session in a checkout of octo-org/score; ` - + "that session becomes this document's Planner."; + let message = "Connect a machine to get a reply. Connect an agent client to " + + `${room.url} over the agent connection from a session in a checkout of octo-org/score.`; expect(await room.send("@chopin draft the plan")).toMatchObject({ kind: "session:error", @@ -290,9 +288,8 @@ test("once the attached Planner's owner sign-in expires, @chopin is refused with expect(await room.send("@chopin draft the plan")).toMatchObject({ kind: "session:error", code: "planner-not-attached", - message: "No Planner is attached to this document. To attach one, connect a Planner client to " - + `${room.url} over the agent connection from a session in a checkout of octo-org/score; ` - + "that session becomes this document's Planner.", + message: "Connect a machine to get a reply. Connect an agent client to " + + `${room.url} over the agent connection from a session in a checkout of octo-org/score.`, }); expect(context.chat.entries).toEqual([]); expect(context.chat.waiting).toEqual([]); @@ -677,7 +674,7 @@ test("a dropped link's run card says disconnected, not stopped, and its open que } // Nobody else can take the Planner while its account's client may return. expect(await Chat.invoke(context, RIVAL, "Anything?")).toBe("planner-not-attached"); - expect(await context.plannerNotAttached!()).toContain("is disconnected"); + expect(await context.plannerNotAttached!()).toContain("machine is offline"); let client = await down.reconnect(); expect(context.plan.records.size).toBe(1); diff --git a/apps/server/src/harness/remote/attachments.ts b/apps/server/src/harness/remote/attachments.ts index cdb0b47e4..e66a426ea 100644 --- a/apps/server/src/harness/remote/attachments.ts +++ b/apps/server/src/harness/remote/attachments.ts @@ -116,40 +116,41 @@ type PendingAttach = { }; /** - * What `@chopin` and `invoke_planner` say when no Planner is attached to the document: with - * `machines`, when Chopin runs Planners on people's machines, how to get one there. + * What `@chopin` and `invoke_planner` say when no Planner is attached to the document, in plain + * words that name none of Chopin's own terms: a machine is needed. With `machines`, when Chopin + * runs Planners on people's machines, how to get one there; otherwise how to connect a client. */ export function notAttachedMessage(url: string, repository: string, machines = false): string { if (machines) { - return "No Planner is attached to this document. Connect a machine that can run Planners " - + "(start `chopin connect` with `--planner-command`), or choose one with the machine " - + "button in Chat."; + return "Connect a machine to get a reply. Choose one with the machine button, " + + "or run `chopin connect` on a machine that can reply."; } - return `No Planner is attached to this document. To attach one, connect a Planner client to ` - + `${url} over the agent connection from a session in a checkout of ${repository}; ` - + `that session becomes this document's Planner.`; + return `Connect a machine to get a reply. Connect an agent client to ${url} over the agent ` + + `connection from a session in a checkout of ${repository}.`; } /** * What `@chopin` and `invoke_planner` say while the document's Planner is disconnected: it has - * not stopped, its work and open Decisions are kept, and it carries on when it reconnects. + * not stopped, the work is kept, and it carries on when the machine is back. It names only its + * owner's login, never the machine. */ export function disconnectedMessage(login: string | undefined): string { - return `This document's Planner${login ? ` (@${login})` : ""} is disconnected, so it cannot take ` - + "a message now. Its workflows and open Decisions are kept and answers still reach it, " - + "and it carries on when it reconnects."; + return `${login ? `@${login}'s machine` : "The machine"} is offline, so it can't reply now. ` + + "Your work is kept and picks up when it's back."; } /** * What `@chopin` and `invoke_planner` say when the Planner a machine is starting is not ready: - * wait a moment, or, once its backend has failed to start more than once, why it cannot. + * still connecting to it, wait a moment, or, once its backend has failed to start more than once, + * that it can't connect and why, as the machine reported it. It speaks of the machine, as the + * document's machine button does, and names only its owner's login, never the machine. */ export function startingMessage(login: string, failure?: Session.PlannerStartFailure): string { if (failure) { - return `This document's Planner can't start on @${login}'s machine: ${failure.reason} ` + return `Can't connect to @${login}'s machine: ${failure.reason} ` + `(${failure.attempts} attempts in a row). It keeps trying; try again once that is fixed.`; } - return `This document's Planner is still starting on @${login}'s machine. Try again in a moment.`; + return `Still connecting to @${login}'s machine. Try again in a moment.`; } export function forbiddenMessage(repository: string): string { diff --git a/apps/server/src/harness/remote/planner-dispatch.test.ts b/apps/server/src/harness/remote/planner-dispatch.test.ts index c7bd36aca..59b47d965 100644 --- a/apps/server/src/harness/remote/planner-dispatch.test.ts +++ b/apps/server/src/harness/remote/planner-dispatch.test.ts @@ -23,7 +23,7 @@ import { } from "../../testing/agent-link"; import { createHumanInput } from "../atomic/human-input"; import { hostInputRoom } from "../../testing/decisions"; -import { startingMessage } from "./attachments"; +import { disconnectedMessage, notAttachedMessage, startingMessage } from "./attachments"; import { MachinePlanners } from "./machine-planners"; import type { RepositoryPage } from "../../github/client"; @@ -538,7 +538,7 @@ describe("a Planner backend that keeps failing to start", () => { it("is what an @chopin request is told while the Planner cannot start, instead of to try again in a moment", () => { expect(startingMessage("mona")).toBe( - "This document's Planner is still starting on @mona's machine. Try again in a moment.", + "Still connecting to @mona's machine. Try again in a moment.", ); expect( startingMessage("mona", { @@ -547,11 +547,35 @@ describe("a Planner backend that keeps failing to start", () => { at: "2026-11-04T02:00:00.000Z", }), ).toBe( - "This document's Planner can't start on @mona's machine: unsafe-checkout: refused " + "Can't connect to @mona's machine: unsafe-checkout: refused " + "(3 attempts in a row). It keeps trying; try again once that is fixed.", ); }); + it("refuses in plain words that name none of Chopin's own terms, only a machine and the person's work", () => { + let internal = /planner|attach|\bheld\b|\bdecisions?\b|@chopin/i; + let at = "2026-11-04T02:00:00.000Z"; + let messages = [ + notAttachedMessage( + "https://chopin.test/documents/octo-org/score/plan", + "octo-org/score", + true, + ), + notAttachedMessage("https://chopin.test/documents/octo-org/score/plan", "octo-org/score"), + disconnectedMessage("mona"), + disconnectedMessage(undefined), + startingMessage("mona"), + // The reason is the machine's own words, kept as reported, so a neutral one is used here. + startingMessage("mona", { attempts: 3, reason: "exit 127", at }), + ]; + for (let message of messages) expect(internal.test(message), message).toBe(false); + expect(notAttachedMessage("u", "r", true)).toStartWith("Connect a machine to get a reply."); + expect(notAttachedMessage("u", "r")).toStartWith("Connect a machine to get a reply."); + expect(disconnectedMessage("mona")).toBe( + "@mona's machine is offline, so it can't reply now. Your work is kept and picks up when it's back.", + ); + }); + it("keeps what is told short and plain", async () => { let { machine, choose, runtime, channel } = await dispatchServer(); let laptop = await machine("member", "U_member", "laptop"); diff --git a/apps/server/src/mcp/hosted.test.ts b/apps/server/src/mcp/hosted.test.ts index 4e5cff453..1fcd931ac 100644 --- a/apps/server/src/mcp/hosted.test.ts +++ b/apps/server/src/mcp/hosted.test.ts @@ -374,11 +374,9 @@ describe("the hosted MCP adapter", () => { ).toEqual({ kind: "refused", code: "planner-not-attached", - message: - "No Planner is attached to this document. To attach one, connect a Planner client to " + message: "Connect a machine to get a reply. Connect an agent client to " + "https://chopin.test/documents/octo-org/score/release-readiness over the agent " - + "connection from a session in a checkout of octo-org/score; that session becomes this " - + "document's Planner.", + + "connection from a session in a checkout of octo-org/score.", }); } finally { await Service.close(opened.plan); diff --git a/apps/server/src/mcp/invoke.test.ts b/apps/server/src/mcp/invoke.test.ts index 7156a4795..33b31b248 100644 --- a/apps/server/src/mcp/invoke.test.ts +++ b/apps/server/src/mcp/invoke.test.ts @@ -127,7 +127,7 @@ test("invoke_planner exposes each refusal as an object code", async () => { }); test("invoke_planner carries a refusal's message beside its code", async () => { - let message = "No Planner is attached to this document. Connect a Planner client to attach one."; + let message = "Connect a machine to get a reply. Connect an agent client to the document."; let mcp = endpoint({ invoke: async () => ({ kind: "refused", code: "planner-not-attached", message }), }); diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 64e578bf5..6e6abe455 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -172,7 +172,7 @@ Rectangular controls use a restrained four-step radius scale: small for grips an - **Buttons:** Primary, secondary, outline, ghost, and destructive tiers (outline is a white button with a quiet 12% edge, for a single action inside a card) share one shape and compact text treatment. Resting outlines use at most 15% opacity, including primary and danger treatments; secondary buttons keep the lighter 7% passive edge. Sizes follow shadcn's Nova scale: `btn-xs` 24px, `btn-sm` 28px, `btn-md` 32px and `btn-lg` 36px, with icon-only `btn-icon-xs` (24px) and `btn-icon` (28px). Extra-small and small buttons use the extra-small text role, a 4px icon gap and the medium radius; medium and large use the small text role, a 6px gap and the large radius. Every button chooses a size. Hover and pressed states change the relevant color role. Keyboard focus shows the brand focus ring; a pointer press hides it on buttons and menus, leaves the clicked Chat composer only its quiet brand edge, and other text fields keep theirs. - **Recovery actions:** Use `btn-outline-danger` on danger surfaces and `icon-danger` for error indicators. Hover and pressed states stay within the danger palette. - **Fields and selections:** White fields use the medium radius, control edge, and subtle resting shadow. Invalid fields use the destructive role. Disabled controls use neutral fill and muted text. -- **Navigation:** The current location uses a white surface and primary ink. Other items stay quieter and reveal their affordance on hover or focus. A navigation item that leads to a full page discloses progressively: a click opens a popover with the essential information and the most common choice (Machines lists the machines you can use, yours and those teammates share, sorted together newest first, and sets your default), plus a link to the full page. A document's own machine, which plans and builds it, is the exception: an icon-only Local or Cloud status button left of Build plan, never in the Chat composer. +- **Navigation:** The current location uses a white surface and primary ink. Other items stay quieter and reveal their affordance on hover or focus. A navigation item that leads to a full page discloses progressively: a click opens a popover with the essential information and the most common choice (Machines lists the machines you can use, yours and those teammates share, sorted together newest first, and sets your default), plus a link to the full page. A document's own machine, which plans and builds it, is the exception: an icon-only Local or Cloud status button left of Build plan, never in the Chat composer. It also carries the state of the document's Planner, which says nothing while it is attached: the dot becomes a thin petrol spinner while the machine is being connected to, the warning role where it cannot be, a muted grey while the Planner is away, and no dot at all where no machine is connected, the same bare tertiary icon as a button with no machine, never the machine's own success green. The button's name and the top of its menu say it in words that speak of the machine (_Connecting to Build box_, _Can't connect to Build box_ with the reason and what has been tried, _Build box is offline. Your work is kept._, _Build box stepped away. Your work is kept until 10:31 AM._, _No machine is connected._). Status words are plain and name none of Chopin's own terms, such as the Planner, attaching or Decisions, nor Chopin itself; `status-copy.test.tsx` keeps it so. - **Menus and popovers:** Every menu follows shadcn's Nova menu. The panel has 4px padding, the large radius, and a hairline ring with the raised shadow. Items are 28px: 4px by 6px padding, a 6px icon gap, the medium radius and the small text role. Group labels use the extra-small text role in medium weight and tertiary ink with the item's inset. Dividers are 1px, run through the panel's padding to both edges and keep 4px above and below. A selected item is tinted with the selected surface, never marked with a check. Secondary detail sits right-aligned in tertiary ink on the same line. - **Icons:** Icon size is contextual: the container chooses it, not the call site. 16px is the default for menus, medium and large buttons, icon buttons and standalone interface icons. 14px sits beside small text: badges, pills, capsules and small buttons. 12px is for compact controls: extra-small buttons and the compact sidebar Search, disclosure, add-project, document-creation and document-action controls, which keep 24px targets. Icons in one row share one size and one column. - **Badges and status graphics:** Neutral, success, warning, and danger use paired semantic surface, icon, graphic, and text roles. A badge combines an icon and label in a pill. Merged (plum) is the fifth quad, reserved for finished GitHub work: a merged pull request or a completed issue, as GitHub colours them. @@ -216,4 +216,4 @@ Important visual dimensions and colors belong to the shared theme. Use finite co SeeCode uses a fixed canvas typography catalog in `packages/diagrams/src/core/tokens.mjs`: node labels 14px, subtitles 12px, tags 10px and edge labels 12px. Measurement and SVG drawing variables derive from the same catalog, so changing type updates boxes and routing together. These are coordinate-system roles; interface controls retain the fluid UI scale. Architecture graphs may use a narrower derived layout, targeting the smaller of 1100px or the document lane, while preserving saved source, node identities, connections and playback. Fitting has a readability floor; dense diagrams keep native scrolling and zoom controls below the diagram. -`InlineNotice` reserves space in the page flow for recoverable failures, using the shared semantic surface, icon and text roles. Navigation retry controls align within it; notices never cover document content. Connection labels and recovery buttons use the same compact text role. +`InlineNotice` reserves space in the page flow for recoverable failures, using the shared semantic surface, icon and text roles. Navigation retry controls align within it; notices never cover document content. A lost connection is one such notice at the top of the document's content (or of Chat where Chat is all that shows), after a short grace period so a blip shows nothing: _You're offline. Your changes are kept and will sync when you reconnect._ It eases open and shut in the shared collapse motion, and Retry joins it only once automatic reconnects have failed repeatedly. It is one element above Chat and the document wherever they take turns, so switching between them neither restarts its grace period nor announces it again; on a phone it floats just under the top bar, and its height is added to the panes' top inset so it never covers the first of their content. Nothing visible follows it when the connection returns, and nothing about connection state sits in the composer. The composer's attached top strip takes the same warning roles for a machine that cannot be connected to, with the accent link _How to fix_; it too eases open and shut, and a state that clears leaves quietly, with no confirmation. A person who has no machine button has nowhere else to see the Planner's state, so for them the strip carries every state in the machine-first words, and draws the reason for a machine that cannot be connected to exactly as the machine reported it. A screen reader hears each of these once, from one polite live region apiece, never from the strip or the button as well. Reduced motion settles each of them at once. diff --git a/apps/web/src/build-model.test.ts b/apps/web/src/build-model.test.ts index 97ba14055..e9f5cb176 100644 --- a/apps/web/src/build-model.test.ts +++ b/apps/web/src/build-model.test.ts @@ -309,7 +309,7 @@ describe("first build", () => { }); }); - it("keeps Build plan inert while no Planner is attached to draft its tasks", () => { + it("keeps Build plan inert while no machine is connected to draft its tasks", () => { let context = { state: idle, live: false, @@ -320,14 +320,14 @@ describe("first build", () => { }; expect(buildLook(fresh, { ...context, plannerDetached: true })).toEqual({ kind: "plan", - blocked: "Connect a Planner to this document to build", + blocked: "Connect a machine to this document to build", }); expect(buildLook(fresh, context)).not.toMatchObject({ - blocked: "Connect a Planner to this document to build", + blocked: "Connect a machine to this document to build", }); }); - it("says a disconnected Planner must reconnect to draft tasks, not that none is connected", () => { + it("says a disconnected machine must reconnect to draft tasks, not that none is connected", () => { let context = { state: idle, live: false, @@ -339,11 +339,11 @@ describe("first build", () => { }; expect(buildLook(fresh, { ...context, plannerDisconnected: true })).toEqual({ kind: "plan", - blocked: "Reconnect the Planner to build", + blocked: "Reconnect the machine to build", }); - // A Planner that detached, or never attached, is still told to connect one. + // A machine that stepped away, or was never connected, is still told to connect one. expect(buildLook(fresh, context)).toMatchObject({ - blocked: "Connect a Planner to this document to build", + blocked: "Connect a machine to this document to build", }); }); @@ -363,17 +363,17 @@ describe("first build", () => { "build-box is offline. Start chopin connect on it, or choose another machine.", ), ).toBe("build-box is offline"); - expect(draftRefusalCopy("No Planner is attached to this document. To attach one, …")) - .toBe("Connect a Planner to this document to build."); + expect(draftRefusalCopy("Connect a machine to get a reply. Connect an agent client to …")) + .toBe("Connect a machine to this document to build."); expect(draftRefusalCopy("anything", "planner-not-attached")) - .toBe("Connect a Planner to this document to build."); - // Chopin says so when the Planner is attached but its connection dropped. + .toBe("Connect a machine to this document to build."); + // Chopin says so when the machine was connected but its connection dropped. expect( draftRefusalCopy( - "This document's Planner (@mona) is disconnected, so it cannot take a message now.", + "@mona's machine is offline, so it can't reply now. Your work is kept and picks up when it's back.", "planner-not-attached", ), - ).toBe("Reconnect the Planner to build."); + ).toBe("Reconnect the machine to build."); }); it("returns to the button when drafting or starting fails", () => { diff --git a/apps/web/src/build-model.ts b/apps/web/src/build-model.ts index 141b65807..132d95af7 100644 --- a/apps/web/src/build-model.ts +++ b/apps/web/src/build-model.ts @@ -370,14 +370,21 @@ export function startedBy(snapshot: Snapshot | undefined, userId: string): strin return snapshot.startedBy; } +/** + * Why Build plan waits when no machine can draft the tasks, in words that speak of a machine as + * the machine button does. The first one also opens how to connect one, so the button keys on it. + */ +export const CONNECT_MACHINE_TO_BUILD = "Connect a machine to this document to build"; +const RECONNECT_MACHINE_TO_BUILD = "Reconnect the machine to build"; + /** * Plain copy for a refusal the server explains, or `undefined` for one it does * not, which reads as Chopin failing to draft. */ export function draftRefusalCopy(message: string, code?: string): string | undefined { - if (/Planner.* is disconnected/.test(message)) return "Reconnect the Planner to build."; - if (code === "planner-not-attached" || /No Planner is attached/.test(message)) { - return "Connect a Planner to this document to build."; + if (/ is offline, so it can't reply now/.test(message)) return `${RECONNECT_MACHINE_TO_BUILD}.`; + if (code === "planner-not-attached" || /Connect a machine to get a reply/.test(message)) { + return `${CONNECT_MACHINE_TO_BUILD}.`; } if (/accepted comments/.test(message)) { return "Update the document for accepted comments first."; @@ -630,9 +637,7 @@ function planBlocker(snapshot: Snapshot | undefined, context: BuildContext): str return "Chopin isn’t running for this document"; } if (phase.kind === "drafting" && context.plannerDetached) { - return context.plannerDisconnected - ? "Reconnect the Planner to build" - : "Connect a Planner to this document to build"; + return context.plannerDisconnected ? RECONNECT_MACHINE_TO_BUILD : CONNECT_MACHINE_TO_BUILD; } } diff --git a/apps/web/src/build-plan-button.tsx b/apps/web/src/build-plan-button.tsx index 3e845c332..133090fea 100644 --- a/apps/web/src/build-plan-button.tsx +++ b/apps/web/src/build-plan-button.tsx @@ -17,6 +17,7 @@ import { buildPhase, buildRefusalCopy, buildTabReady, + CONNECT_MACHINE_TO_BUILD, draftRefusalCopy, firstBuildStep, hasBuilt, @@ -468,7 +469,7 @@ export function BuildPlanButton( } let connectionReason = ("blocked" in look ? look.blocked : undefined) ?? ("tip" in look ? look.tip : undefined); - if (canEdit && documentURL && connectionReason?.startsWith("Connect a Planner")) { + if (canEdit && documentURL && connectionReason?.startsWith(CONNECT_MACHINE_TO_BUILD)) { return ( <> Connect Chopin}> diff --git a/apps/web/src/chat/chat.tsx b/apps/web/src/chat/chat.tsx index f20f2e880..feb868b28 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -27,7 +27,7 @@ import { import { chatDraftKey } from "../demo-actor"; import { canWithdraw } from "./withdraw"; import type { DraftInputHandle } from "./draft-input"; -import { PlannerAttachment } from "./planner-attachment"; +import type { PlannerNotice } from "./planner-status"; import { TypingReporter } from "./typing"; import { ChatComposer } from "./composer"; @@ -84,7 +84,7 @@ import type { ExcerptCorrectionAction } from "../conversation-plan/analysis-over import type { ChatDestination } from "../conversation-plan/source"; import type { ResearchOfferControls } from "./research-offer"; -import type { Chat as Wire, ConversationPlan, Session } from "@chopin/protocol"; +import type { Chat as Wire, ConversationPlan } from "@chopin/protocol"; import type { Repository } from "../api"; import type { ResearchLaunchBlock, ResearchLaunchResult } from "@chopin/editor"; import type { ChatCommand } from "./commands"; @@ -139,10 +139,13 @@ export type ChatProps = { notice?: string; /** One calm line shown while the transcript has loaded and is still empty. */ emptyNotice?: string; - /** Who holds the document's remote Planner; absent under every other harness. */ - planner?: Session.PlannerAttachment; - /** The viewer's own machine that is starting the Planner, by name, when one is. */ - plannerMachine?: string; + /** + * The strip for the composer's top edge about the document's remote Planner, when its state + * needs one: a machine that cannot be connected to, or, for a viewer with no machine button to + * say it, any state that is not healthy (`plannerPlacement` decides). Absent while it is + * attached, and under every other harness. + */ + plannerStrip?: PlannerNotice; }; function draftKey(room: string, handle: string): string { @@ -212,8 +215,7 @@ export function Chat( people = [], notice, emptyNotice, - planner, - plannerMachine, + plannerStrip, onShowDecisions, onResearch, onShowResearch, @@ -265,8 +267,7 @@ export function Chat( let submission = useRef(undefined); let draftRef = useRef(draft); draftRef.current = draft; - // A reload, including the header's fallback when reconnecting keeps - // failing, must not take an unsent message with it. Kept as it changes + // A reload must not take an unsent message with it. Kept as it changes // rather than on unload, which a browser does not promise to announce. useEffect(() => keepDraft(room, handle, draft), [room, handle, draft]); let pickerId = useId(); @@ -288,14 +289,8 @@ export function Chat( let typingEnabled = composerReady && active && agent && !!participationState?.enabled; // A draft never waits on the connection; only sending does. let draftable = !readonly && !archived; + // The document says when the connection is lost; Chat only holds back what it cannot send. let connectionNotice = useConnectionNotice(!connected); - let connectionLabel = connectionNotice === "none" - ? undefined - : connectionNotice === "offline" || wire?.status === "closed" || wire?.status === "denied" - ? "Offline" - : wire?.status === "connecting" - ? "Connecting…" - : "Reconnecting…"; let effectiveMode = !!agent && (mode || draft.submission?.to === "planner" || addressedOutsideReferences(draft.text, draft.references)); let workingTurn = transcriptReady ? turn : undefined; @@ -719,7 +714,6 @@ export function Chat( {transcriptView}

{notice}

-
); @@ -772,7 +766,7 @@ export function Chat( ? { tone: "info", text: "Research isn’t available in this document" } : !agent ? { tone: "info", text: "Chopin unavailable" } - : undefined} + : plannerStrip} unavailable={archived ? "archived" : readonly ? "readonly" : undefined} references={referencesEnabled ? { @@ -794,8 +788,6 @@ export function Chat( || blockedCommand || (connectionNotice !== "none" && !draftCommand(draft.text))} onSubmit={submit} - connectionLabel={connectionLabel} - onReconnect={connectionNotice === "offline" && wire ? () => wire.reconnect() : undefined} run={agent && (busy || counts.active > 0) ? { action: "stop", @@ -842,7 +834,6 @@ export function Chat( )} } - attachment={} input={{ "aria-activedescendant": commandOpen ? referenceOptionId(commandPickerId, commandActive) diff --git a/apps/web/src/chat/composer.css b/apps/web/src/chat/composer.css index dd0dee4cb..704db3a1f 100644 --- a/apps/web/src/chat/composer.css +++ b/apps/web/src/chat/composer.css @@ -123,6 +123,34 @@ background: var(--color-danger-surface); color: var(--color-danger-text); } +/* Something to act on, in the warning roles; the link keeps the one accent. */ +.chat-composer .composer-notice[data-warning] { + background: var(--color-warning-surface); + color: var(--color-warning-text); +} +.chat-composer .composer-notice-icon { + --color-icon: var(--color-warning-icon); +} +/* The reason a machine gave and what has been tried, each on its own line under the words. */ +.chat-composer .composer-notice-line { + display: block; +} +.chat-composer .composer-notice-link { + color: var(--color-brand-ink); + text-decoration: underline; + text-underline-offset: 0.2em; + white-space: nowrap; +} +/* + * A strip that eases open and shut carries the tuck under the composer itself, so the collapsing + * track holds the whole strip and the composer rises over its last half rem as before. + */ +.chat-composer .composer-notice-slot { + margin-bottom: -0.5rem; +} +.chat-composer .composer-notice-slot .composer-notice { + margin-bottom: 0; +} .chat-composer .composer-unavailable { display: flex; flex-direction: column; @@ -231,129 +259,57 @@ } /* - * Who holds the remote Planner: one quiet line under the composer, aligned with - * the draft's text. The words name the state; the dot only repeats it. + * The remote Planner's status, in the row directly under the composer. A healthy Planner has no + * row: it is a collapsed grid track until there is something to say, and it eases open and shut + * through the shared collapse motion. Quiet states are one muted line in the compact text role; + * a Planner that cannot start is a warning notice as wide as the composer, so what the person + * must act on is the only thing in colour. Petrol is only the working spinner and the link. */ -.chat-composer .composer-planner { +.chat-composer .planner-status-body { + padding-top: calc(var(--spacing) * 2); +} +.chat-composer .planner-status-line { display: flex; - align-items: center; - gap: 0.375rem; - padding: 0.375rem 0.75rem 0; - color: var(--color-text-secondary); + align-items: flex-start; + gap: calc(var(--spacing) * 1.5); + padding-inline: var(--spacing); + color: var(--color-text-tertiary); font-size: var(--text-xs); line-height: var(--text-xs--line-height); } -.chat-composer .composer-planner-label { - min-width: 0; - overflow-wrap: anywhere; -} -.chat-composer .composer-planner-dot { - flex: none; - width: 0.375rem; - height: 0.375rem; - border-radius: var(--radius-full); - background: var(--color-success); -} -.chat-composer .composer-planner[data-planner-state="held"] .composer-planner-dot, -.chat-composer .composer-planner[data-planner-state="disconnected"] .composer-planner-dot, -.chat-composer .composer-planner[data-planner-state="starting"] .composer-planner-dot { - background: var(--color-warning); -} -.chat-composer .composer-planner[data-planner-state="none"] { - color: var(--color-text-tertiary); -} -/* Hollow: nothing holds the Planner. */ -.chat-composer .composer-planner[data-planner-state="none"] .composer-planner-dot { - border: var(--edge-width) solid currentColor; - background: transparent; -} - -/* - * A disconnected Planner says what it kept after its name, in quieter ink, and may wrap, so the - * dot lines up with the first line. - */ -.chat-composer .composer-planner[data-planner-state="disconnected"] .composer-planner-detail { - color: var(--color-text-tertiary); -} - -/* - * A machine whose Planner backend keeps failing to start: still the starting state, so the dot and - * the line sit where they did, but the dot is red and the line has three: what is wrong, in the - * same colour, then why, then how often it was tried in the quietest ink. They wrap, so the dot - * lines up with the first. - */ -.chat-composer .composer-planner[data-planner-failing], -.chat-composer .composer-planner[data-planner-state="disconnected"] { - align-items: flex-start; -} -.chat-composer .composer-planner[data-planner-failing] .composer-planner-dot, -.chat-composer .composer-planner[data-planner-state="disconnected"] .composer-planner-dot { - margin-block-start: calc((1lh - 0.375rem) / 2); -} -.chat-composer .composer-planner[data-planner-failing] .composer-planner-dot { - background: var(--color-danger-graphic); -} -.chat-composer .composer-planner-lead, -.chat-composer .composer-planner-reason, -.chat-composer .composer-planner-meta { - display: block; -} -.chat-composer .composer-planner-lead { - color: var(--color-danger-text); -} -.chat-composer .composer-planner-meta { - color: var(--color-text-tertiary); +.chat-composer .planner-status-line:empty { + display: none; } - -/* Inside the footer row, so losing the connection moves nothing above it. */ -.chat-composer .composer-connection { - display: inline-flex; +.chat-composer .planner-status-text { min-width: 0; + overflow-wrap: anywhere; } -.chat-composer .composer-connection-group { - display: contents; +.chat-composer .planner-status-icon { + --color-icon: var(--color-neutral-icon); } -.chat-composer .composer-connection-label { +.chat-composer .planner-status-spinner { display: inline-flex; - align-items: center; - gap: 0.375rem; - padding-inline: 0.25rem; - color: var(--color-text-secondary); - font-size: var(--text-xs); - line-height: var(--text-xs--line-height); - white-space: nowrap; -} -.chat-composer .composer-connection-label::before { - content: ""; - width: 0.375rem; - height: 0.375rem; - flex: none; - border-radius: var(--radius-full); - background: var(--color-warning); + width: 0.875rem; + height: 0.875rem; + color: var(--color-brand); } -/* The same words, tone and dot as the document's own status in its header. */ -.chat-composer .composer-connection-label[data-offline] { - color: var(--color-destructive-ink); +.chat-composer .planner-status-spinner [data-progress-track] { + stroke: var(--color-selected-strong); } -.chat-composer .composer-connection-label[data-offline]::before { - display: none; -} -@keyframes composer-connection-enter { - from { - opacity: 0; - } +.chat-composer .planner-status-spinner [data-progress-arc] { + stroke: var(--color-brand); } +/* Reduced motion keeps the still ring; its words say it is working. */ @media (prefers-reduced-motion: no-preference) { - .chat-composer .composer-connection-label { - animation: composer-connection-enter var(--duration-fast) var(--ease-out); + .chat-composer .planner-status-spinner { + animation: button-loader-spin calc(var(--duration-base) * 4) linear infinite; } } -/* - * Side by side with the document, its header already offers Reconnect, and - * one state gets one action. Without it in view, as on a phone, Chat does. - */ -[data-workspace-mode="split"] .chat-composer .composer-reconnect { - display: none; +.chat-composer .planner-status-link { + color: var(--color-brand-ink); + text-decoration: underline; + text-underline-offset: 0.2em; + white-space: nowrap; } /* @@ -489,23 +445,8 @@ .chat-composer .composer-chopin-chip:active:not(:disabled) { color: var(--color-brand-active); } - /* Connection state becomes a slim first row inside the pill. */ - .chat-composer .composer-connection-group:has(.composer-connection-label) { - display: flex; - order: 0; - flex-basis: 100%; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - padding: 0.125rem 0.25rem 0 0.5rem; - } - .chat-composer - .composer-connection-group:not(:has(.composer-connection-label)) - .composer-connection { - position: absolute; - } - .chat-composer .composer-connection-group .composer-reconnect { - min-height: var(--control-height-compact); + .chat-composer .composer-notice-slot { + margin-bottom: 0; } /* Status above the pill is one quiet line rather than a tab behind it. */ .chat-composer .composer-notice { @@ -521,6 +462,11 @@ margin-bottom: 0.375rem; background: var(--color-danger-surface); } + /* Something to act on keeps its warning surface, as a failed send keeps its danger one. */ + .chat-composer .composer-notice[data-warning] { + margin-bottom: 0.375rem; + background: var(--color-warning-surface); + } .chat-composer .composer-unavailable { flex-basis: 100%; min-height: 0; @@ -555,6 +501,6 @@ .chat-composer .composer-actions { min-width: 0; } -.chat-composer :is(.composer-left, .composer-connection, .composer-reconnect, .send-action) { +.chat-composer :is(.composer-left, .send-action) { flex: none; } diff --git a/apps/web/src/chat/composer.test.tsx b/apps/web/src/chat/composer.test.tsx index 7f37ab289..33a812613 100644 --- a/apps/web/src/chat/composer.test.tsx +++ b/apps/web/src/chat/composer.test.tsx @@ -6,6 +6,7 @@ import { ChatComposer } from "./composer"; import type { ChatComposerProps } from "./composer"; +/** The markup, with the apostrophe React escapes in text written back, so copy reads as typed. */ function render(props: Partial = {}): string { return renderToStaticMarkup(createElement(ChatComposer, { agent: true, @@ -22,7 +23,7 @@ function render(props: Partial = {}): string { value: "", }, ...props, - })); + })).replaceAll("'", "'"); } test("renders the draft as a combobox in the field", () => { @@ -70,14 +71,116 @@ test("read-only and archived documents replace the draft and hide notices", () = expect(render({ unavailable: "archived" })).toContain("Document archived"); }); -test("shows the connection, Reconnect, and one run control", () => { - let markup = render({ - connectionLabel: "Offline", - onReconnect: () => {}, - run: { action: "stop", disabled: false, onClick: () => {} }, - }); - expect(markup).toContain('data-offline="true"'); - expect(markup).toContain("Reconnect"); +test("shows one run control, and no connection state of its own", () => { + let markup = render({ run: { action: "stop", disabled: false, onClick: () => {} } }); expect(markup).toContain('aria-label="Stop Chopin"'); expect(markup).not.toContain('aria-label="Resume Chopin"'); + // The document says when the connection is lost, once, at the top of its content. + expect(markup).not.toContain("composer-connection"); + expect(markup).not.toContain("Reconnect"); + expect(markup).not.toContain("Retry"); +}); + +test("puts nothing beneath the composer, whatever the Planner is doing", () => { + let markup = render({ + notice: { + tone: "warning", + text: "Can't connect to Build box.", + link: { label: "How to fix", href: "https://example.test/fix" }, + }, + }); + expect(markup.endsWith("")).toBe(true); + expect(markup.lastIndexOf("composer-notice")).toBeLessThan(markup.indexOf("composer-surface")); + // The composer has no slot for anything under it. + expect(render()).not.toContain("composer-planner"); +}); + +test("a warning is an alert on the composer's top edge: the words, a link, and what a screen reader adds", () => { + let markup = render({ + notice: { + tone: "warning", + text: "Can't connect to Build box.", + detail: ["The reason", "Tried 3 times, last at 6:29 PM. Still trying."], + link: { label: "How to fix", href: "https://example.test/fix" }, + }, + }); + expect(markup).toMatch(/ Ended { expect(empty).toContain('

No archived documents

'); expect(empty).toContain("No matching documents"); expect(markup).toContain('data-chat-entry="true"'); - let chat = plate(markup, "chat"); + // The apostrophe React escapes in text, written back, so copy reads as typed. + let chat = plate(markup, "chat").replaceAll("'", "'"); expect(chat).toContain('class="workspace-root'); expect(chat).toContain('class="composer-surface field"'); expect(chat).toContain('class="composer-notice motion-feedback"'); - expect(chat).toContain("Reconnect"); + // The Planner's states: a warning on the composer's edge, and the machine button's dot and note. + // The strips announce nothing themselves: the page's one live region does. + expect(chat).toMatch(/class="composer-notice" data-warning="true"/); + expect(chat).not.toMatch(/class="composer-notice" role="(alert|status)" data-warning/); + expect(chat).toContain("Can't connect to Build box."); + expect(chat).toContain("How to fix"); + // With no machine button the strip carries each state, and draws the reason as given. + expect(chat).toContain( + 'The checkout\'s git config sets a credential helper', + ); + expect(chat).toContain("Can't connect to @mona's machine."); + expect(chat).toContain("Connecting to @mona's machine"); + expect(chat).toContain("@mona's machine is offline. Your work is kept."); + expect(chat).toContain("@mona's machine stepped away. Your work is kept until "); + expect(chat).toContain("No machine is connected."); + expect(chat).toContain("Connecting to Build box"); + expect(chat).toContain("Build box is offline. Your work is kept."); + expect(chat).toContain('data-planner="starting"'); + expect(chat).toContain('data-planner="failing"'); + expect(chat).toContain('data-planner="away"'); + expect(chat).not.toContain("Reconnect"); + // The machine button's dots beside each state: only a healthy one keeps the machine's green, + // and no machine connected has no dot at all. + let cells = chat.split('class="design-audit-planner-presence"')[1]!.split( + '
', + ).slice(1); + expect(cells).toHaveLength(6); + expect(cells[0]).toContain('data-state="connected"'); + for (let cell of cells.slice(1)) expect(cell).not.toContain('data-state="connected"'); + let none = cells.at(-1)!; + expect(none).toContain("No machine is connected."); + expect(none).not.toContain("status-dot"); expect(chat).toContain('aria-label="Stop Chopin"'); expect(chat).toContain("Read-only access"); expect(chat).toContain("Document archived"); diff --git a/apps/web/src/machine-button.css b/apps/web/src/machine-button.css index d70759ab6..0e1fe68a7 100644 --- a/apps/web/src/machine-button.css +++ b/apps/web/src/machine-button.css @@ -16,24 +16,68 @@ color: var(--color-text-tertiary); } -/* The status dot as a badge on the icon's corner, ringed in the surface behind it. */ +/* + * The status dot as a badge on the icon's corner, ringed in the surface behind it. The ring is the + * surface's own colour wherever the button sits, so a dot that is hollow can be filled with it. + */ .machine-glyph-dot { + --machine-dot-ring: var(--color-page); position: absolute; right: calc(var(--spacing) * -1); bottom: calc(var(--spacing) * -1); box-sizing: content-box; border-width: calc(var(--spacing) * 0.5); border-style: solid; - border-color: var(--color-page); + border-color: var(--machine-dot-ring); } :is(.machine-button:hover, .machine-button[aria-expanded="true"], .app-menu-item:is(:hover:not([data-disabled]), :focus-visible), .machine-sheet-item:active:not([data-disabled])) .machine-glyph-dot { - border-color: var(--color-hover); + --machine-dot-ring: var(--color-hover); } :is(.app-menu-item, .machine-sheet-item)[aria-checked="true"] .machine-glyph-dot { - border-color: var(--color-selected); + --machine-dot-ring: var(--color-selected); +} + +/* + * The Planner's own state, where it is not simply attached. Away is the muted grey of an offline + * machine and a warning is the warning role, each replacing the machine's green. Starting is a + * thin petrol ring turning on the surface, so it reads as work under way and not as a state. + */ +.machine-glyph .machine-glyph-dot[data-planner="away"] { + background: var(--color-text-tertiary); +} + +.machine-glyph .machine-glyph-dot[data-planner="failing"] { + background: var(--color-warning-graphic); +} + +.machine-glyph .machine-glyph-dot[data-planner="starting"] { + background: var(--machine-dot-ring); + /* Larger than a dot, so the ring has room to read as a ring. */ + width: calc(var(--spacing) * 3); + height: calc(var(--spacing) * 3); + right: calc(var(--spacing) * -1.5); + bottom: calc(var(--spacing) * -1.5); +} + +.machine-glyph .machine-glyph-dot[data-planner="starting"]::after { + content: ""; + position: absolute; + inset: 0; + border-width: calc(var(--spacing) * 0.5); + border-style: solid; + border-color: var(--color-selected-strong); + border-top-color: var(--color-brand); + border-radius: var(--radius-full); +} + +/* Reduced motion keeps the still ring: its tooltip and name say it is starting. */ +@media (prefers-reduced-motion: no-preference) { + .machine-glyph .machine-glyph-dot[data-planner="starting"]::after { + animation: button-loader-spin calc(var(--duration-base) * 4) linear infinite; + } } /* The icon alone; its accessible name and tooltip say which machine. */ @@ -175,3 +219,66 @@ position: absolute; inset: min(0px, calc((var(--composer-pill-control) - var(--control-target-coarse)) / 2)); } + +/* + * What the Planner is doing, at the top of the menu: a title where it cannot start, then the + * reason, what has been tried in quiet ink, and where to fix it. Inset as the menu's rows are, and + * set apart from them by the menu's own divider. + */ +.machine-planner { + display: grid; + gap: calc(var(--spacing) * 1.5); + padding: calc(var(--spacing) * 2) var(--menu-item-padding-inline-normal); + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); + color: var(--color-text-secondary); + text-wrap: pretty; +} + +.machine-planner p { + margin: 0; + overflow-wrap: anywhere; +} + +.machine-planner-title { + display: flex; + align-items: flex-start; + gap: calc(var(--spacing) * 1.5); + color: var(--color-text-primary); + font-weight: 600; +} + +.machine-planner-title svg { + --color-icon: var(--color-warning-icon); +} + +.machine-planner-detail { + color: var(--color-text-tertiary); + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); +} + +.machine-planner-link { + justify-self: start; + color: var(--color-brand-ink); + text-decoration: underline; + text-underline-offset: 0.2em; +} + +/* On a phone's sheet, the rows' own inset and size. */ +.machine-sheet-group .machine-planner { + padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4); + font-size: var(--text-base); + line-height: var(--text-base--line-height); +} + +.machine-sheet-group .machine-planner-detail { + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); +} + +.machine-sheet-group .machine-planner-link { + min-height: var(--control-target-coarse); + display: inline-flex; + align-items: center; +} diff --git a/apps/web/src/machine-button.test.tsx b/apps/web/src/machine-button.test.tsx index 03f69435e..179e4f238 100644 --- a/apps/web/src/machine-button.test.tsx +++ b/apps/web/src/machine-button.test.tsx @@ -1,12 +1,18 @@ import { expect, test } from "bun:test"; import { createElement } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; +import { renderToStaticMarkup as renderToStaticMarkupRaw } from "react-dom/server"; import { BuildPlanButton } from "./build-plan-button"; -import { MachineButton, MachineGlyph } from "./machine-button"; +import { PLANNER_HOW_TO_FIX, plannerStatus } from "./chat/planner-status"; +import { MachineButton, MachineGlyph, PlannerNote } from "./machine-button"; import type { MachineChoice } from "./machine-choice"; +/** Markup with the apostrophe React escapes in text written back, so copy reads as typed. */ +function renderToStaticMarkup(element: Parameters[0]): string { + return renderToStaticMarkupRaw(element).replaceAll("'", "'"); +} + function choice(service: boolean, connected: boolean): MachineChoice { return { state: { @@ -116,3 +122,165 @@ test("offline looks the same everywhere: the machine button and Build plan dimme let described = /aria-describedby="([^"]+)"/.exec(plan)![1]; expect(plan).toContain(`build-box is offline`); }); + +const AT = "2026-11-04T18:29:00.000Z"; + +function button( + planner: Parameters[0], + phone = false, + machine: { service: boolean; connected: boolean } = { service: true, connected: true }, +): string { + return renderToStaticMarkup( + createElement(MachineButton, { + choice: choice(machine.service, machine.connected), + phone, + planner: plannerStatus(planner, "build-box"), + }), + ); +} + +test("a healthy Planner leaves the button and its machine's dot exactly as they were", () => { + let healthy = button({ state: "attached", login: "mona" }); + expect(healthy).toContain('aria-label="Runs on build-box"'); + expect(healthy).toContain('data-state="connected"'); + expect(healthy).not.toContain("data-planner"); + expect(healthy).not.toContain("aria-describedby"); +}); + +test("a starting Planner turns the dot into a spinner and says where it is starting", () => { + let markup = button({ state: "starting", login: "mona" }); + expect(markup).toContain('aria-label="Runs on build-box. Connecting to build-box"'); + expect(markup).toContain('data-tooltip="Runs on build-box. Connecting to build-box"'); + expect(markup).toContain('data-planner="starting"'); + expect(markup).toMatch(/class="status-dot machine-glyph-dot" data-planner="starting"/); +}); + +test("a Planner that cannot start is a warning dot, its reason the button's description", () => { + let markup = button({ + state: "starting", + login: "mona", + failure: { attempts: 3, reason: "The checkout's git config sets a credential helper", at: AT }, + }); + expect(markup).toContain( + 'aria-label="Runs on build-box. Can\'t connect to build-box."', + ); + expect(markup).toContain('data-planner="failing"'); + let described = /aria-describedby="([^"]+)"/.exec(markup)![1]; + // The reason as given, and what has been tried, each a block of its own for a screen reader. + expect(markup).toMatch( + new RegExp( + `

The checkout's git config sets a credential helper

Tried 3 times, last at [^<]+. Still trying.

`, + ), + ); +}); + +test("a Planner that is disconnected or has stepped away is a muted dot", () => { + for ( + let planner of [ + { state: "disconnected", login: "mona" }, + { state: "held", login: "mona", until: AT }, + ] as const + ) { + let markup = button(planner, true); + expect(markup).toContain('data-planner="away"'); + expect(markup).toContain('data-phone="true"'); + } +}); + +test("with no machine connected the button is the bare icon with no dot, as with no machine at all", () => { + let none = button({ state: "none" }); + expect(none).toContain('aria-label="Runs on build-box. No machine is connected."'); + expect(none).toContain('data-planner="none"'); + // Not the machine's own green: this document has nothing running on it, whatever the machine is. + expect(none).not.toContain("status-dot"); + // The same quiet icon in tertiary ink as the button of a person who has no machine. + expect(none).toContain("data-automatic"); + let bare = renderToStaticMarkup(createElement(MachineGlyph, {})); + expect(bare).toContain("data-automatic"); + expect(bare).not.toContain("status-dot"); +}); + +/** Every way the Planner can be unwell, which is every state but a healthy one. */ +const UNWELL = [ + { state: "none" }, + { state: "starting", login: "mona" }, + { state: "starting", login: "mona", failure: { attempts: 3, reason: "exit 127", at: AT } }, + { state: "disconnected", login: "mona" }, + { state: "held", login: "mona", until: AT }, +] as const; + +test("whatever is wrong with the Planner, the machine's connected green is never drawn", () => { + for (let service of [true, false]) { + for (let phone of [false, true]) { + // Whatever the machine's own state is, its dot is not the Planner's to borrow. + for (let connected of [true, false]) { + for (let planner of UNWELL) { + let markup = button(planner, phone, { service, connected }); + let drawn = `${planner.state}${"failure" in planner ? " (failing)" : ""}`; + expect(markup, drawn).not.toContain('data-state="connected"'); + } + } + } + } + // And a healthy Planner leaves the machine's own green exactly as it was. + expect(button({ state: "attached", login: "mona" })).toContain('data-state="connected"'); +}); + +test("a glyph with a Planner state but no machine still shows the state", () => { + let markup = renderToStaticMarkup(createElement(MachineGlyph, { planner: "starting" })); + expect(markup).toContain('data-planner="starting"'); +}); + +test("the top of the menu says why a Planner cannot start, in full, with where to fix it", () => { + let status = plannerStatus({ + state: "starting", + login: "mona", + failure: { attempts: 3, reason: "The checkout's git config sets a credential helper", at: AT }, + }, "Build box")!; + let markup = renderToStaticMarkup(createElement(PlannerNote, { status })); + expect(markup).toContain('role="group"'); + expect(markup).toContain('aria-label="Connection"'); + expect(markup).toContain("Can't connect to Build box

"); + expect(markup).toContain("

The checkout's git config sets a credential helper

"); + expect(markup).toMatch( + /

Tried 3 times, last at [^<]+\. Still trying\.<\/p>/, + ); + expect(markup).toContain(`href="${PLANNER_HOW_TO_FIX}"`); + // A link in a menu is one of its items, so the arrow keys reach it. + expect(markup).toContain('role="menuitem"'); + expect(markup).toContain('rel="noreferrer"'); + expect(markup).toContain('target="_blank"'); + expect(markup).toContain('role="separator"'); +}); + +test("the top of the menu tells every other state as one sentence, with no link", () => { + let say = (planner: Parameters[0]) => + renderToStaticMarkup( + createElement(PlannerNote, { status: plannerStatus(planner, "Build box")! }), + ); + expect(say({ state: "starting", login: "mona" })).toContain( + "

Connecting to Build box

", + ); + expect(say({ state: "disconnected", login: "mona" })).toContain( + "

Build box is offline. Your work is kept.

", + ); + expect(say({ state: "none" })).toContain( + "

No machine is connected.

", + ); + expect(say({ state: "none" })).not.toContain(" { + let reason = " path ends in a slash / "; + let planner = { + state: "starting", + login: "mona", + failure: { attempts: 2, reason, at: AT }, + } as const; + let note = renderToStaticMarkup( + createElement(PlannerNote, { status: plannerStatus(planner, "Build box")! }), + ); + // Not trimmed, and no stop added to a path that ends in a slash. + expect(note).toContain(`

${reason}

`); + expect(button(planner)).toContain(`

${reason}

`); +}); diff --git a/apps/web/src/machine-button.tsx b/apps/web/src/machine-button.tsx index 2896b2ddb..bfbd0a632 100644 --- a/apps/web/src/machine-button.tsx +++ b/apps/web/src/machine-button.tsx @@ -2,8 +2,10 @@ import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Drawer } from "@base-ui/react/drawer"; import { useTransitionPresence } from "@chopin/editor/transition-presence"; -import { CloudIcon, MachineIcon, PlusIcon } from "@chopin/icons"; +import { CloudIcon, MachineIcon, PlusIcon, WarningIcon } from "@chopin/icons"; +import { PLANNER_HOW_TO_FIX, plannerButtonLabel, plannerGlyph } from "./chat/planner-status"; +import type { PlannerGlyph, PlannerStatus } from "./chat/planner-status"; import { documentMenuKeyAction } from "./document-actions-menu"; import { chosenMachine, @@ -29,28 +31,73 @@ export const ADD_MACHINE_PATH = "/machines?add"; * A machine as an icon with its state as a dot on the icon's corner: the cloud icon for one the * background service runs, the laptop otherwise, the same two as Add machine's menu. The dot is * the Machines page's status dot. With no machine it is the laptop in tertiary ink, no dot. + * While the document's Planner is not simply attached the dot is its, never the machine's: a + * thin petrol spinner as it starts, a warning where it cannot, a muted grey while it is away, and + * with no machine connected no dot at all, the same bare icon as with no machine. */ export function MachineGlyph( - { machine, size = 16 }: { + { machine, planner, size = 16 }: { machine?: Pick; + planner?: PlannerGlyph; size?: 16 | 18; }, ) { let Icon = machine?.service ? CloudIcon : MachineIcon; let state: MachineState | undefined = machine && machineState(machine); + let bare = !machine || planner === "none"; return (