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