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
+54 -228
View File
@@ -1,26 +1,26 @@
/**
* @file LaneConsolePane.tsx
* @description One lane's run console: the RunSetup ↔ TerminalView switcher,
* moved out of Workspace.tsx so the Workspace page can render 1, 2, or 4 of
* these side by side (split terminal view). Owns its own prompt/cwd/model/
* permissionMode/effort/sandbox/approval/resumeSession/handle/busy/runHistory
* state — nothing is shared between panes. `lanes`, `binaryStatus`,
* `cwdSuggestions`, `activeRuns`, and `externalSessions` are supplied as props
* because they are global, not lane-specific, and fetching them per pane would
* mean N redundant identical requests for an N-pane layout. `binaryStatus` is
* keyed by tool (claude | codex) and probed once globally, so gating the Start
* button uses the binary status for the lane's configured tool.
* @description One lane's run console: a TerminalView on the pane's tmux
* session, or — with no run — an empty pane whose button goes back to the
* workspace home, the ONE place a new session is started (its prompt box,
* `WorkspaceHome.tsx`; there is no run form in a pane any more). Moved out of
* Workspace.tsx so the Workspace page can render 1, 2, or 4 of these side by
* side (split terminal view). Owns its own handle/busy/runHistory state —
* nothing is shared between panes. `lanes`, `binaryStatus`, `activeRuns`, and
* `externalSessions` are supplied as props because they are global, not
* lane-specific, and fetching them per pane would mean N redundant identical
* requests for an N-pane layout.
*
* That state is bound to the lane the pane currently shows: switching `laneId`
* swaps the whole pane over to the new lane — its cwd, its tool, its history,
* swaps the whole pane over to the new lane — its history
* and its live tmux session — instead of leaving the previous lane's terminal
* on screen under a new lane's header.
*
* Each pane is one tmux session on its lane — split panes all show the same
* lane, pane `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming
* into a pane already holding a live run answers 409 ERUNLIVE rather than overwriting a
* prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch
* that and attach to the still-live session instead of failing outright
* prompt in flight — `onResumeFromHistory` catches
* that and attaches to the still-live session instead of failing outright
* (with a notice explaining what happened), since that session is still
* reachable and the user did not ask to kill it. `killRun` is the other half:
* the Active runs list's Kill button (`RunHistory.tsx`), for when the user
@@ -30,23 +30,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Play, AlertCircle } from "lucide-react";
import { Play, AlertCircle, Plus } from "lucide-react";
import { api } from "../../lib/api";
import type {
CwdSuggestion,
DashboardRunHistoryItem,
EffortLevel,
PermissionMode,
RunHandle,
RunListResponse,
RunStartArgs,
CodexSandbox,
CodexApproval,
} from "../../lib/api";
import type { DashboardRunHistoryItem, RunHandle, RunListResponse } from "../../lib/api";
import type { Session, Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { TerminalView } from "./TerminalView";
import { RunSetup } from "./RunSetup";
import { ActiveRunsSwitcher } from "./RunHistory";
/** The claude lane a session's cwd belongs to: the longest path-boundary
@@ -70,13 +59,11 @@ export interface LaneConsolePaneProps {
onLaneIdChange: (id: number) => void;
onLaneCreated: (lane: Lane) => void;
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null;
/** Active Claude Code sessions started outside the dashboard, listed in the
* active-runs switcher alongside dashboard runs. */
externalSessions?: Session[];
wsConnected: boolean;
defaultCwd?: string;
onHasActiveRunChange?: (active: boolean) => void;
/** Which split pane this is. Every pane shows the SAME lane, each in its own
* tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane-<id>-<slot>`. */
@@ -89,6 +76,10 @@ export interface LaneConsolePaneProps {
/** A live run to show in this pane (a session picked in the sidebar). It
* attaches to that tmux session only — nothing is started or resumed. */
attachRequest?: { runId: string; nonce: string } | null;
/** Opens the workspace home, where a new session is started. */
onNewSession?: () => void;
/** The pane's run exiting goes back to the home too (a single pane). */
homeOnExit?: boolean;
}
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
@@ -103,33 +94,25 @@ export function LaneConsolePane({
onLaneIdChange,
onLaneCreated,
binaryStatus,
cwdSuggestions,
activeRuns,
externalSessions,
wsConnected,
defaultCwd,
onHasActiveRunChange,
slot = 0,
hiddenRunIds,
onHandleChange,
attachRequest,
onNewSession,
homeOnExit = false,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
const { t: tCommon } = useTranslation("common");
const [prompt, setPrompt] = useState("");
const [model, setModel] = useState("");
const [permissionMode, setPermissionMode] = useState<PermissionMode>("auto");
const [effort, setEffort] = useState<EffortLevel>("");
const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? defaultCwd ?? "");
const [resumeSession, setResumeSession] = useState<Session | null>(null);
const [handle, setHandle] = useState<RunHandle | null>(null);
const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
const [error, setError] = useState<string | null>(null);
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("on-request");
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
const laneTool = currentLane?.tool ?? "claude";
@@ -155,13 +138,10 @@ export function LaneConsolePane({
[externalSessions, laneId, lanes]
);
const aiTools = useAiTools();
const toolLabel = aiTools.label(laneTool);
// A new run starts on the tool's default model (AI Tools settings), and a
// lane switch to another tool drops the previous tool's pick.
const defaultModel = aiTools.byId(laneTool)?.defaultModel ?? "";
useEffect(() => {
setModel(defaultModel);
}, [laneTool, defaultModel]);
// The tool of the run on screen — a session's own, which need not be the
// workspace's; the workspace's with no run.
const paneTool = handle?.tool ?? laneTool;
const toolLabel = aiTools.label(paneTool);
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
@@ -171,12 +151,6 @@ export function LaneConsolePane({
onHandleChange?.(handle?.id ?? null);
}, [handle, onHandleChange]);
useEffect(() => {
if (!currentLane && defaultCwd && cwd === "") {
setCwd(defaultCwd);
}
}, [defaultCwd, currentLane, cwd]);
const refreshList = useCallback(() => {
if (laneId !== null) {
api.run
@@ -194,19 +168,15 @@ export function LaneConsolePane({
// `lanes` and `activeRuns` are re-fetched every few seconds by the page, so
// reading them through a ref keeps the lane-switch effect below off their
// identity — a background poll must not wipe a half-typed prompt.
const latest = useRef({ lanes, activeRuns, defaultCwd });
latest.current = { lanes, activeRuns, defaultCwd };
const latest = useRef({ lanes, activeRuns });
latest.current = { lanes, activeRuns };
// A pane's prompt, cwd, history and terminal all belong to the lane it
// shows, so switching lanes has to swap every one of them. Re-attach right
// away when the new lane already has a live run: each lane sticks to its own
// tmux session, and the switch should land on that session rather than on an
// empty setup form the user then has to Start out of.
// A pane's history and terminal belong to the lane it shows, so switching
// lanes has to swap both. Re-attach right away when the new lane already
// has a live run: each lane sticks to its own tmux session.
const autoAttachedForLane = useRef<number | null>(null);
useEffect(() => {
const { activeRuns: runs } = latest.current;
setPrompt("");
setResumeSession(null);
setError(null);
setBusy(null);
const own = laneId === null ? null : paneRunId(laneId, slot);
@@ -266,18 +236,6 @@ export function LaneConsolePane({
}
}, [activeRuns, laneId, slot, handle]);
// The cwd tracks the lane's own folder separately, keyed on the resolved
// path rather than on `laneId` alone: the pane can mount before the lane
// list has loaded (split view restores its pane lanes from localStorage),
// and `laneId` never changes afterwards, so a laneId-only effect would leave
// the console pointing at the default directory. RunSetup submits this
// string verbatim, so a stale one starts the run in the wrong folder.
const laneCwd = currentLane?.cwd ?? null;
useEffect(() => {
if (laneCwd) setCwd(laneCwd);
else if (laneId === null) setCwd(latest.current.defaultCwd ?? "");
}, [laneId, laneCwd]);
const attachToRun = useCallback(
async (id: string) => {
if (busy) return;
@@ -296,118 +254,6 @@ export function LaneConsolePane({
[busy, t]
);
const onStartFromSetup = useCallback(
async (args: RunStartArgs) => {
if (busy) return;
setBusy("start");
setError(null);
try {
const effectiveCwd = args.cwd || undefined;
if (!effectiveCwd) {
throw new Error(t("errors.cwdRequired"));
}
// Resolve the lane from the cwd the user actually typed, not from
// args.laneId — RunSetup always supplies this pane's laneId (a
// required prop), which would otherwise silently start a run in the
// wrong lane whenever the user types a cwd different from the one
// this pane currently shows. A cwd can now back both a claude lane
// and a codex lane, so match this pane's own tool too (laneTool,
// which already defaults to "claude" when no lane is selected —
// the same default RunSetup renders) — otherwise the wrong sibling
// lane could be picked.
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd && l.tool === laneTool);
let targetLaneId: number;
if (ownedLane) {
targetLaneId = ownedLane.id;
if (ownedLane.id !== laneId) onLaneIdChange(ownedLane.id);
} else {
try {
const ensureResult = await api.lanes.ensure({
cwd: effectiveCwd,
tool: laneTool,
});
targetLaneId = ensureResult.lane.id;
onLaneIdChange(ensureResult.lane.id);
onLaneCreated(ensureResult.lane);
} catch (err) {
throw new Error(
t("errors.laneCreateFailed", {
message: err instanceof Error ? err.message : "unknown",
})
);
}
}
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
slot,
prompt: args.initialPrompt || "",
model: args.model || undefined,
permissionMode: args.permissionMode,
resumeSessionId: args.resumeSessionId,
effort: args.effort || undefined,
sandbox: args.sandbox,
approval: args.approval,
resumeLast: args.resumeLast,
bypass: args.bypass,
router: args.router,
});
} catch (laneErr: unknown) {
// A code-only check: the server's message is human prose ("lane
// already has a live run"), never the literal strings "409" or
// "ERUNLIVE" — matching against `.message` here is dead code that
// always falls through to the raw error below.
// This pane's own session is the live one — land on it.
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
await attachToRun(paneRunId(targetLaneId, slot));
setError(t("errors.attachedToLiveInstead"));
return;
}
throw laneErr;
}
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
if (!startedId) {
throw new Error(t("errors.noRunIdReturned"));
}
try {
const fetched = await api.run.get(startedId);
setHandle(fetched);
refreshList();
} catch {
try {
await attachToRun(startedId);
refreshList();
} catch (fallbackErr: unknown) {
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
throw new Error(t("errors.runStartedButNotAttached", { message: attachMsg }));
}
}
} catch (err: unknown) {
const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.startFailed", { message: m }));
} finally {
setBusy(null);
}
},
[
busy,
t,
lanes,
laneId,
laneTool,
slot,
onLaneIdChange,
onLaneCreated,
attachToRun,
refreshList,
]
);
const onResumeFromHistory = useCallback(
async (item: DashboardRunHistoryItem) => {
if (!item.session_id) return;
@@ -419,10 +265,11 @@ export function LaneConsolePane({
if (item.cwd) {
const effectiveCwd = item.cwd;
// A cwd can now back both a claude lane and a codex lane — match
// this history item's own tool, the ground truth for which lane it
// belongs to, rather than an arbitrary lane at that cwd.
let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd && l.tool === item.tool)?.id;
// This pane's history is its own lane's (`refreshList`), whatever
// tool each session ran on. With no lane, a cwd can back both a
// claude and a codex lane — match the item's own tool.
let targetLaneId =
laneId ?? lanes.find((l) => l.cwd === effectiveCwd && l.tool === item.tool)?.id;
if (!targetLaneId) {
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd, tool: item.tool });
@@ -476,7 +323,6 @@ export function LaneConsolePane({
}
setHandle(fetched);
setResumeSession(null);
refreshList();
} catch (err) {
const msg = err instanceof Error ? err.message : "unknown";
@@ -485,7 +331,7 @@ export function LaneConsolePane({
setBusy(null);
}
},
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun]
[busy, refreshList, t, lanes, laneId, slot, onLaneCreated, onLaneIdChange, attachToRun]
);
const onViewFromHistory = useCallback(
@@ -497,8 +343,6 @@ export function LaneConsolePane({
const newRun = useCallback(() => {
setHandle(null);
setPrompt("");
setResumeSession(null);
setError(null);
}, []);
@@ -608,12 +452,12 @@ export function LaneConsolePane({
</header>
{/* Not probed yet (a lane on a tool the page has not probed) is not "missing". */}
{binaryStatus?.[laneTool] && !binaryStatus[laneTool].found && (
{binaryStatus?.[paneTool] && !binaryStatus[paneTool].found && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>
{t("binary.missing", {
cli: aiTools.byId(laneTool)?.command ?? laneTool,
cli: aiTools.byId(paneTool)?.command ?? paneTool,
tool: toolLabel,
})}
</span>
@@ -628,52 +472,34 @@ export function LaneConsolePane({
)}
{!handle ? (
<RunSetup
laneId={laneId ?? 0}
prompt={prompt}
onPromptChange={setPrompt}
cwd={cwd}
onCwdChange={setCwd}
cwdSuggestions={cwdSuggestions}
model={model}
onModelChange={setModel}
permissionMode={permissionMode}
onPermissionModeChange={setPermissionMode}
effort={effort}
onEffortChange={setEffort}
binaryFound={binaryStatus?.[laneTool]?.found ?? true}
busy={busy === "start"}
onStart={onStartFromSetup}
activeRuns={activeRuns}
laneCwd={currentLane?.cwd}
resumeSession={resumeSession}
onResumeSessionChange={setResumeSession}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
tool={laneTool}
sandbox={sandbox}
onSandboxChange={setSandbox}
approval={approval}
onApprovalChange={setApproval}
onResumeLast={(bypass) =>
onStartFromSetup({ laneId: laneId ?? 0, cwd, resumeLast: true, bypass })
}
/>
<div
data-testid="pane-no-run"
className="flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
>
<p>{t("emptyPane")}</p>
{onNewSession && (
<button type="button" onClick={onNewSession} className="btn-primary text-xs">
<Plus className="h-3.5 w-3.5" />
{t("actions.newSession")}
</button>
)}
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
<TerminalView
runId={handle!.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// go straight back to the setup form rather than leaving a dead
// terminal the user has to click "New run" out of.
// a single pane goes straight back to the home's prompt box rather
// than leaving a dead terminal the user has to click out of.
onExit={() => {
newRun();
refreshList();
if (homeOnExit) onNewSession?.();
}}
/>
<button
onClick={newRun}
onClick={onNewSession ?? newRun}
className="mt-3 px-3 py-1.5 text-sm rounded border border-border hover:border-border-light text-fg-secondary hover:text-fg-primary transition-colors"
>
{t("actions.newRun")}