2f39f4ec98
Combines three tasks that couldn't land as separate commits: the pre-commit hook's full test run crashes on any intermediate state where Workspace.tsx still imports the files being deleted, so the deletion (old RunConsole/useRunStream/run-spawner/stream-json-parser), the RunSetup/RunHistory type adjustments, and this file's own TerminalView wiring had to be staged together and committed as one hook-passable unit. - Delete RunConsole.tsx, useRunStream.ts, server/lib/run-spawner.js, server/lib/stream-json-parser.js and their tests (Task 8). - Adjust RunSetup.tsx/RunHistory.tsx to the tmux-backed RunHandle/ RunStartArgs/DashboardRunHistoryItem shapes, remove mode selection UI (Task 9). - Swap Workspace.tsx's chat-bubble run console for TerminalView (xterm.js over /ws-pty/:runId), drop the stream-json envelope plumbing, update Start/Resume to the new RunStartArgs payload. Create onStartFromSetup handler to work with RunSetup's new callback shape. Remove mode state and related plumbing. Remove send/followUp state (no longer using old RunConsole chat interface). - Add promptPlaceholderTerminal i18n key to support RunSetup's new placeholder text (Task 10). - Update Workspace.test.tsx to mock TerminalView component. - Regenerate screens.snapshot.test.tsx snapshot (only Workspace run panel changes: terminal container instead of chat bubbles).
1250 lines
47 KiB
TypeScript
1250 lines
47 KiB
TypeScript
/**
|
|
* @file Workspace.tsx
|
|
* @description Merged workspace page combining lanes (agent work units) and runs
|
|
* (Claude Code spawns). Displays a horizontal lane strip at the top with counters,
|
|
* the selected lane's pipeline map, and run configuration/console/history below.
|
|
* Runs are tied to lanes: starting a run posts to POST /api/lanes/:id/start.
|
|
* When a cwd isn't owned by any lane, calls POST /api/lanes/ensure first.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
/* =============================================================================
|
|
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
|
* =============================================================================
|
|
* ## Design constraints
|
|
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
|
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
|
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
|
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
|
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
|
* - The console never writes a lane's stage: no POST /api/lanes/:id/stage calls.
|
|
*
|
|
* ## Internal dependencies
|
|
* - `../lib/api` — REST client including lanes and run endpoints.
|
|
* - `../lib/types` — Lane, Lane Counts, run handles, etc.
|
|
* - `../lib/eventBus` — WebSocket subscription.
|
|
* - `../components/lanes/` — LaneCard, PipelineMap, DestructiveLaneModal.
|
|
* - `../components/run/` — RunConsole, RunSetup, RunHistory, ActiveRunsSwitcher.
|
|
*
|
|
* ## Public surface
|
|
* - `Workspace` — merged page; see TSDoc on the symbol for behavior.
|
|
*
|
|
* ============================================================================= */
|
|
/* -----------------------------------------------------------------------------
|
|
* EXPORT CATALOG
|
|
* **Workspace**
|
|
* The main exported page component. Merges lanes and runs into one workspace.
|
|
*
|
|
* ----------------------------------------------------------------------------- */
|
|
|
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Play, AlertCircle, X, Plus } from "lucide-react";
|
|
import { api } from "../lib/api";
|
|
import type {
|
|
CwdSuggestion,
|
|
DashboardRunHistoryItem,
|
|
EffortLevel,
|
|
PermissionMode,
|
|
RunHandle,
|
|
RunListResponse,
|
|
RunMode,
|
|
} from "../lib/api";
|
|
import type {
|
|
Session,
|
|
TranscriptMessage,
|
|
TranscriptContent,
|
|
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 PipelineMap from "../components/lanes/PipelineMap";
|
|
import LaneCard from "../components/lanes/LaneCard";
|
|
import LaneStripCard from "../components/lanes/LaneStripCard";
|
|
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
|
|
const [lanes, setLanes] = useState<Lane[]>([]);
|
|
const [counts, setCounts] = useState<LaneCounts>({ total: 0, running: 0, needs_you: 0, dead: 0 });
|
|
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
|
|
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
|
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
|
const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null);
|
|
const [features, setFeatures] = useState<LaneFeature[]>([]);
|
|
const [pipelineTemplates, setPipelineTemplates] = useState<
|
|
{ id: string; name: string; nodes: { id: string }[] }[]
|
|
>([]);
|
|
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);
|
|
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 [slashCommands, setSlashCommands] = useState<any[]>([]);
|
|
|
|
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
|
|
const refreshLanes = useCallback(async () => {
|
|
try {
|
|
const r = await api.lanes.list();
|
|
setLanes(r.lanes);
|
|
setCounts(r.counts);
|
|
setSelectedLaneId((cur) =>
|
|
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
|
);
|
|
} catch {
|
|
// Silent fail for lanes load
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
api.run
|
|
.binary()
|
|
.then(setBinaryStatus)
|
|
// Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
|
|
// `claude` is missing from PATH — leave the probe unresolved rather than
|
|
// showing a misleading "claude missing" banner for an unrelated fault.
|
|
.catch(() => undefined);
|
|
api.run
|
|
.list()
|
|
.then(setActiveRuns)
|
|
.catch(() => undefined);
|
|
api.run
|
|
.history(50)
|
|
.then((r) => setRunHistory(r.items))
|
|
.catch(() => undefined);
|
|
api.lanes
|
|
.pipelines()
|
|
.then((r) => setPipelineTemplates(r.pipelines))
|
|
.catch(() => undefined);
|
|
void refreshLanes();
|
|
api.run
|
|
.cwds()
|
|
.then((r) => {
|
|
setCwdSuggestions(r.items);
|
|
// Pre-fill cwd with the user's home directory — a neutral default.
|
|
// Spawning in the dashboard's own cwd would make ad-hoc runs inherit
|
|
// this repo's project context (.claude/agents, skills, rules,
|
|
// CLAUDE.md, .mcp.json), which is almost never what an ad-hoc run
|
|
// wants and can bloat the initial request (issue #202). Fall back to
|
|
// the dashboard cwd when no home suggestion exists. The user can
|
|
// change it; we just don't want an invisible default.
|
|
const home = r.items.find((s) => s.kind === "home");
|
|
const dashboard = r.items.find((s) => s.kind === "dashboard");
|
|
const preferred = home || dashboard;
|
|
if (preferred) {
|
|
setCwd((current) => current || preferred.path);
|
|
}
|
|
})
|
|
.catch(() => undefined);
|
|
// Discover user / project / plugin slash commands. The CLI's built-ins
|
|
// are appended client-side.
|
|
Promise.all([api.ccConfig.commands(), api.ccConfig.plugins()])
|
|
.then(([cmdsResp, pluginsResp]) => {
|
|
const userProject = cmdsResp.items.map<SlashCommand>((c) => ({
|
|
name: c.name,
|
|
description: (c.frontmatter?.description as string | undefined) || c.preview.slice(0, 80),
|
|
source: c.scope === "project" ? "project" : "user",
|
|
filePath: c.file,
|
|
}));
|
|
const pluginCmds: SlashCommand[] = [];
|
|
for (const p of pluginsResp.plugins || []) {
|
|
const cmds = p.contributes?.commands ?? 0;
|
|
if (!cmds || !p.installPath) continue;
|
|
}
|
|
setSlashCommands([...userProject, ...pluginCmds, ...BUILTIN_SLASH_COMMANDS]);
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
// Subscribe to lane updates from the event bus
|
|
return eventBus.subscribe((msg: WSMessage) => {
|
|
if (msg.type !== "lane_update") return;
|
|
const payload = msg.data as { lane?: Lane; removed?: number };
|
|
if (payload.removed !== undefined) {
|
|
void refreshLanes();
|
|
return;
|
|
}
|
|
const lane = payload.lane;
|
|
if (!lane) return;
|
|
setLanes((cur) => {
|
|
const i = cur.findIndex((l) => l.id === lane.id);
|
|
if (i === -1) {
|
|
void refreshLanes();
|
|
return cur;
|
|
}
|
|
const next = [...cur];
|
|
next[i] = lane;
|
|
return next;
|
|
});
|
|
});
|
|
}, [refreshLanes]);
|
|
|
|
// A reconnect (e.g. the dashboard server restarting) resumes the WS but does
|
|
// not replay missed lane_update diffs, so a lane whose fields changed while
|
|
// disconnected — status, needs_action — would keep showing its pre-restart
|
|
// badges forever with no further server-side change to broadcast. Refetch
|
|
// the full list whenever the socket comes back up.
|
|
useEffect(
|
|
() =>
|
|
eventBus.onConnection((isConnected) => {
|
|
if (isConnected) void refreshLanes();
|
|
}),
|
|
[refreshLanes]
|
|
);
|
|
|
|
const refreshList = useCallback(() => {
|
|
api.run
|
|
.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
|
|
// no WS event fires. Lighter than typical WS gaps; aggressive enough that
|
|
// status flips appear within seconds without needing a manual refresh.
|
|
useEffect(() => {
|
|
const tick = setInterval(() => {
|
|
refreshList();
|
|
}, 5000);
|
|
return () => clearInterval(tick);
|
|
}, [refreshList]);
|
|
|
|
// Refresh whenever the tab regains focus / visibility - typical when the
|
|
// user comes back from running `claude` in a terminal and wants to see the
|
|
// current state of every run without waiting for the next poll.
|
|
useEffect(() => {
|
|
const onFocus = () => refreshList();
|
|
const onVis = () => {
|
|
if (document.visibilityState === "visible") refreshList();
|
|
};
|
|
window.addEventListener("focus", onFocus);
|
|
document.addEventListener("visibilitychange", onVis);
|
|
return () => {
|
|
window.removeEventListener("focus", onFocus);
|
|
document.removeEventListener("visibilitychange", onVis);
|
|
};
|
|
}, [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 {
|
|
const transcript = await api.sessions
|
|
.transcript(item.session_id, { limit: 200 })
|
|
.catch(() => ({ messages: [] as TranscriptMessage[] }));
|
|
|
|
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: "",
|
|
mode: "conversation",
|
|
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({
|
|
prompt: "",
|
|
mode: "conversation",
|
|
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 inline (no spawn). Headless runs are single-shot, so
|
|
// there's no resume - but the transcript is still worth seeing without
|
|
// navigating away. Sets a synthetic completed handle so the UI renders
|
|
// as read-only (no Stop button, no follow-up input - both are gated on isLive).
|
|
const onViewFromHistory = useCallback(
|
|
async (item: DashboardRunHistoryItem) => {
|
|
if (!item.session_id) return;
|
|
if (busy) return;
|
|
setError(null);
|
|
try {
|
|
const synthetic: RunHandle = {
|
|
id: item.id,
|
|
pid: null,
|
|
mode: item.mode,
|
|
cwd: item.cwd,
|
|
model: item.model,
|
|
permissionMode: item.permission_mode || "acceptEdits",
|
|
effort: item.effort,
|
|
prompt: item.prompt_preview || "",
|
|
argv: [],
|
|
resumeSessionId: item.resume_session_id,
|
|
status: item.status,
|
|
startedAt: new Date(item.started_at).getTime(),
|
|
endedAt: item.ended_at ? new Date(item.ended_at).getTime() : null,
|
|
exitCode: item.exit_code,
|
|
signal: null,
|
|
error: null,
|
|
sessionId: item.session_id,
|
|
envelopeCount: 0,
|
|
stdoutTail: "",
|
|
stderrTail: "",
|
|
};
|
|
setHandle(synthetic);
|
|
setResumeSession(null);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : "unknown";
|
|
setError(t("errors.attachFailed", { message: msg }));
|
|
}
|
|
},
|
|
[busy, t]
|
|
);
|
|
|
|
const start = useCallback(async () => {
|
|
if (!prompt.trim() || busy) return;
|
|
setBusy("start");
|
|
setError(null);
|
|
try {
|
|
const effectiveCwd = resumeSession?.cwd || cwd || undefined;
|
|
// Expand /user-or-project slash commands client-side so the model
|
|
// receives the rendered template, matching what the CLI does.
|
|
const expandedPrompt = await maybeExpandSlashCommand(prompt, slashCommands);
|
|
|
|
// 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: expandedPrompt,
|
|
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" || h.status === "spawning")
|
|
);
|
|
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: any) => {
|
|
if (busy) return;
|
|
setBusy("start");
|
|
setError(null);
|
|
try {
|
|
const expandedPrompt = await maybeExpandSlashCommand(
|
|
args.initialPrompt || "",
|
|
slashCommands
|
|
);
|
|
const effectiveCwd = args.cwd || undefined;
|
|
|
|
if (!effectiveCwd) {
|
|
throw new Error(t("errors.cwdRequired"));
|
|
}
|
|
|
|
let targetLaneId = args.laneId;
|
|
if (!targetLaneId) {
|
|
// If no lane provided, try to find or create one
|
|
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd);
|
|
if (ownedLane) {
|
|
targetLaneId = ownedLane.id;
|
|
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: expandedPrompt,
|
|
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, slashCommands, t, lanes, selectedLaneId, refreshLanes, attachToRun, refreshList]
|
|
);
|
|
|
|
const stop = useCallback(async () => {
|
|
if (!handle || busy) return;
|
|
setBusy("kill");
|
|
setError(null);
|
|
try {
|
|
await api.run.kill(handle.id);
|
|
} catch (err: unknown) {
|
|
const m = err instanceof Error ? err.message : "unknown";
|
|
setError(t("errors.killFailed", { message: m }));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
}, [handle, busy, t]);
|
|
|
|
const newRun = useCallback(() => {
|
|
setHandle(null);
|
|
setPrompt("");
|
|
setResumeSession(null);
|
|
setError(null);
|
|
}, []);
|
|
|
|
const status = handle?.status ?? "idle";
|
|
const isLive = status === "spawning" || status === "running";
|
|
const hasFinished = status === "completed" || status === "error" || status === "killed";
|
|
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
|
|
|
|
// Feature list follows the selected lane, resets the viewer on lane switch.
|
|
useEffect(() => {
|
|
setViewedFeatureSlug(null);
|
|
setViewedFeature(null);
|
|
if (currentLane === null || currentLane === undefined) {
|
|
setFeatures([]);
|
|
return;
|
|
}
|
|
api.lanes.features
|
|
.list(currentLane.id)
|
|
.then((data) => setFeatures(data.features))
|
|
.catch(() => setFeatures([]));
|
|
}, [currentLane?.id]);
|
|
|
|
// Fetch the archived snapshot when the picker selects one — read-only, never
|
|
// touches the live lane.
|
|
useEffect(() => {
|
|
if (!currentLane || !viewedFeatureSlug) {
|
|
setViewedFeature(null);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
api.lanes.features
|
|
.show(currentLane.id, viewedFeatureSlug)
|
|
.then((data) => {
|
|
if (!cancelled) setViewedFeature(data.feature);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setViewedFeature(null);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [currentLane?.id, viewedFeatureSlug]);
|
|
|
|
// Proof gallery follows the selected lane.
|
|
useEffect(() => {
|
|
if (!currentLane) {
|
|
setProofFeatures([]);
|
|
return;
|
|
}
|
|
api.lanes.proof
|
|
.list(currentLane.id)
|
|
.then((data) => setProofFeatures(data.features))
|
|
.catch(() => setProofFeatures([]));
|
|
}, [currentLane?.id, viewedFeatureSlug]);
|
|
|
|
// Resolve the active feature slug: either the user-selected one, or the lane's active_feature_id
|
|
const activeFeatureSlug =
|
|
viewedFeatureSlug ??
|
|
(currentLane?.active_feature_id
|
|
? features.find((f) => f.id === currentLane?.active_feature_id)?.slug
|
|
: null);
|
|
const proofFeature = activeFeatureSlug
|
|
? 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;
|
|
|
|
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
|
setLaneActionError(null);
|
|
try {
|
|
if (action === "reset" || action === "remove" || action === "purge") {
|
|
await api.lanes.action(id, action, {
|
|
confirm: true,
|
|
expect: body?.expect,
|
|
...(body?.force === true ? { force: true } : {}),
|
|
});
|
|
} else {
|
|
await api.lanes.action(id, action, body);
|
|
}
|
|
if (action === "remove") await refreshLanes();
|
|
} catch (err) {
|
|
setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown"));
|
|
}
|
|
};
|
|
|
|
// Mirrors `ccam lanes pipeline <template> <id>`: same PATCH, same
|
|
// stage-mismatch warning when the current declared stage matches no node
|
|
// in the newly chosen template.
|
|
const handlePipelineChange = async (id: number, pipeline: string) => {
|
|
setLaneActionError(null);
|
|
try {
|
|
const { lane } = await api.lanes.update(id, { pipeline });
|
|
await refreshLanes();
|
|
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
|
|
setLaneActionError(
|
|
tLanes("pipelinePicker.stageMismatch", {
|
|
stage: lane.stage,
|
|
pipeline: lane.pipeline,
|
|
nodes: lane.pipeline_nodes.map((n) => n.id).join(", "),
|
|
})
|
|
);
|
|
}
|
|
} catch (err) {
|
|
setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown"));
|
|
}
|
|
};
|
|
|
|
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}
|
|
slashCommands={slashCommands}
|
|
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={
|
|
viewportLocked
|
|
? "h-[calc(100vh-2.5rem)] lg:h-[calc(100vh-3rem)] flex flex-col gap-5"
|
|
: "space-y-5"
|
|
}
|
|
>
|
|
{/* Page header: what this screen is, and the four numbers that say
|
|
whether anything needs a human right now. */}
|
|
<div className="flex flex-wrap items-center gap-3 border-b border-border pb-3">
|
|
<h2 className="text-base font-semibold tracking-tight text-fg-primary">
|
|
{tLanes("title")}
|
|
</h2>
|
|
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
|
<span
|
|
data-testid="count-total"
|
|
className="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
|
|
>
|
|
{counts.total} {tLanes("countTotal")}
|
|
</span>
|
|
<span
|
|
data-testid="count-running"
|
|
className="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
|
|
>
|
|
{counts.running} {tLanes("countRunning")}
|
|
</span>
|
|
{counts.needs_you > 0 && (
|
|
<span
|
|
data-testid="count-needs-you"
|
|
className="rounded-full bg-status-warning/20 px-2.5 py-0.5 text-status-warning"
|
|
>
|
|
{counts.needs_you} {tLanes("countNeedsYou")}
|
|
</span>
|
|
)}
|
|
{counts.dead > 0 && (
|
|
<span
|
|
data-testid="count-dead"
|
|
className="rounded-full bg-status-danger/20 px-2.5 py-0.5 text-status-danger"
|
|
>
|
|
{counts.dead} {tLanes("countDead")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => setAddLaneOpen(true)}
|
|
className="ml-auto flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
|
|
title={tLanes("addLane")}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
{tLanes("add")}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Lane carousel: pick a lane here, read it below. Horizontal scroll with
|
|
snap so a dozen lanes stay in one row instead of a wall of cards. */}
|
|
<div
|
|
data-testid="lane-strip"
|
|
className="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1"
|
|
>
|
|
{lanes.map((l) => (
|
|
<LaneStripCard
|
|
key={l.id}
|
|
lane={l}
|
|
selected={selectedLaneId === l.id}
|
|
onSelect={() => setSelectedLaneId(l.id)}
|
|
/>
|
|
))}
|
|
{!lanes.length && (
|
|
<p className="text-sm text-fg-muted">
|
|
{tLanes("emptyState")} <code>ccam lanes add --cwd $(pwd)</code>
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* The selected lane's pipeline, full width — the thing you actually
|
|
come to this page to read. */}
|
|
{currentLane && (
|
|
<section data-testid="lane-detail" className="card p-4">
|
|
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
|
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
|
|
{tLanes("cardId", { id: currentLane.id })}
|
|
</span>
|
|
<span className="truncate text-sm font-semibold text-fg-primary">
|
|
{currentLane.title || currentLane.cwd}
|
|
</span>
|
|
<select
|
|
data-testid="pipeline-picker"
|
|
aria-label={tLanes("pipelinePicker.label")}
|
|
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
|
value={currentLane.pipeline}
|
|
disabled={!!viewedFeature}
|
|
title={
|
|
viewedFeature
|
|
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
|
: undefined
|
|
}
|
|
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
|
>
|
|
{(pipelineTemplates.length
|
|
? pipelineTemplates
|
|
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
|
|
).map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{/* `stage` defaults to the DB sentinel "idle" until the driving
|
|
session ever calls `ccam stage` — that string collides with
|
|
`status`'s own "idle"/"running" vocabulary, so a lane that is
|
|
actively running but has never declared a stage read as if it
|
|
were sitting idle. Show a distinct label instead of the raw
|
|
sentinel whenever it doesn't match any node this pipeline
|
|
actually has. */}
|
|
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
|
|
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
|
|
? currentLane.stage
|
|
: tLanes("stageUndeclared")}
|
|
</span>
|
|
{currentLane.detected_stage && (
|
|
<span
|
|
data-testid="detail-auto-stage"
|
|
title={currentLane.detected_signal || undefined}
|
|
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
|
|
>
|
|
{tLanes("autoStage", { stage: currentLane.detected_stage })}
|
|
</span>
|
|
)}
|
|
{features.length > 0 && (
|
|
<select
|
|
data-testid="feature-picker"
|
|
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
|
value={viewedFeatureSlug ?? ""}
|
|
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
|
|
>
|
|
<option value="">{tLanes("features.live")}</option>
|
|
{features.map((f) => (
|
|
<option key={f.slug} value={f.slug}>
|
|
{f.slug}
|
|
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
<div className="mb-3">
|
|
<LaneCard
|
|
lane={currentLane}
|
|
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
|
|
childWorktrees={lanes.filter(
|
|
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
|
|
)}
|
|
onSelectLane={setSelectedLaneId}
|
|
/>
|
|
</div>
|
|
<div className="mb-3">
|
|
<PipelineMap
|
|
nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
|
|
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
|
/>
|
|
{viewedFeature && (
|
|
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
|
|
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{proofFeature &&
|
|
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
|
|
<div data-testid="proof-gallery" className="mt-2">
|
|
{proofFeature.ticket_report && (
|
|
<a
|
|
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-xs text-fg-muted"
|
|
>
|
|
{tLanes("proof.ticketReport")}
|
|
</a>
|
|
)}
|
|
{Object.entries(proofFeature.groups).map(([group, images]) => (
|
|
<div key={group} className="mt-1">
|
|
<span className="text-xs text-fg-muted">
|
|
{group} · {images.length}
|
|
</span>
|
|
<div className="flex flex-wrap gap-1">
|
|
{images.slice(0, 8).map((img) => (
|
|
<img
|
|
key={img}
|
|
loading="lazy"
|
|
className="h-16 w-16 rounded object-cover"
|
|
src={api.lanes.proof.imageUrl(
|
|
currentLane.id,
|
|
proofFeature.slug,
|
|
group,
|
|
img
|
|
)}
|
|
alt={img}
|
|
/>
|
|
))}
|
|
{images.length > 8 && (
|
|
<span className="text-xs text-fg-muted">+{images.length - 8}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
|
|
{consoleSection}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<AddLaneModal
|
|
open={addLaneOpen}
|
|
cwdSuggestions={cwdSuggestions}
|
|
onClose={() => setAddLaneOpen(false)}
|
|
onAdded={(lane) => {
|
|
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]));
|
|
setSelectedLaneId(lane.id);
|
|
void refreshLanes();
|
|
}}
|
|
/>
|
|
|
|
{laneActionError && (
|
|
<p
|
|
role="alert"
|
|
className="rounded border border-status-danger bg-status-danger/40 px-3 py-2 text-sm text-status-danger"
|
|
>
|
|
{tLanes("actionError", { message: laneActionError })}
|
|
</p>
|
|
)}
|
|
|
|
{/* 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>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Expand a user/project/plugin slash command client-side. Reads the command
|
|
* markdown body via /api/cc-config/file, strips frontmatter, and substitutes
|
|
* `$ARGUMENTS` with whatever the user typed after the command name. If the
|
|
* command isn't user-defined (built-in or unknown), returns the original
|
|
* text unchanged so it still gets sent (the model will see it as text).
|
|
*/
|
|
async function maybeExpandSlashCommand(text: string, commands: SlashCommand[]): Promise<string> {
|
|
const trimmed = text.trimStart();
|
|
if (!trimmed.startsWith("/")) return text;
|
|
const m = trimmed.match(/^\/([\w:-]+)(?:\s+([\s\S]*))?$/);
|
|
if (!m) return text;
|
|
const [, name, args = ""] = m;
|
|
const cmd = commands.find((c) => c.name === name);
|
|
if (!cmd || cmd.source === "builtin" || !cmd.filePath) return text;
|
|
try {
|
|
const body = await api.ccConfig.file(cmd.filePath);
|
|
let content = body.text;
|
|
// Strip frontmatter if present
|
|
if (content.startsWith("---")) {
|
|
const end = content.indexOf("\n---", 3);
|
|
if (end >= 0) content = content.slice(end + 4).replace(/^\s*\n/, "");
|
|
}
|
|
return content.replace(/\$ARGUMENTS/g, args);
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
// ── 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>
|
|
);
|
|
}
|