refactor(workspace): fix viewportLocked regression and wire run status callback
This commit is contained in:
@@ -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
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user