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
+76 -51
View File
@@ -44,7 +44,7 @@
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
@@ -58,6 +58,7 @@ import {
Maximize2,
FolderTree,
GitCompare,
ChartColumn,
X,
} from "lucide-react";
import { api } from "../lib/api";
@@ -74,6 +75,7 @@ import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lane
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
import { SessionReplay } from "../components/lanes/SessionReplay";
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
@@ -163,7 +165,7 @@ function SplitLayoutToggle({
);
}
/** A split pane's one-line header: which lane, which slot, the run's model,
/** A split pane's one-line header: the run's AI tool, which lane, which slot, the run's model,
* and — for slots 1–3 — a button that kills that pane's own tmux session
* (`DELETE /api/run/<runId>`). Slot 0 is the lane's run and has no close. */
function PaneHeader({
@@ -171,11 +173,14 @@ function PaneHeader({
slot,
runId,
model,
tool,
}: {
lane: Lane | null;
slot: number;
runId: string | null;
model: string | null;
/** The tool the pane's run is on — a session's own, not the workspace's. */
tool: string | null;
}) {
const { t } = useTranslation("lanes");
const tools = useAiTools();
@@ -184,11 +189,8 @@ function PaneHeader({
data-testid={`pane-header-${slot}`}
className="flex h-6 shrink-0 items-center gap-1.5 rounded border border-border bg-surface-2 px-2 text-[11px] text-fg-muted"
>
{lane && (
<AiToolIcon
tool={{ id: lane.tool, label: tools.label(lane.tool) }}
className="h-3.5 w-3.5 !p-0"
/>
{tool && (
<AiToolIcon tool={{ id: tool, label: tools.label(tool) }} className="h-3.5 w-3.5 !p-0" />
)}
<span className="min-w-0 truncate font-medium text-fg-secondary">
{lane ? lane.title || lane.cwd.split("/").pop() : ""}
@@ -216,33 +218,32 @@ function ConsoleArea({
splitView,
setLayout,
binaryStatus,
cwdSuggestions,
activeRuns,
externalSessions,
wsConnected,
defaultCwd,
onHasActiveRunChange,
onLaneCreated,
onLaneIdChange,
showToggle = true,
attachRequest,
onNewSession,
}: {
lanes: Lane[];
selectedLaneId: number | null;
splitView: SplitViewState;
setLayout: (layout: SplitLayout) => void;
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null;
externalSessions: Session[];
wsConnected: boolean;
defaultCwd: string;
onHasActiveRunChange: (val: boolean) => void;
onLaneCreated: (lane: Lane) => void;
onLaneIdChange: (id: number | null) => void;
showToggle?: boolean;
/** A live run to attach a pane to, from the sidebar (`?run=`, `&pane=`). */
attachRequest: { runId: string; nonce: string; pane: number } | null;
/** Back to the workspace home, where a new session is started. */
onNewSession?: () => void;
}) {
// Every split pane shows the SELECTED lane, each in its own tmux session
// (slot = pane index). Tracking what each pane shows lets the others leave
@@ -280,12 +281,12 @@ function ConsoleArea({
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={onHasActiveRunChange}
onNewSession={onNewSession}
homeOnExit
attachRequest={attachRequest?.pane === 0 ? attachRequest : null}
/>
) : (
@@ -301,6 +302,7 @@ function ConsoleArea({
slot={i}
runId={paneRunIds[i] ?? null}
model={activeRuns?.items.find((r) => r.id === paneRunIds[i])?.model ?? null}
tool={activeRuns?.items.find((r) => r.id === paneRunIds[i])?.tool ?? null}
/>
<LaneConsolePane
lanes={lanes}
@@ -312,11 +314,10 @@ function ConsoleArea({
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onNewSession={onNewSession}
attachRequest={attachRequest?.pane === i ? attachRequest : null}
/>
</div>
@@ -341,6 +342,10 @@ export function Workspace() {
// second pick of the same session a new request.
const location = useLocation();
const runParam = searchParams.get("run");
// `?session=` — an ENDED session picked in the sidebar or Recents: its
// conversation, with a prompt box whose message resumes it (SessionReplay).
const sessionParam = runParam ? null : searchParams.get("session");
const navigate = useNavigate();
// `&pane=<n>` — which split pane attaches it (Ctrl/Cmd+click in the sidebar
// asks for pane 1); pane 0 otherwise.
const paneParam = Math.min(Math.max(Number(searchParams.get("pane")) || 0, 0), 3);
@@ -420,7 +425,6 @@ export function Workspace() {
{ found: boolean; path: string | null }
> | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [defaultCwd, setDefaultCwd] = useState<string>("");
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
// Split view state
@@ -528,22 +532,7 @@ export function Workspace() {
.catch(() => undefined);
api.run
.cwds()
.then((r) => {
setCwdSuggestions(r.items);
// Pre-fill cwd with the user's home directory — a neutral default.
// Spawning in the dashboard's own cwd would make ad-hoc runs inherit
// this repo's project context (.claude/agents, skills, rules,
// CLAUDE.md, .mcp.json), which is almost never what an ad-hoc run
// wants and can bloat the initial request (issue #202). Fall back to
// the dashboard cwd when no home suggestion exists. The user can
// change it; we just don't want an invisible default.
const home = r.items.find((s) => s.kind === "home");
const dashboard = r.items.find((s) => s.kind === "dashboard");
const preferred = home || dashboard;
if (preferred) {
setDefaultCwd(preferred.path);
}
})
.then((r) => setCwdSuggestions(r.items))
.catch(() => undefined);
}, []);
@@ -676,7 +665,11 @@ export function Workspace() {
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
// the console, panes and Changes panel, which the home only hides (CSS), so
// a live terminal never unmounts behind it.
const home = !!currentLane && !runParam;
const home = !!currentLane && !runParam && !sessionParam;
const replaying = !!currentLane && !!sessionParam;
// The session the Analyst button opens (`/sessions/:id`): the replayed one,
// or the one the opened pane holds.
const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null;
const viewportLocked =
!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full"));
@@ -983,25 +976,42 @@ export function Workspace() {
currentLane ? "border-t border-border pt-3" : ""
}`}
>
{replaying && (
<SessionReplay
lane={currentLane}
sessionId={sessionParam}
liveRuns={activeRuns?.items ?? []}
onResumed={(runId) =>
setSearchParams({ lane: String(currentLane.id), run: runId })
}
/>
)}
{/* `showToggle`: the lane header carries its own split toggle; with
no lane there is no header, so the console shows one instead. */}
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
splitView={splitView}
setLayout={setLayout}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={() => void refreshLanes()}
onLaneIdChange={setSelectedLaneId}
showToggle={!currentLane}
attachRequest={attachRequest}
/>
no lane there is no header, so the console shows one instead.
A replay only HIDES it: the console has one render site and a
live terminal must never unmount. */}
<div className={replaying ? "hidden" : "contents"}>
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
splitView={splitView}
setLayout={setLayout}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={() => void refreshLanes()}
onLaneIdChange={setSelectedLaneId}
showToggle={!currentLane}
attachRequest={attachRequest}
onNewSession={
currentLane
? () => setSearchParams({ lane: String(currentLane.id) })
: undefined
}
/>
</div>
</div>
</section>
@@ -1032,6 +1042,21 @@ export function Workspace() {
<Icon className="h-4 w-4" />
</button>
))}
{/* The opened session's analysis page — a page of its own. */}
<button
type="button"
data-testid="panel-analyst"
disabled={!analystSessionId}
onClick={() =>
analystSessionId &&
navigate(`/sessions/${encodeURIComponent(analystSessionId)}`)
}
title={tLanes("panel.analyst")}
aria-label={tLanes("panel.analyst")}
className="flex h-7 w-7 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent"
>
<ChartColumn className="h-4 w-4" />
</button>
<button
type="button"
data-testid="lane-changes-resize"