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
+21 -4
View File
@@ -1808,6 +1808,26 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
});
}
}
// The SESSION's AI tool: a resume keeps the CLI its session ran on
// (`sessions.tool`); a new session takes the catalog id the request
// names — known and enabled, only an id, never a command — and the
// workspace's own tool otherwise.
const resumeId = body.resumeSessionId || (body.resume ? lane.session_id : null);
let sessionTool = null;
if (resumeId) {
sessionTool =
db.prepare("SELECT tool FROM sessions WHERE id = ?").get(resumeId)?.tool || null;
} else if (body.tool !== undefined) {
try {
if (typeof body.tool !== "string") {
throw Object.assign(new Error("tool must be a string"), { code: "EBADTOOL" });
}
lanesLib.validateTool(body.tool); // EBADTOOL, EDISABLED
} catch (err) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
sessionTool = body.tool;
}
// Overwriting run_id while its child is alive orphans that child: a later
// reset would kill and await only the RECORDED run, then `git clean -fd`
// the directory the orphan is still writing into. Stop the first run before starting a
@@ -1834,10 +1854,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
inWorktree: !!resumeWorktree,
// A NEW session in a worktree of its own (`claude --worktree`).
worktree: body.worktree === true && !body.resumeSessionId,
// The tool is the LANE's fact, never the request's: a client that
// asked for the other CLI would otherwise get a pane that doesn't
// match the lane's badge, its history, or its binary probe.
tool: current.tool || "claude",
tool: sessionTool || current.tool || "claude",
// The SESSION's AI Router, by name; a resume that names none goes
// back to the one its session last ran on.
router: