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
20 changes: 20 additions & 0 deletions .adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,26 @@ signal is that you find yourself explaining *why not* rather than *how*.
- ❌ "Use Vitest for the web suite" — that's just what the repo does; `CLAUDE.md` covers it
- ❌ Anything already legible from the code, a test name, or a commit message

**The bar is high, and it is meant to be.** These are for the handful of decisions that shape the
system, not a record of work done. A merged PR is not an occasion for an ADR; neither is a decision
that merely took some thought, nor one you'd like on the record because it was hard-won. If a
directory of ADRs reads like a changelog, it has stopped being useful — the signal drowns, and the
few entries that genuinely close off a road get skimmed past with the rest.

Before adding one, both of these must be true:

1. **Someone has actually argued for the other road, or demonstrably will.** A real PR, a real issue,
a proposal you had to talk someone out of. "A future contributor might wonder" is not enough — that
is what a comment is for.
2. **The argument has nowhere better to live.** If it fits at the line that would change, put it
there: whoever reopens the question is reading that code, not this directory. An ADR is for
reasoning that spans files, or that argues against a road with no single line to attach to.

When in doubt, don't. A comment at the point of change costs nothing and is read by exactly the
person who needs it; an ADR that didn't need writing dilutes the ones that did. Two candidates were
turned down on this basis in one day (bundled-font laziness, and the direct-typing lifecycle) — both
became file-header comments, and both are better for it.

## Relationship to the other docs

Nothing here restates what lives elsewhere; the point is the *reasoning*, once.
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ the unit name; the Herdr action runs from anywhere.
still-mounted router (unmounting it ate in-progress composer drafts) and pauses polling through
`lib/idle.ts`. Don't restore it as a security control or re-describe it as one
([ADR 0007](./.adr/0007-the-idle-lock-is-a-pause-not-a-gate.md)).
- **"Type into terminal" is armed by a named choice and dies with the pane view.** Long-pressing Send
opens a menu; the hold never arms it alone. It disarms on a pane switch, a composer lock (gone pane,
read-only, idle pause), a hidden page, and a failed batch — never persisted, never restored. Don't
lift it, and don't add the reply guard's `composerReady` pre-flight to it; the reasoning for both
sits in `web/src/components/send-mode-menu.tsx`'s header.
- **PWA** via `vite-plugin-pwa` (`web/vite.config.ts`): manifest + `sw.js`, registered manually
from `virtual:pwa-register` in `main.tsx` (bundled = CSP-safe). Install/SW need a **secure
context** — over plain HTTP they no-op silently (Chrome insecure-origin flag, or HTTPS, to test).
Expand Down
290 changes: 290 additions & 0 deletions web/src/components/composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,296 @@ describe("Composer — send", () => {
});
});

describe("Composer — typing into the terminal", () => {
/** The entry point: the named "Type" toggle in the Controls row, beside Keys. */
function startDirectTyping() {
fireEvent.click(screen.getByRole("button", { name: /^type into terminal$/i }));
return screen.getByPlaceholderText(/type into the terminal/i);
}

it("focuses the textarea synchronously so the activation gesture opens the phone keyboard", () => {
renderComposer();

expect(startDirectTyping()).toHaveFocus();
});

// The entry point must be a deliberate press and nothing else: it sits in a row of dock toggles,
// so it must not send, and it must not leave a half-open dock covering the keyboard it needs.
it("arms from the Controls row without sending, and closes an open dock", async () => {
let replyCalls = 0;
server.use(replyHandler(() => replyCalls++));
renderComposer();
fireEvent.click(screen.getByRole("button", { name: /^keys$/i }));
expect(screen.getByRole("button", { name: /close keys/i })).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: /^type into terminal$/i }));

expect(screen.getByPlaceholderText(/type into the terminal/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /close keys/i })).toBeNull();
expect(replyCalls).toBe(0);
expect(screen.getByRole("button", { name: /^type into terminal$/i })).toHaveAttribute(
"aria-pressed",
"true",
);
});

it("shows the armed strip and stops from it", async () => {
renderComposer();
startDirectTyping();

const strip = screen.getByText(/typing into terminal/i);
expect(strip).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /^stop$/i }));

