Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions apps/server/src/chat/remote-planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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([]);
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 16 additions & 15 deletions apps/server/src/harness/remote/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 27 additions & 3 deletions apps/server/src/harness/remote/planner-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", {
Expand All @@ -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");
Expand Down
6 changes: 2 additions & 4 deletions apps/server/src/mcp/hosted.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/mcp/invoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
});
Expand Down
4 changes: 2 additions & 2 deletions apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
26 changes: 13 additions & 13 deletions apps/web/src/build-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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",
});
});

Expand All @@ -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", () => {
Expand Down
Loading
Loading