refactor(workspace): fix viewportLocked regression and wire run status callback

This commit is contained in:
2026-08-14 12:09:46 +07:00
parent 8a61a2b359
commit 22ce61bcfe
4 changed files with 74 additions and 1274 deletions
+11 -3
View File
@@ -11,7 +11,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Play, AlertCircle } from "lucide-react";
import { api } from "../../lib/api";
@@ -39,6 +39,8 @@ export interface LaneConsolePaneProps {
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null;
wsConnected: boolean;
defaultCwd?: string;
onHasActiveRunChange?: (active: boolean) => void;
}
export function LaneConsolePane({
@@ -51,6 +53,8 @@ export function LaneConsolePane({
cwdSuggestions,
activeRuns,
wsConnected,
defaultCwd,
onHasActiveRunChange,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
@@ -60,7 +64,7 @@ export function LaneConsolePane({
const [model, setModel] = useState("");
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
const [effort, setEffort] = useState<EffortLevel>("");
const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? "");
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);
@@ -69,6 +73,10 @@ export function LaneConsolePane({
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
}, [handle, onHasActiveRunChange]);
const refreshList = useCallback(() => {
if (laneId !== null) {
api.run
@@ -259,7 +267,7 @@ export function LaneConsolePane({
setError(null);
}, []);
if (laneId === null) {
if (laneId === null && !showLaneSelector) {
return (
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
<select
@@ -132,10 +132,16 @@ describe("LaneConsolePane", () => {
expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument();
});
it("renders an empty placeholder with just a picker when laneId is null", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector />);
it("renders an empty placeholder when laneId is null and showLaneSelector is false", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={false} />);
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
});
it("renders the full console with selector when laneId is null but showLaneSelector is true", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
expect(screen.getByTestId("console-body")).toBeInTheDocument();
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
});
});
+49 -603
View File
@@ -37,25 +37,14 @@
*
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Play, AlertCircle, X, Plus } from "lucide-react";
import { Plus } from "lucide-react";
import { api } from "../lib/api";
import type {
CwdSuggestion,
DashboardRunHistoryItem,
EffortLevel,
PermissionMode,
RunHandle,
RunListResponse,
RunStartArgs,
} from "../lib/api";
import type { Session, Lane, LaneFeature, LaneCounts, ProofFeature, WSMessage } from "../lib/types";
import type { CwdSuggestion, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, LaneCounts, ProofFeature, WSMessage } from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { TerminalView } from "../components/run/TerminalView";
import { RunSetup } from "../components/run/RunSetup";
import { ActiveRunsSwitcher } from "../components/run/RunHistory";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard from "../components/lanes/LaneCard";
import LaneStripCard from "../components/lanes/LaneStripCard";
@@ -64,9 +53,7 @@ import { AddLaneModal } from "../components/lanes/AddLaneModal";
// ── Page ──────────────────────────────────────────────────────────────
export function Workspace() {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
const [searchParams, setSearchParams] = useSearchParams();
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
// Lane state
@@ -83,22 +70,15 @@ export function Workspace() {
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
// Run state
const [prompt, setPrompt] = useState("");
const [model, setModel] = useState("");
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
const [effort, setEffort] = useState<EffortLevel>("");
const [cwd, setCwd] = useState("");
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);
// Run state kept at page level: shared across every pane, or drives the
// lane strip itself rather than any one pane's form.
const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>(
null
);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [defaultCwd, setDefaultCwd] = useState<string>("");
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
const refreshLanes = useCallback(async () => {
@@ -126,10 +106,6 @@ export function Workspace() {
.list()
.then(setActiveRuns)
.catch(() => undefined);
api.run
.history(50)
.then((r) => setRunHistory(r.items))
.catch(() => undefined);
api.lanes
.pipelines()
.then((r) => setPipelineTemplates(r.pipelines))
@@ -150,7 +126,7 @@ export function Workspace() {
const dashboard = r.items.find((s) => s.kind === "dashboard");
const preferred = home || dashboard;
if (preferred) {
setCwd((current) => current || preferred.path);
setDefaultCwd(preferred.path);
}
})
.catch(() => undefined);
@@ -196,19 +172,7 @@ export function Workspace() {
.list()
.then(setActiveRuns)
.catch(() => undefined);
// Fetch history for the selected lane only
if (selectedLaneId !== null) {
api.run
.history(50, { laneId: selectedLaneId })
.then((r) => setRunHistory(r.items))
.catch(() => undefined);
} else {
api.run
.history(50)
.then((r) => setRunHistory(r.items))
.catch(() => undefined);
}
}, [selectedLaneId]);
}, []);
// Background poll so the run list and history reflect external changes
// (server-boot reconciliation, sibling tabs, direct DB edits) even when
@@ -237,406 +201,6 @@ export function Workspace() {
};
}, [refreshList]);
// Resume a run from the persistent history list. Routes through the lane
// system: ensure a lane for the cwd, then start a new claude process
// in resumeSessionId mode. If cwd is null, resume as a non-lane run
// (non-lane runs are those created before this feature or via the CLI).
const onResumeFromHistory = useCallback(
async (item: DashboardRunHistoryItem) => {
if (!item.session_id) return;
if (busy) return;
setBusy("start");
setError(null);
try {
let fetched: RunHandle;
if (item.cwd) {
// Resume through a lane: ensure the lane exists, then start on it
const effectiveCwd = item.cwd;
let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd)?.id;
if (!targetLaneId) {
// Ensure lane for this cwd
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
targetLaneId = ensureResult.lane.id;
setLanes((prev) => {
const exists = prev.some((l) => l.id === ensureResult.lane.id);
return exists ? prev : [...prev, ensureResult.lane];
});
}
// Start on the lane with resume
const laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: "",
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
effort: item.effort || undefined,
resumeSessionId: item.session_id,
});
if (!laneStartResult.lane?.run_id) {
throw new Error("No run_id returned from lane start");
}
fetched = await api.run.get(laneStartResult.lane.run_id);
} else {
// No cwd: resume as a non-lane run (backward compatibility).
// These runs stay outside the lane system and are cleaned up
// by their own expiry, not by lane release.
fetched = await api.run.start({
laneId: 0,
initialPrompt: "",
cwd: undefined,
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
effort: item.effort || undefined,
resumeSessionId: item.session_id,
});
}
setHandle(fetched);
setResumeSession(null);
refreshList();
} catch (err) {
const msg = err instanceof Error ? err.message : "unknown";
setError(t("errors.startFailed", { message: msg }));
} finally {
setBusy(null);
}
},
[busy, refreshList, t, lanes]
);
// View a past run: navigate to the SessionDetail page which shows the transcript.
const navigate = useNavigate();
const onViewFromHistory = useCallback(
(item: DashboardRunHistoryItem) => {
if (item.session_id) {
navigate(`/sessions/${encodeURIComponent(item.session_id)}`);
}
},
[navigate]
);
const start = useCallback(async () => {
if (!prompt.trim() || busy) return;
setBusy("start");
setError(null);
try {
const effectiveCwd = resumeSession?.cwd || cwd || undefined;
// Determine which lane to use. If no lane is selected or the cwd
// doesn't belong to the selected lane, ensure a lane for this cwd first.
if (!effectiveCwd) {
throw new Error(t("errors.cwdRequired"));
}
let targetLaneId = selectedLaneId;
if (
targetLaneId === null ||
(selectedLaneId !== null &&
lanes.find((l) => l.id === selectedLaneId)?.cwd !== effectiveCwd)
) {
// Check if any existing lane owns this cwd
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd);
if (ownedLane) {
targetLaneId = ownedLane.id;
setSelectedLaneId(ownedLane.id);
} else {
// Ensure a new lane for this cwd
try {
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
targetLaneId = ensureResult.lane.id;
setSelectedLaneId(ensureResult.lane.id);
setLanes((prev) => {
const exists = prev.some((l) => l.id === ensureResult.lane.id);
return exists ? prev : [...prev, ensureResult.lane];
});
} catch (err) {
throw new Error(
t("errors.laneCreateFailed", {
message: err instanceof Error ? err.message : "unknown",
})
);
}
}
}
if (targetLaneId === null) {
throw new Error(t("errors.noLaneSelected"));
}
// Start the run on the target lane
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: prompt || "",
model: model || undefined,
permissionMode,
resumeSessionId: resumeSession?.id,
effort: effort || undefined,
});
} catch (laneErr: unknown) {
// Check for 409 ERUNLIVE — the lane already has a live run
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
// Re-fetch the lane to get its current run_id. Read the run id off
// the response, not off `lanes` - refreshLanes() only schedules a
// setState, so the render-scope `lanes` array here is still the
// pre-409 snapshot and would never carry the live run.
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
await refreshLanes();
if (updatedLane?.run_id) {
// Attach to the already-running run
await attachToRun(updatedLane.run_id);
return;
}
}
throw laneErr;
}
// The response is { lane? } per the API. Read the run_id from the lane.
if (!laneStartResult.lane?.run_id) {
throw new Error(t("errors.noRunIdReturned"));
}
// Fetch the full RunHandle for the new run; fall back to attachToRun if fetch fails
try {
const handle = await api.run.get(laneStartResult.lane.run_id);
setHandle(handle);
refreshList();
} catch (attachErr: unknown) {
// Run started but we can't fetch the handle. Attach to the run via the existing path.
try {
await attachToRun(laneStartResult.lane.run_id);
refreshList();
} catch (fallbackErr: unknown) {
// Even attach failed. Refresh lanes and report the attach error.
await refreshLanes();
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);
}
}, [
prompt,
cwd,
model,
permissionMode,
busy,
refreshList,
t,
resumeSession,
selectedLaneId,
lanes,
]);
const attachToRun = useCallback(
async (id: string) => {
if (busy) return;
setBusy("attach");
setError(null);
try {
const fetched = await api.run.get(id);
setHandle(fetched);
} catch (err: unknown) {
const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.attachFailed", { message: m }));
} finally {
setBusy(null);
}
},
[busy, t]
);
// Honor `?session=<id>` deep-links from /sessions and /sessions/:id -
// map the session id to a live run handle and attach to it instead of
// dropping the user on the new-run config card. Strip the param once
// consumed so a refresh of the Run page doesn't keep re-attaching.
const attachAttemptedRef = useRef<Set<string>>(new Set());
useEffect(() => {
const sid = searchParams.get("session");
if (!sid) return;
if (handle && handle.sessionId === sid) {
// Already attached to this session - just clean the URL.
const next = new URLSearchParams(searchParams);
next.delete("session");
setSearchParams(next, { replace: true });
return;
}
if (attachAttemptedRef.current.has(sid)) return;
attachAttemptedRef.current.add(sid);
api.run
.list()
.then((list) => {
const target = list.items.find((h) => h.sessionId === sid && h.status === "running");
if (target) {
void attachToRun(target.id);
} else {
setError(
t(
"errors.sessionRunNotFound",
"No active dashboard run is driving this session right now."
)
);
}
})
.catch(() => undefined)
.finally(() => {
const next = new URLSearchParams(searchParams);
next.delete("session");
setSearchParams(next, { replace: true });
});
}, [searchParams, setSearchParams, handle, attachToRun, t]);
// Prefill the prompt box from `?prompt=<text>` (e.g. Tabby's Ask handoff).
// Apply once, then strip the param so a later refresh doesn't overwrite edits
// the user has since made to the prompt. When `?autostart=1` is also present
// (Tabby's "ask" path), arm a pending flag so the run fires automatically
// once preflight is ready - see the autostart effect below.
const promptPrefilledRef = useRef(false);
const pendingAutostartRef = useRef(false);
useEffect(() => {
if (promptPrefilledRef.current) return;
const p = searchParams.get("prompt");
if (!p) return;
promptPrefilledRef.current = true;
if (searchParams.get("autostart") === "1") pendingAutostartRef.current = true;
setPrompt(p);
const next = new URLSearchParams(searchParams);
next.delete("prompt");
next.delete("autostart");
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
// Autostart a deep-linked prompt once preflight has settled. We wait for the
// binary probe (can't spawn without `claude`), the prefilled prompt, and the
// defaulted cwd so the spawn matches exactly what the manual Start button
// would do. Fires at most once; if `claude` isn't found or a run is already
// in flight, it disarms and leaves the prompt prefilled for a manual Start.
useEffect(() => {
if (!pendingAutostartRef.current) return;
if (binaryStatus === null) return; // probe still pending
if (!binaryStatus.found) {
pendingAutostartRef.current = false;
return;
}
if (busy || handle) {
pendingAutostartRef.current = false;
return;
}
if (!prompt.trim() || !cwd) return; // wait for prefill + cwd default
pendingAutostartRef.current = false;
void start();
}, [binaryStatus, prompt, cwd, busy, handle, start]);
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 the currently-selected lane's id
// (a required prop), which would otherwise silently start a run in the wrong
// lane whenever the user types a cwd different from the one currently selected.
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd);
let targetLaneId: number;
if (ownedLane) {
targetLaneId = ownedLane.id;
if (ownedLane.id !== args.laneId) setSelectedLaneId(ownedLane.id);
} else {
try {
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
targetLaneId = ensureResult.lane.id;
setSelectedLaneId(ensureResult.lane.id);
setLanes((prev) => {
const exists = prev.some((l) => l.id === ensureResult.lane.id);
return exists ? prev : [...prev, ensureResult.lane];
});
} catch (err) {
throw new Error(
t("errors.laneCreateFailed", {
message: err instanceof Error ? err.message : "unknown",
})
);
}
}
if (!targetLaneId) {
throw new Error(t("errors.noLaneSelected"));
}
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: args.initialPrompt || "",
model: args.model || undefined,
permissionMode: args.permissionMode,
resumeSessionId: args.resumeSessionId,
effort: args.effort || undefined,
});
} catch (laneErr: unknown) {
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
await refreshLanes();
if (updatedLane?.run_id) {
await attachToRun(updatedLane.run_id);
return;
}
}
throw laneErr;
}
if (!laneStartResult.lane?.run_id) {
throw new Error(t("errors.noRunIdReturned"));
}
try {
const handle = await api.run.get(laneStartResult.lane.run_id);
setHandle(handle);
refreshList();
} catch (attachErr: unknown) {
try {
await attachToRun(laneStartResult.lane.run_id);
refreshList();
} catch (fallbackErr: unknown) {
await refreshLanes();
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, selectedLaneId, refreshLanes, attachToRun, refreshList, prompt]
);
const newRun = useCallback(() => {
setHandle(null);
setPrompt("");
setResumeSession(null);
setError(null);
}, []);
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
// Feature list follows the selected lane, resets the viewer on lane switch.
@@ -696,15 +260,10 @@ export function Workspace() {
? proofFeatures.find((f) => f.slug === activeFeatureSlug)
: null;
// Only lock the page to the viewport when we're showing a live run session.
// The config-card screen needs normal page flow so the form is fully
// reachable on short windows. The run-session screen, however, owns the
// chat panel and we want long chats to scroll inside the panel - never the
// page - so we constrain only that case.
// Only a live console needs the viewport-locked shell that lets its chat
// panel scroll internally. Collapsed, the page is an ordinary scrolling
// document and the lane grid gets the whole height.
const viewportLocked = !!handle;
// Viewport locked only when a live run is showing (TerminalView needs locked
// viewport for chat scrolling). The config-card screen needs normal page flow
// so the form is fully reachable on short windows.
const viewportLocked = paneHasActiveRun;
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
setLaneActionError(null);
@@ -746,89 +305,6 @@ export function Workspace() {
}
};
const consoleSection = (
<>
{/* Always attached under the pipeline - no header, no collapse. The
pipeline panel above already names the lane; unmounting RunConsole
would throw away a live run's rendered history and scroll
position, so this stays mounted for the page's whole life. */}
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
<Header
activeRuns={activeRuns}
currentHandleId={handle?.id || null}
onAttach={attachToRun}
wsConnected={wsConnected}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onRefresh={refreshList}
/>
{binaryStatus && !binaryStatus.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")}</span>
</div>
)}
{error && (
<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 className="flex-1 break-all">{error}</span>
<button
onClick={() => setError(null)}
className="text-status-danger/70 hover:text-status-danger p-0.5"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{!handle ? (
// Config card uses normal page flow - page scrolls if needed.
<RunSetup
laneId={currentLane?.id || 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?.found ?? true}
busy={busy === "start"}
onStart={onStartFromSetup}
activeRuns={activeRuns}
laneCwd={currentLane?.cwd}
resumeSession={resumeSession}
onResumeSessionChange={setResumeSession}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
/>
) : (
// Run session is wrapped in a flex container so the terminal panel
// can take all remaining viewport height.
<div className="flex-1 min-h-0 flex flex-col">
<TerminalView
runId={handle.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
/>
<button
onClick={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")}
</button>
</div>
)}
</div>
</>
);
return (
<div
className={
@@ -894,10 +370,7 @@ export function Workspace() {
key={l.id}
lane={l}
selected={selectedLaneId === l.id}
onSelect={() => {
setSelectedLaneId(l.id);
setCwd(l.cwd);
}}
onSelect={() => setSelectedLaneId(l.id)}
/>
))}
{!lanes.length && (
@@ -1041,7 +514,21 @@ export function Workspace() {
</div>
)}
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
{consoleSection}
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
showLaneSelector={false}
onLaneIdChange={(id) => setSelectedLaneId(id)}
onLaneCreated={(lane) =>
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun}
/>
</div>
</section>
)}
@@ -1069,65 +556,24 @@ export function Workspace() {
{/* No lane selected (none exist, or nothing picked yet): the console has
nowhere to attach, so it falls back to page level. Without this the
start form would be unreachable on a fresh install. */}
{!currentLane && <div className="flex min-h-0 flex-col gap-2">{consoleSection}</div>}
{!currentLane && (
<div className="flex min-h-0 flex-col gap-2">
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
showLaneSelector={true}
onLaneIdChange={(id) => setSelectedLaneId(id)}
onLaneCreated={(lane) =>
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
/>
</div>
)}
</div>
);
}
// ── Header ────────────────────────────────────────────────────────────
function Header({
activeRuns,
currentHandleId,
onAttach,
wsConnected,
runHistory,
onResumeFromHistory,
onViewFromHistory,
onRefresh,
}: {
activeRuns: RunListResponse | null;
currentHandleId: string | null;
onAttach: (id: string) => void;
wsConnected: boolean;
runHistory: DashboardRunHistoryItem[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
onRefresh: () => void;
}) {
const { t } = useTranslation("run");
const { t: tCommon } = useTranslation("common");
return (
<header className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
<Play className="w-4.5 h-4.5 text-accent" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{tCommon("live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")}
</span>
)}
</div>
<p className="text-xs text-fg-muted max-w-3xl">{t("subtitle")}</p>
</div>
<ActiveRunsSwitcher
activeRuns={activeRuns}
currentHandleId={currentHandleId}
onAttach={onAttach}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onRefresh={onRefresh}
/>
</header>
);
}
@@ -4722,671 +4722,6 @@ exports[`screen snapshots > Claude Config 1`] = `
</div>
`;
exports[`screen snapshots > Dashboard 1`] = `
<div>
<div
class="flex flex-col gap-8 animate-fade-in min-h-[calc(100vh-4rem)]"
>
<div
class="flex flex-wrap items-center justify-between gap-3"
>
<div
class="flex items-center gap-3"
>
<div
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center"
>
<svg
class="lucide lucide-layout-dashboard w-4.5 h-4.5 text-accent"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="9"
rx="1"
width="7"
x="3"
y="3"
/>
<rect
height="5"
rx="1"
width="7"
x="14"
y="3"
/>
<rect
height="9"
rx="1"
width="7"
x="14"
y="12"
/>
<rect
height="5"
rx="1"
width="7"
x="3"
y="16"
/>
</svg>
</div>
<div>
<div
class="flex items-center gap-2"
>
<h1
class="text-lg font-semibold text-fg-primary"
>
Dashboard
</h1>
<span
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
>
<span
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
/>
Live
</span>
</div>
<p
class="text-xs text-fg-muted"
>
Real-time overview of Claude Code agent activity
</p>
</div>
</div>
<div
class="flex items-center gap-3"
>
<div
class="flex bg-surface-2 rounded-lg p-0.5 border border-border"
>
<button
class="px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 bg-accent/15 text-accent shadow-sm"
>
<svg
class="lucide lucide-activity w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"
/>
</svg>
Monitor
</button>
<button
class="px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 text-fg-muted hover:text-fg-secondary"
>
<svg
class="lucide lucide-server w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="8"
rx="2"
ry="2"
width="20"
x="2"
y="2"
/>
<rect
height="8"
rx="2"
ry="2"
width="20"
x="2"
y="14"
/>
<line
x1="6"
x2="6.01"
y1="6"
y2="6"
/>
<line
x1="6"
x2="6.01"
y1="18"
y2="18"
/>
</svg>
Health
</button>
</div>
<button
class="btn-ghost flex-shrink-0"
>
<svg
class="lucide lucide-refresh-cw w-4 h-4"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
/>
<path
d="M21 3v5h-5"
/>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
/>
<path
d="M8 16H3v5"
/>
</svg>
Refresh
</button>
</div>
</div>
<div
class="flex-1 flex flex-col gap-8 min-h-0"
>
<div
class="grid grid-cols-2 md:grid-cols-3 gap-4"
>
<div
class="card p-5"
>
<div
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
>
Total Sessions
</span>
<svg
class="lucide lucide-folder-open w-5 h-5 flex-shrink-0 text-accent"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
/>
</svg>
</div>
<div
class="flex items-end gap-2 min-w-0"
>
<span
class="relative inline-block cursor-default"
>
<span
class="text-2xl font-semibold text-fg-primary truncate"
>
0
</span>
</span>
<span
class="text-xs text-fg-muted mb-1 flex-shrink-0"
>
0 active
</span>
</div>
</div>
<div
class="card p-5"
>
<div
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
>
Active Agents
</span>
<svg
class="lucide lucide-bot w-5 h-5 flex-shrink-0 text-status-success"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 8V4H8"
/>
<rect
height="12"
rx="2"
width="16"
x="4"
y="8"
/>
<path
d="M2 14h2"
/>
<path
d="M20 14h2"
/>
<path
d="M15 13v2"
/>
<path
d="M9 13v2"
/>
</svg>
</div>
<div
class="flex items-end gap-2 min-w-0"
>
<span
class="text-2xl font-semibold text-fg-primary truncate"
>
0
</span>
</div>
</div>
<div
class="card p-5"
>
<div
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
>
Active Subagents
</span>
<svg
class="lucide lucide-git-branch w-5 h-5 flex-shrink-0 text-violet-400"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="6"
x2="6"
y1="3"
y2="15"
/>
<circle
cx="18"
cy="6"
r="3"
/>
<circle
cx="6"
cy="18"
r="3"
/>
<path
d="M18 9a9 9 0 0 1-9 9"
/>
</svg>
</div>
<div
class="flex items-end gap-2 min-w-0"
>
<span
class="text-2xl font-semibold text-fg-primary truncate"
>
0
</span>
<span
class="text-xs text-fg-muted mb-1 flex-shrink-0"
>
0 in active sessions
</span>
</div>
</div>
<div
class="card p-5"
>
<div
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
>
Events Today
</span>
<svg
class="lucide lucide-zap w-5 h-5 flex-shrink-0 text-yellow-400"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"
/>
</svg>
</div>
<div
class="flex items-end gap-2 min-w-0"
>
<span
class="relative inline-block cursor-default"
>
<span
class="text-2xl font-semibold text-fg-primary truncate"
>
0
</span>
</span>
</div>
</div>
<div
class="card p-5"
>
<div
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
>
Total Events
</span>
<svg
class="lucide lucide-activity w-5 h-5 flex-shrink-0 text-violet-400"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"
/>
</svg>
</div>
<div
class="flex items-end gap-2 min-w-0"
>
<span
class="relative inline-block cursor-default"
>
<span
class="text-2xl font-semibold text-fg-primary truncate"
>
0
</span>
</span>
</div>
</div>
<div
class="card p-5"
>
<div
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
>
Total Cost
</span>
<svg
class="lucide lucide-dollar-sign w-5 h-5 flex-shrink-0 text-status-success"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="12"
x2="12"
y1="2"
y2="22"
/>
<path
d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
/>
</svg>
</div>
<div
class="flex items-end gap-2 min-w-0"
>
<span
class="relative inline-block cursor-default"
>
<span
class="text-2xl font-semibold text-fg-primary truncate"
>
$0.00
</span>
</span>
</div>
</div>
</div>
<div
class="grid grid-cols-1 lg:grid-cols-[1fr_auto_1fr] gap-0 min-w-0 flex-1 min-h-0"
>
<div
class="min-w-0 overflow-y-auto pr-6"
>
<div
class="flex items-center justify-between mb-4"
>
<h3
class="text-sm font-medium text-fg-secondary"
>
Active Agents
</h3>
<button
class="btn-ghost text-xs"
>
View Board
<svg
class="lucide lucide-arrow-right w-3 h-3"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12h14"
/>
<path
d="m12 5 7 7-7 7"
/>
</svg>
</button>
</div>
<div
class="flex flex-col items-center justify-center py-20 text-center"
>
<div
class="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5"
>
<svg
class="lucide lucide-bot w-6 h-6 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 8V4H8"
/>
<rect
height="12"
rx="2"
width="16"
x="4"
y="8"
/>
<path
d="M2 14h2"
/>
<path
d="M20 14h2"
/>
<path
d="M15 13v2"
/>
<path
d="M9 13v2"
/>
</svg>
</div>
<h3
class="text-base font-medium text-fg-secondary mb-2"
>
No active agents
</h3>
<p
class="text-sm text-fg-muted max-w-md mb-6"
>
Agents will appear here when a Claude Code session is running.
</p>
</div>
</div>
<div
class="hidden lg:block w-px bg-border self-stretch"
/>
<div
class="min-w-0 overflow-y-auto pl-6"
>
<div
class="flex items-center justify-between mb-4"
>
<h3
class="text-sm font-medium text-fg-secondary"
>
Recent Activity
</h3>
<button
class="btn-ghost text-xs"
>
View All
<svg
class="lucide lucide-arrow-right w-3 h-3"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12h14"
/>
<path
d="m12 5 7 7-7 7"
/>
</svg>
</button>
</div>
<div
class="flex flex-col items-center justify-center py-20 text-center"
>
<div
class="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5"
>
<svg
class="lucide lucide-activity w-6 h-6 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"
/>
</svg>
</div>
<h3
class="text-base font-medium text-fg-secondary mb-2"
>
No activity yet
</h3>
<p
class="text-sm text-fg-muted max-w-md mb-6"
>
Events from Claude Code sessions will stream here in real-time.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`screen snapshots > Kanban board 1`] = `
<div>
<div
@@ -5782,6 +5117,11 @@ exports[`screen snapshots > Run 1`] = `
class="flex min-h-0 flex-1 flex-col gap-5"
data-testid="console-body"
>
<select
aria-label="Pane lane selector"
class="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-secondary"
data-testid="pane-lane-select"
/>
<header
class="flex items-start gap-3"
>