fix(run,lanes): prompt autocomplete, per-lane Active runs, compact Changes header

- The run form's prompt box offers suggestions again: `/` lists the run
  cwd's project and user commands and skills plus the CLI built-ins
  (claude lanes only), `@` lists files under the cwd; Enter picks while
  the list is open and sends otherwise. The 2f39f4e rewrite had left a
  bare textarea that ignored both.
- Active runs lists only the current lane's runs and external sessions
  (sessions matched to the lane by longest path-boundary cwd), instead of
  every lane's.
- The Changes panel's git line and its actions are one compact row in one
  card, and the mode/range toolbar is one wrapping row.
- The lane card hides itself when it has nothing to show, instead of
  leaving an empty frame where the git row used to be.
This commit is contained in:
2026-09-24 13:30:30 +07:00
parent faa56f5c75
commit 428519335d
15 changed files with 583 additions and 130 deletions
@@ -331,4 +331,56 @@ describe("LaneConsolePane", () => {
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
});
it("lists only this lane's runs and sessions in Active runs, not every lane's", () => {
const run = (id: string, laneId: number, promptPreview: string) => ({
id,
laneId,
status: "running",
cwd: "/workspace/x",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: "2026-09-24T01:00:00Z",
promptPreview,
});
const session = (id: string, cwd: string, name: string) =>
({
id,
cwd,
name,
model: null,
source: "local",
started_at: "2026-09-24T01:00:00Z",
}) as never;
render(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
lanes={[
LANE,
{ ...LANE, id: 2, cwd: "/workspace/b" },
// Nested inside lane 1: its sessions are ITS own, by longest prefix.
{ ...LANE, id: 3, cwd: "/workspace/a/nested" },
]}
activeRuns={{ items: [run("r1", 1, "mine"), run("r2", 2, "other lane's")] as never }}
externalSessions={[
session("s1", "/workspace/a/src", "my session"),
session("s2", "/workspace/b", "b session"),
session("s3", "/workspace/a/nested/x", "nested session"),
session("s4", "/workspace/ab", "sibling prefix"),
]}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: /2 active runs/ }));
expect(screen.getByText("mine")).toBeTruthy();
expect(screen.getByText("my session")).toBeTruthy();
expect(screen.queryByText("other lane's")).toBeNull();
expect(screen.queryByText("b session")).toBeNull();
expect(screen.queryByText("nested session")).toBeNull();
expect(screen.queryByText("sibling prefix")).toBeNull();
});
});
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { RunSetup } from "../RunSetup";
import type { CwdSuggestion } from "../../../lib/api";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -26,7 +27,22 @@ vi.mock("../../../lib/api", async (importOriginal) => {
api: {
run: { files: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
ccConfig: { file: r({ text: "" }) },
ccConfig: {
file: r({ text: "" }),
commands: r({
items: [
{
scope: "project",
name: "ship-it",
frontmatter: { description: "Ship the change" },
preview: "",
},
],
}),
skills: r({
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
}),
},
},
};
});
@@ -66,7 +82,6 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
busy={false}
activeRuns={null}
resumeSession={null}
slashCommands={[]}
runHistory={[]}
tool="claude"
sandbox="workspace-write"
@@ -300,7 +315,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
busy={false}
activeRuns={null}
resumeSession={null}
slashCommands={[]}
runHistory={[]}
laneCwd="/Users/tester/lane-b"
tool="claude"
@@ -364,3 +378,48 @@ describe("RunSetup — per-tool fields", () => {
expect(onResumeLast).toHaveBeenCalled();
});
});
describe("RunSetup — / and @ suggestions in the prompt box", () => {
const box = () => screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholderTerminal"));
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
const { spies } = renderSetup({ prompt: "" });
fireEvent.change(box(), { target: { value: "/rev" } });
// Both prefix matches; the shorter name ranks first.
const options = await screen.findAllByRole("option");
expect(options.map((o) => o.textContent)).toEqual([
expect.stringContaining("/review"),
expect.stringContaining("/review-pr"),
]);
fireEvent.keyDown(box(), { key: "ArrowDown" });
// Read for the directory the run will start in, not the server's own.
expect(api.ccConfig.commands).toHaveBeenCalledWith(undefined, "/Users/tester");
expect(api.ccConfig.skills).toHaveBeenCalledWith(undefined, "/Users/tester");
fireEvent.keyDown(box(), { key: "Enter" });
expect(spies.onPromptChange).toHaveBeenLastCalledWith("/review-pr ");
// Enter picked the suggestion; it did not send the prompt.
expect(spies.onStart).not.toHaveBeenCalled();
});
it("suggests files under the cwd on @", async () => {
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
// The prompt is controlled by the page: put the cursor at the end of it.
const { spies } = renderSetup({ prompt: "look at @ma" });
(box() as HTMLTextAreaElement).setSelectionRange(11, 11);
fireEvent.select(box());
fireEvent.click(await screen.findByText("src/main.ts"));
expect(api.run.files).toHaveBeenCalledWith("/Users/tester", "ma");
expect(spies.onPromptChange).toHaveBeenLastCalledWith("look at @src/main.ts ");
});
it("offers no slash commands on a codex lane", async () => {
renderSetup({ prompt: "", tool: "codex" });
fireEvent.change(box(), { target: { value: "/rev" } });
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByTestId("prompt-autocomplete")).toBeNull();
expect(api.ccConfig.commands).not.toHaveBeenCalled();
});
});