await waitFor(() =>
expect(screen.queryByPlaceholderText(/type into the terminal/i)).toBeNull(),
);
});

// The other half of the same rule: the composer locking (pane gone, device demoted to read-only,
// or the idle pause) means the view is no longer live either.
it("stops when the composer locks under it", async () => {
function Harness() {
const [gone, setGone] = useState(false);
return (
<>
<button type="button" onClick={() => setGone(true)}>
lock it
</button>
<Composer
paneId="w1:p1"
agent="claude"
isShell={false}
gone={gone}
readOnly={false}
dialogPresent={false}
text="pane output"
terminalDraft={null}
rawTerminalDraft={null}
prefs={{ wrap: true, fontSize: 11, rawTerminal: false }}
setWrap={vi.fn()}
stepFontSize={vi.fn()}
setRawTerminal={vi.fn()}
onSent={vi.fn()}
/>
</>
);
}
const router = createMemoryRouter([{ path: "/", element: <Harness /> }]);
render(<RouterProvider router={router} />);

fireEvent.click(screen.getByRole("button", { name: /^type into terminal$/i }));
expect(screen.getByPlaceholderText(/type into the terminal/i)).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "lock it" }));

await waitFor(() =>
expect(screen.queryByPlaceholderText(/type into the terminal/i)).toBeNull(),
);
});

// The mirror stops tracking the pane when the page is backgrounded, so the next keystroke would
// go into a terminal the user is not looking at.
it("stops when the page is hidden", async () => {
renderComposer();
startDirectTyping();

Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
fireEvent(document, new Event("visibilitychange"));

await waitFor(() =>
expect(screen.queryByPlaceholderText(/type into the terminal/i)).toBeNull(),
);
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
});

it("sends committed keyboard text as literal ordered keys with no implicit Enter", async () => {
const keyCalls: string[][] = [];
let replyCalls = 0;
server.use(
http.post(/\/api\/pane\/[^/]+\/keys$/, async ({ request }) => {
keyCalls.push(((await request.json()) as { keys: string[] }).keys);
return HttpResponse.json({ ok: true });
}),
replyHandler(() => replyCalls++),
);
renderComposerWithStatus({ dialogPresent: true });

const box = startDirectTyping();
expect(screen.getByRole("button", { name: /^type into terminal$/i })).toHaveAttribute(
"aria-pressed",
"true",
);
fireEvent.change(box, { target: { value: "b a" } });

await waitFor(() => expect(keyCalls).toEqual([["b", "Space", "a"]]));
expect(keyCalls.flat()).not.toContain("Enter");
expect(replyCalls).toBe(0);
expect(box).toHaveValue("");
expect(screen.getByTestId("status")).toHaveTextContent(/typing into the terminal/i);
});

