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