feat(workspace): AI tool per session, one place to start a session, ended sessions open like Claude Desktop

- A session picks its own AI tool: `tool` on POST /api/lanes/:id/start
  (a catalog id, known + enabled, else 400 EBADTOOL/EDISABLED); a resume
  keeps the tool its session ran on (`sessions.tool`). The workspace's
  `lanes.tool` is now only the default.
- The workspace home's prompt box is the one place a session starts. Its
  fields run in order: AI → AI Router (Native or a profile) → Model (the
  tool's own list, or the router's models) → Permission (Claude mode /
  Codex sandbox+approval / catalog bypass) → Effort.
- The pane's run form (RunSetup) is gone: New run, a single pane's exit and
  an empty split pane's New session all go to the home. RunSetup.tsx keeps
  PromptEditor, CwdAutocomplete and ModelPicker (new `choices` prop).
- The tool icon moved from the sidebar's workspace row to each session row
  (right edge, `session.tool`); the split-pane header shows the run's tool.
- An ended session opens in its workspace (`&session=`, SessionReplay.tsx):
  its conversation read-only plus a prompt box; only a SENT message resumes
  it (resumeSessionId + prompt, first free slot). The console is only
  hidden behind it.
- Analyst button beside Files/Changes in the right panel opens
  /sessions/:id for the opened session.
- Gemini CLI ships its own icon (client/public/ai-tools/gemini.svg, from
  zonalogo.com).
- RouterPicker always shows (Native disabled for tools without router
  support); useAiRouters() exported.
- Tests: server start-with-tool cases; SessionReplay, home field order/tool/
  router models, empty pane, Analyst; dropped the cwd-field suites
  (Run.defaultCwd, Workspace.laneCwd). Docs: CLAUDE.md rules, API.md,
  LANES.md, README, ARCHITECTURE, INSTALL.
This commit is contained in:
2026-10-02 15:29:38 +07:00
parent c507d37baf
commit 4802684d2f
34 changed files with 1137 additions and 2504 deletions
+39
View File
@@ -1318,4 +1318,43 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
assert.equal(after.run_id, null);
assert.equal(after.status, "idle");
});
it("starts a session on the AI tool the request names, not the workspace's", async () => {
const lane = await adoptedLane("session-tool");
assert.equal(lane.tool, "claude");
// A fake `codex` on PATH, so the pane runs this script, not a real CLI.
const bin = path.join(ROOT, "session-tool-bin");
fs.mkdirSync(bin, { recursive: true });
fs.writeFileSync(
path.join(bin, "codex"),
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n"
);
fs.chmodSync(path.join(bin, "codex"), 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
try {
const started = await request("POST", `/api/lanes/${lane.id}/start`, {
prompt: "hi",
tool: "codex",
});
assert.equal(started.status, 200, JSON.stringify(started.body));
const run = require("../lib/dashboard-runs").getRun(started.body.run_id);
assert.equal(run.tool, "codex");
// The workspace's own tool is untouched.
assert.equal(started.body.lane.tool, "claude");
} finally {
await request("POST", `/api/lanes/${lane.id}/stop`, {});
process.env.PATH = originalPath;
}
});
it("refuses a start naming a tool the catalog does not know", async () => {
const lane = await adoptedLane("session-tool-bad");
for (const tool of ["no-such-cli", 42]) {
const r = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "hi", tool });
assert.equal(r.status, 400, JSON.stringify(r.body));
assert.equal(r.body.error.code, "EBADTOOL");
}
assert.equal(require("../lib/lanes").getLane(lane.id).run_id, null);
});
});