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
+173 -19
View File
@@ -2,10 +2,14 @@
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
* Claude Desktop project page: the name, a prompt box that starts a NEW
* session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and
* then opens it (`&run=<runId>`), the workspace's Recents — the sessions it
* then opens it (`&run=<runId>`) — the ONE place a session is started, its
* fields in order: AI (the session's own tool, the workspace's by default),
* AI Router (Native or a profile), Model (the tool's own list, or the
* router's models), Permission (Claude's mode, Codex's sandbox/approval, a
* catalog tool's bypass) and Effort — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot),
* then the archived ones folded with Unarchive; a live session opens its pane,
* any other its transcript, nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
@@ -13,11 +17,12 @@
*/
import { Checkbox } from "../Checkbox";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
ArchiveRestore,
ShieldAlert,
FileText,
Folder,
GitBranch,
@@ -29,8 +34,13 @@ import {
} from "lucide-react";
import {
api,
RUN_EFFORT_CHOICES,
type CodexApproval,
type CodexSandbox,
type EffortLevel,
type LaneMemoryFile,
type LaneSourcesView,
type ModelChoice,
type PermissionMode,
type RunHandle,
} from "../../lib/api";
@@ -39,7 +49,8 @@ import { fmtCost, timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelPicker, PromptEditor } from "../run/RunSetup";
import { RouterPicker, routerArg, useSessionRouter } from "../run/RouterPicker";
import { AiToolIcon } from "../AiToolIcon";
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker";
import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
@@ -74,6 +85,18 @@ function fmtBytes(n: number | null): string {
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
/** One labelled field of the prompt box's row. */
function Field({ label, width, children }: { label: string; width: string; children: ReactNode }) {
return (
<div className={width}>
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{label}
</span>
{children}
</div>
);
}
type HomeProps = Parameters<typeof HomeBody>[0];
/** Keyed by workspace: every piece of state below (a half-typed prompt, the
@@ -98,17 +121,48 @@ function HomeBody({
const navigate = useNavigate();
const aiTools = useAiTools();
const prefs = useProjectTreePrefs();
const claude = lane.tool === "claude";
// The NEW session's AI tool — its own choice, the workspace's by default.
const [tool, setTool] = useState(lane.tool);
const entry = aiTools.byId(tool);
const claude = tool === "claude";
const codex = tool === "codex";
const [prompt, setPrompt] = useState("");
// The tool's default model, also when the catalog arrives after mount.
const defaultModel = aiTools.byId(lane.tool)?.defaultModel ?? "";
const [router, setRouter] = useSessionRouter();
const routers = useAiRouters();
// Claude and Codex, also before the catalog has loaded (as RouterPicker).
const routerCapable = entry?.routerCapable ?? (claude || codex);
// A profile picked for a tool that can use one; its models replace the tool's own.
const profile = routerCapable
? (routers?.find((r) => r.name === router && !r.builtin) ?? null)
: null;
const routerModels: ModelChoice[] | undefined = profile
? [
...new Set(
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
Boolean
)
),
].map((id) => ({ id, label: id }))
: undefined;
// The tool's default model (also when the catalog arrives after mount); on
// a router, the router's own default. A new tool or router drops the pick.
const defaultModel = profile ? "" : (entry?.defaultModel ?? "");
const [model, setModel] = useState(defaultModel);
useEffect(() => setModel(defaultModel), [defaultModel]);
useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]);
// Claude and Codex always take a model and an effort; a catalog tool a
// model only when its file names the flag, an effort never.
const hasModel = claude || codex || !!entry?.modelFlag;
const [mode, setMode] = useState<PermissionMode>("auto");
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("on-request");
// Per session, never remembered.
const [bypass, setBypass] = useState(false);
const canBypass = !claude && !!entry?.bypass;
const [effort, setEffort] = useState<EffortLevel>("");
const hasEffort = claude || codex;
// Claude Desktop's worktree toggle: the new session gets a git worktree of
// its own (`claude --worktree`), made and cleaned up by Claude Code.
const [inWorktree, setInWorktree] = useState(false);
const [router, setRouter] = useSessionRouter();
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
@@ -230,10 +284,16 @@ function HomeBody({
const r = await api.lanes.action(lane.id, "start", {
slot,
prompt: prompt.trim(),
...(model ? { model } : {}),
// Only a different tool is named: the workspace's own keeps starting
// even after it was disabled in Settings.
...(tool !== lane.tool ? { tool } : {}),
...routerArg(routerCapable, router),
...(hasModel && model ? { model } : {}),
...(claude ? { permissionMode: mode } : {}),
...(codex ? { sandbox, approval } : {}),
...(canBypass && bypass ? { bypass: true } : {}),
...(hasEffort && effort ? { effort } : {}),
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
...routerArg(aiTools.byId(lane.tool)?.routerCapable, router),
});
setPrompt("");
const runId = r.run_id ?? paneRunId(lane.id, slot);
@@ -248,10 +308,27 @@ function HomeBody({
const openSession = (s: Session) => {
const r = liveRunFor(lane, liveRuns, s.id);
navigate(
r ? `/workspace?lane=${lane.id}&run=${encodeURIComponent(r.id)}` : `/sessions/${s.id}`
`/workspace?lane=${lane.id}&${r ? `run=${encodeURIComponent(r.id)}` : `session=${encodeURIComponent(s.id)}`}`
);
};
// The tools a session may start on: installed and enabled, plus the
// workspace's own whatever its state.
const toolOptions = [
...new Set([
lane.tool,
...aiTools.tools.filter((x) => x.installed && x.enabled).map((x) => x.id),
]),
];
const danger =
claude && mode === "bypassPermissions"
? t("run:hint.permissionWarning")
: canBypass && bypass
? t("run:hint.bypassWarning", { tool: aiTools.label(tool) })
: codex && sandbox === "danger-full-access"
? t("run:hint.sandboxWarning")
: null;
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
const name = workspaceName(lane);
@@ -340,13 +417,35 @@ function HomeBody({
slash={claude}
fileCwd={lane.cwd}
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
<div className="w-56">
<ModelPicker value={model} onChange={setModel} tool={lane.tool} />
</div>
<RouterPicker tool={lane.tool} value={router} onChange={setRouter} />
<div className="mt-2 flex flex-wrap items-end gap-2">
<Field label={t("home.ai")} width="w-44">
<div data-testid="workspace-home-tool" className="flex items-center gap-1.5">
<AiToolIcon
tool={{ id: tool, label: aiTools.label(tool) }}
className="h-6 w-6 shrink-0"
/>
<div className="min-w-0 flex-1">
<Select<string>
value={tool}
onChange={(v) => {
setTool(v);
setBypass(false);
}}
options={toolOptions.map((id) => ({ value: id, label: aiTools.label(id) }))}
/>
</div>
</div>
</Field>
<Field label={t("router.label")} width="w-44">
<RouterPicker tool={tool} routers={routers} value={router} onChange={setRouter} />
</Field>
{hasModel && (
<Field label={t("run:fields.model")} width="w-56">
<ModelPicker value={model} onChange={setModel} tool={tool} choices={routerModels} />
</Field>
)}
{claude && (
<div className="w-44">
<Field label={t("run:fields.permissionMode")} width="w-44">
<Select<PermissionMode>
value={mode}
onChange={setMode}
@@ -358,7 +457,56 @@ function HomeBody({
{ value: "bypassPermissions", label: t("run:fields.permissionBypass") },
]}
/>
</div>
</Field>
)}
{codex && (
<>
<Field label={t("run:fields.sandbox")} width="w-40">
<Select<CodexSandbox>
value={sandbox}
onChange={setSandbox}
options={[
{ value: "read-only", label: t("run:fields.sandboxReadOnly") },
{ value: "workspace-write", label: t("run:fields.sandboxWorkspaceWrite") },
{ value: "danger-full-access", label: t("run:fields.sandboxFullAccess") },
]}
/>
</Field>
<Field label={t("run:fields.approval")} width="w-32">
<Select<CodexApproval>
value={approval}
onChange={setApproval}
options={[
{ value: "on-request", label: t("run:fields.approvalOnRequest") },
{ value: "never", label: t("run:fields.approvalNever") },
]}
/>
</Field>
</>
)}
{hasEffort && (
<Field label={t("run:fields.effort")} width="w-40">
<Select<EffortLevel>
value={effort}
onChange={setEffort}
options={RUN_EFFORT_CHOICES.map((c) => ({
value: c.id,
label: c.label,
hint: c.hint,
}))}
/>
</Field>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{canBypass && (
<Checkbox
tone="danger"
checked={bypass}
onChange={setBypass}
label={t("run:fields.bypass")}
labelClassName="text-xs text-status-danger"
/>
)}
{claude && project?.root && (
<Checkbox
@@ -381,6 +529,12 @@ function HomeBody({
</button>
</div>
</div>
{danger && (
<p className="mt-2 flex items-start gap-2 text-xs text-status-danger">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{danger}
</p>
)}
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}