it("sends a swiped/IME-composed word once when composition commits", async () => {
const keyCalls: string[][] = [];
server.use(
http.post(/\/api\/pane\/[^/]+\/keys$/, async ({ request }) => {
keyCalls.push(((await request.json()) as { keys: string[] }).keys);
return HttpResponse.json({ ok: true });
}),
);
renderComposer();
const box = startDirectTyping();

fireEvent.compositionStart(box);
fireEvent.input(box, {
target: { value: "swipe" },
data: "swipe",
inputType: "insertCompositionText",
isComposing: true,
});
expect(keyCalls).toEqual([]);
fireEvent.compositionEnd(box, { data: "swipe" });

await waitFor(() => expect(keyCalls).toEqual([["s", "w", "i", "p", "e"]]));
// Gboard may emit the committed value once more as an ordinary input after compositionend.
fireEvent.input(box, {
target: { value: "swipe" },
data: "swipe",
inputType: "insertText",
isComposing: false,
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(keyCalls).toEqual([["s", "w", "i", "p", "e"]]);
});

it("sends terminal keys that do not change the textarea value", async () => {
const keyCalls: string[][] = [];
server.use(
http.post(/\/api\/pane\/[^/]+\/keys$/, async ({ request }) => {
keyCalls.push(((await request.json()) as { keys: string[] }).keys);
return HttpResponse.json({ ok: true });
}),
);
renderComposer();
const box = startDirectTyping();

fireEvent.keyDown(box, { key: "Backspace" });
await waitFor(() => expect(keyCalls).toEqual([["Backspace"]]));
fireEvent.keyDown(box, { key: "Enter" });
await waitFor(() => expect(keyCalls).toEqual([["Backspace"], ["Enter"]]));

// Android can omit keydown for its virtual Backspace and expose only beforeinput.
fireEvent(
box,
new InputEvent("beforeinput", {
bubbles: true,
cancelable: true,
inputType: "deleteContentBackward",
}),
);
await waitFor(() =>
expect(keyCalls).toEqual([["Backspace"], ["Enter"], ["Backspace"]]),
);

// Gboard can mark its virtual Enter as composing while it commits the current candidate.
fireEvent(
box,
new InputEvent("beforeinput", {
bubbles: true,
cancelable: true,
inputType: "insertParagraph",
isComposing: true,
}),
);
await waitFor(() =>
expect(keyCalls).toEqual([["Backspace"], ["Enter"], ["Backspace"], ["Enter"]]),
);
});

it("exits on a tap of the highlighted keyboard button", async () => {
const user = userEvent.setup();
renderComposer();
const box = startDirectTyping();

fireEvent.blur(box); // dismissing the Android keyboard does not silently disarm the mode
expect(screen.getByPlaceholderText(/type into the terminal/i)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /stop typing into terminal/i }));

expect(screen.getByPlaceholderText(/type a reply/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send" })).toHaveAttribute(
"aria-pressed",
"false",
);
});

it("stops direct typing when a key batch is refused", async () => {
server.use(
http.post(/\/api\/pane\/[^/]+\/keys$/, () =>
HttpResponse.json({ ok: false, error: "pane unavailable" }, { status: 500 }),
),
);
renderComposerWithStatus();
const box = startDirectTyping();

fireEvent.change(box, { target: { value: "b" } });

const replyBox = await screen.findByPlaceholderText(/type a reply/i);
await waitFor(() => expect(replyBox).not.toHaveFocus());
expect(screen.getByTestId("status")).toHaveTextContent(/pane unavailable/i);
});

it("refuses activation while a buffered reply exists", async () => {
const user = userEvent.setup();
renderComposerWithStatus();
const box = screen.getByPlaceholderText(/type a reply/i);
await user.type(box, "keep this draft");

// The refusal belongs on the named choice, where there is somewhere to explain it.
fireEvent.click(screen.getByRole("button", { name: /^type into terminal$/i }));

expect(screen.getByPlaceholderText(/type a reply/i)).toHaveValue("keep this draft");
expect(screen.queryByPlaceholderText(/type into the terminal/i)).not.toBeInTheDocument();
expect(screen.getByTestId("status")).toHaveTextContent(/send or clear the draft/i);
});

it("resets when the composer changes panes", () => {
function Harness() {
const [paneId, setPaneId] = useState("w1:p1");
return (
<>
<button type="button" onClick={() => setPaneId("w1:p2")}>
Switch pane
</button>
<Composer
paneId={paneId}
agent="claude"
isShell={false}
gone={false}
readOnly={false}
dialogPresent={false}
text="pane output"
terminalDraft={null}
rawTerminalDraft={null}
prefs={{ wrap: true, fontSize: 11, rawTerminal: false }}
setWrap={vi.fn()}
stepFontSize={vi.fn()}
setRawTerminal={vi.fn()}
onSent={vi.fn()}
/>
</>
);
}
const router = createMemoryRouter([{ path: "/", element: <Harness /> }]);
render(<RouterProvider router={router} />);
startDirectTyping();

fireEvent.click(screen.getByRole("button", { name: "Switch pane" }));

expect(screen.getByPlaceholderText(/type a reply/i)).toBeInTheDocument();
expect(screen.queryByPlaceholderText(/type keys/i)).not.toBeInTheDocument();
});
});

// .adr/0009: a modal that owns the keyboard has no input box, so the reply path's PRE-FLIGHT refuses
// before typing anything. The composer's job is to keep the draft, say why, and offer one deliberate
// override — which still runs the type-then-verify guard behind it.
Expand Down
Loading
Loading