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> * @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 { useTranslation } from "react-i18next";
import { Play, AlertCircle } from "lucide-react"; import { Play, AlertCircle } from "lucide-react";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
@@ -39,6 +39,8 @@ export interface LaneConsolePaneProps {
cwdSuggestions: CwdSuggestion[]; cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null; activeRuns: RunListResponse | null;
wsConnected: boolean; wsConnected: boolean;
defaultCwd?: string;
onHasActiveRunChange?: (active: boolean) => void;
} }
export function LaneConsolePane({ export function LaneConsolePane({
@@ -51,6 +53,8 @@ export function LaneConsolePane({
cwdSuggestions, cwdSuggestions,
activeRuns, activeRuns,
wsConnected, wsConnected,
defaultCwd,
onHasActiveRunChange,
}: LaneConsolePaneProps) { }: LaneConsolePaneProps) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes"); const { t: tLanes } = useTranslation("lanes");
@@ -60,7 +64,7 @@ export function LaneConsolePane({
const [model, setModel] = useState(""); const [model, setModel] = useState("");
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits"); const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
const [effort, setEffort] = useState<EffortLevel>(""); 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 [resumeSession, setResumeSession] = useState<Session | null>(null);
const [handle, setHandle] = useState<RunHandle | null>(null); const [handle, setHandle] = useState<RunHandle | null>(null);
const [busy, setBusy] = useState<"start" | "kill" | "attach" | 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; const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
}, [handle, onHasActiveRunChange]);
const refreshList = useCallback(() => { const refreshList = useCallback(() => {
if (laneId !== null) { if (laneId !== null) {
api.run api.run
@@ -259,7 +267,7 @@ export function LaneConsolePane({
setError(null); setError(null);
}, []); }, []);
if (laneId === null) { if (laneId === null && !showLaneSelector) {
return ( return (
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4"> <div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
<select <select
@@ -132,10 +132,16 @@ describe("LaneConsolePane", () => {
expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument(); expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument();
}); });
it("renders an empty placeholder with just a picker when laneId is null", () => { it("renders an empty placeholder when laneId is null and showLaneSelector is false", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector />); render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={false} />);
expect(screen.getByTestId("pane-empty")).toBeInTheDocument(); expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
expect(screen.queryByTestId("console-body")).not.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 { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Play, AlertCircle, X, Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import type { import type { CwdSuggestion, RunListResponse } from "../lib/api";
CwdSuggestion, import type { Lane, LaneFeature, LaneCounts, ProofFeature, WSMessage } from "../lib/types";
DashboardRunHistoryItem,
EffortLevel,
PermissionMode,
RunHandle,
RunListResponse,
RunStartArgs,
} from "../lib/api";
import type { Session, Lane, LaneFeature, LaneCounts, ProofFeature, WSMessage } from "../lib/types";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import { TerminalView } from "../components/run/TerminalView"; import { LaneConsolePane } from "../components/run/LaneConsolePane";
import { RunSetup } from "../components/run/RunSetup";
import { ActiveRunsSwitcher } from "../components/run/RunHistory";
import PipelineMap from "../components/lanes/PipelineMap"; import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard from "../components/lanes/LaneCard"; import LaneCard from "../components/lanes/LaneCard";
import LaneStripCard from "../components/lanes/LaneStripCard"; import LaneStripCard from "../components/lanes/LaneStripCard";
@@ -64,9 +53,7 @@ import { AddLaneModal } from "../components/lanes/AddLaneModal";
// ── Page ────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────
export function Workspace() { export function Workspace() {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes"); const { t: tLanes } = useTranslation("lanes");
const [searchParams, setSearchParams] = useSearchParams();
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
// Lane state // Lane state
@@ -83,22 +70,15 @@ export function Workspace() {
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null); const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]); const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
// Run state // Run state kept at page level: shared across every pane, or drives the
const [prompt, setPrompt] = useState(""); // lane strip itself rather than any one pane's form.
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);
const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null); const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>( const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>(
null null
); );
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]); 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 // Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
const refreshLanes = useCallback(async () => { const refreshLanes = useCallback(async () => {
@@ -126,10 +106,6 @@ export function Workspace() {
.list() .list()
.then(setActiveRuns) .then(setActiveRuns)
.catch(() => undefined); .catch(() => undefined);
api.run
.history(50)
.then((r) => setRunHistory(r.items))
.catch(() => undefined);
api.lanes api.lanes
.pipelines() .pipelines()
.then((r) => setPipelineTemplates(r.pipelines)) .then((r) => setPipelineTemplates(r.pipelines))
@@ -150,7 +126,7 @@ export function Workspace() {
const dashboard = r.items.find((s) => s.kind === "dashboard"); const dashboard = r.items.find((s) => s.kind === "dashboard");
const preferred = home || dashboard; const preferred = home || dashboard;
if (preferred) { if (preferred) {
setCwd((current) => current || preferred.path); setDefaultCwd(preferred.path);
} }
}) })
.catch(() => undefined); .catch(() => undefined);
@@ -196,19 +172,7 @@ export function Workspace() {
.list() .list()
.then(setActiveRuns) .then(setActiveRuns)
.catch(() => undefined); .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 // Background poll so the run list and history reflect external changes
// (server-boot reconciliation, sibling tabs, direct DB edits) even when // (server-boot reconciliation, sibling tabs, direct DB edits) even when
@@ -237,406 +201,6 @@ export function Workspace() {
}; };
}, [refreshList]); }, [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; const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
// Feature list follows the selected lane, resets the viewer on lane switch. // 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) ? proofFeatures.find((f) => f.slug === activeFeatureSlug)
: null; : null;
// Only lock the page to the viewport when we're showing a live run session. // Viewport locked only when a live run is showing (TerminalView needs locked
// The config-card screen needs normal page flow so the form is fully // viewport for chat scrolling). The config-card screen needs normal page flow
// reachable on short windows. The run-session screen, however, owns the // so the form is fully reachable on short windows.
// chat panel and we want long chats to scroll inside the panel - never the const viewportLocked = paneHasActiveRun;
// 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;
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => { const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
setLaneActionError(null); 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 ( return (
<div <div
className={ className={
@@ -894,10 +370,7 @@ export function Workspace() {
key={l.id} key={l.id}
lane={l} lane={l}
selected={selectedLaneId === l.id} selected={selectedLaneId === l.id}
onSelect={() => { onSelect={() => setSelectedLaneId(l.id)}
setSelectedLaneId(l.id);
setCwd(l.cwd);
}}
/> />
))} ))}
{!lanes.length && ( {!lanes.length && (
@@ -1041,7 +514,21 @@ export function Workspace() {
</div> </div>
)} )}
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3"> <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> </div>
</section> </section>
)} )}
@@ -1069,65 +556,24 @@ export function Workspace() {
{/* No lane selected (none exist, or nothing picked yet): the console has {/* 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 nowhere to attach, so it falls back to page level. Without this the
start form would be unreachable on a fresh install. */} 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> </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> </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`] = ` exports[`screen snapshots > Kanban board 1`] = `
<div> <div>
<div <div
@@ -5782,6 +5117,11 @@ exports[`screen snapshots > Run 1`] = `
class="flex min-h-0 flex-1 flex-col gap-5" class="flex min-h-0 flex-1 flex-col gap-5"
data-testid="console-body" 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 <header
class="flex items-start gap-3" class="flex items-start gap-3"
> >