feat(run,ui): make the lane console usable — terminal focus, live-exit recovery, auto permission mode, model aliases
Terminal (issue #33): - `convertEol` is gone from xterm. It rewrote every bare LF as CR+LF, so a tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the column it was on) had the rest of that draw land at column 0, stamping a line's first characters into the left margin of the rows below — the torn left edge that only showed up once content scrolled. - The attach carries the client's real size (`?cols=&rows=`, `sizeFromQuery` in `server/websocket.js`, bounded because it reaches a TIOCSWINSZ ioctl). `attach()` spawned `tmux attach-session` at a hardcoded 80x24 on every attach, and tmux sizes the window to the newest client, so `claude` redrew its whole UI at 80 columns until the first `resize` message landed. - The terminal takes the keyboard on mount, so typing reaches tmux without clicking the pane first. - A pane that ends (double Ctrl+C, `exit`, a crash) reports itself through a new `onExit` prop and `LaneConsolePane` runs `newRun()`, so the console returns to the setup form instead of leaving a dead pane behind a "New run" button. Pane environment: - `server/lib/desktop-env.js` forwards the graphical session's DISPLAY / WAYLAND_DISPLAY / XAUTHORITY / XDG_RUNTIME_DIR (from `systemctl --user show-environment`, this process's own values winning) into the pane. The server normally runs as a systemd user service with none of them, so `claude` there could not read the clipboard and answered "no image found in clipboard" on every image paste. Run setup: - `auto` is a permission mode and is now the default everywhere — allowlists, the `--permission-mode` fallback, the select, and the pane's initial state. - The model picker sends the CLI's aliases (`fable`/`opus`/`sonnet`/`haiku`) instead of pinned ids. `claude --model` resolves an alias to the latest model itself, so a release like Fable 5.1 needs no edit here; pricing is unaffected because it keys off the resolved id the transcript reports. - **Create/update readme file** moved from the Add-lane modal into the lane's Tools menu: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first. - The Workspace's no-lane state is a real banner; the lane strip (add tile included) renders only once a lane exists. Refs #32, #33. Tests: npm run test:server (1828 pass), client vitest (558 pass).
This commit is contained in:
@@ -11,8 +11,12 @@ import { api } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
|
||||
vi.mock("../TerminalView", () => ({
|
||||
TerminalView: ({ runId }: { runId: string }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId} />
|
||||
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
|
||||
// out of claude), which the real TerminalView reports through `onExit`.
|
||||
TerminalView: ({ runId, onExit }: { runId: string; onExit?: () => void }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId}>
|
||||
<button data-testid="fake-pane-exit" onClick={() => onExit?.()} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -130,6 +134,35 @@ describe("LaneConsolePane", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns to the setup form when the pane exits, without a New run click", async () => {
|
||||
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
lane: { ...LANE, run_id: "run-1" },
|
||||
});
|
||||
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "run-1",
|
||||
laneId: 1,
|
||||
status: "running",
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
});
|
||||
|
||||
render(<LaneConsolePane {...baseProps()} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
|
||||
await screen.findByTestId("terminal-view");
|
||||
|
||||
fireEvent.click(screen.getByTestId("fake-pane-exit"));
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
|
||||
expect(screen.getByPlaceholderText(/ask claude/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
|
||||
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
|
||||
const run = (id: string, laneId: number) => ({
|
||||
|
||||
@@ -121,8 +121,10 @@ describe("RunSetup — selections report through callbacks", () => {
|
||||
|
||||
it("reports the model from the picker, including a custom id", () => {
|
||||
const { spies } = renderSetup();
|
||||
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet 5");
|
||||
expect(spies.onModelChange).toHaveBeenCalledWith("claude-sonnet-5");
|
||||
// The picker offers the CLI's "latest" aliases, not pinned ids — that is
|
||||
// what keeps the list from going stale on every model release.
|
||||
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet (latest)");
|
||||
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
|
||||
// "Custom…" is a sentinel, not a model id — it must not be reported as one;
|
||||
// the free-text box it reveals is what reports.
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
|
||||
@@ -16,6 +16,7 @@ const openMock = vi.fn();
|
||||
const disposeMock = vi.fn();
|
||||
const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const focusMock = vi.fn();
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
const termState = {
|
||||
@@ -54,6 +55,7 @@ vi.mock("@xterm/xterm", () => ({
|
||||
loadAddon: loadAddonMock,
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
focus: focusMock,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
@@ -100,13 +102,27 @@ describe("TerminalView", () => {
|
||||
keyHandlerMock.mockClear();
|
||||
loadAddonMock.mockClear();
|
||||
openMock.mockClear();
|
||||
focusMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("opens a WS connection to the run's ws-pty path", () => {
|
||||
it("opens a WS connection to the run's ws-pty path, carrying its own size", () => {
|
||||
// The size rides on the attach URL, not on a `resize` message sent after
|
||||
// it: tmux resizes the window to the newest client, so attaching at the
|
||||
// server's default 80x24 makes claude redraw its UI at 80 columns and
|
||||
// leaves that width in the scrollback (issue #33).
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
|
||||
termState.cols = 0;
|
||||
termState.rows = 0;
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
|
||||
});
|
||||
|
||||
@@ -184,6 +200,24 @@ describe("TerminalView", () => {
|
||||
expect(preventPlainTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("takes the keyboard as soon as it mounts", () => {
|
||||
// The terminal IS the page here — without this the first thing typed after
|
||||
// opening a lane went nowhere until the user clicked the pane.
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(focusMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tells its owner when the pane exited, so the setup form can come back", () => {
|
||||
// A double Ctrl+C out of `claude` ends the tmux session; the dead pane used
|
||||
// to sit there until the user found the "New run" button.
|
||||
const onExit = vi.fn();
|
||||
render(
|
||||
<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" onExit={onExit} />
|
||||
);
|
||||
MockWebSocket.instances[0]!.onmessage?.({ data: '{"type":"exit","code":0}' });
|
||||
expect(onExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders through the canvas addon, not xterm's DOM renderer", () => {
|
||||
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
|
||||
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
|
||||
|
||||
Reference in New Issue
Block a user