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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user