Compare commits

..

4 Commits

Author SHA1 Message Date
nntrivi2001 2c29504c75 test(lanes): stop lane-lifecycle from leaking real tmux + claude processes
Four cases in lane-lifecycle.test.js call /start without stubbing PATH,
so they spawn the real system `claude` binary in a real tmux session
to simulate a stuck/live run. Each then mocks tmux's own exec calls to
fake has-session/kill-session for the app's checks, but never touches
the real spawned process — the mock only fools the app, not the OS.
Two of these leaked past every prior test run undetected (ccam-lane-22,
ccam-lane-24), surfacing in the dashboard's live "Dashboard runs" list
with no DB record and a garbage started_at, and reappearing in a
Workspace split pane pointed at a deleted temp directory.

Stub a lightweight fake `claude` on PATH (same pattern already used
correctly elsewhere in this file) instead of spawning the real CLI, and
explicitly kill the real tmux session in each test's teardown since the
app-level mock never reaches the OS process.
2026-08-14 17:28:54 +07:00
nntrivi2001 39572aa04c fix(workspace): stop the pipeline map from overflowing the detail panel
The lane-detail section is a flex-1 row item next to the lane list, but
lacked min-w-0. A flex item's intrinsic min-width defaults to its
content size, so PipelineMap's 16-node row (which relies on flex-1
min-w-0 truncate per node to shrink) pushed the whole panel wider than
its allotted space instead of compressing, spilling nodes off-screen.
2026-08-14 17:08:09 +07:00
nntrivi2001 3ae0d00b0c fix(workspace): stop false stage-mismatch warning, cap info panel height
Undeclared lanes default stage to the DB sentinel "idle", which never
matches a pipeline node — skip the mismatch warning in that case
instead of showing a false lane-action-failed banner. Also cap the
expandable lane-info block so it can't squeeze the console/split-view
out of the fixed-height detail panel.
2026-08-14 16:56:52 +07:00
nntrivi2001 a2b5fa4669 fix(workspace): move lane list to a vertical column beside detail panel
The detail panel (LaneCard/PipelineMap/proof gallery/console) could
grow tall enough to visually crowd out the lane-strip carousel above
it once a run was attached in split view. Move the lane list into its
own scrolling vertical column beside the detail panel instead of
stacking it above, so neither can cover the other; also collapse the
info block by default in 2/4-pane split view (toggle to expand) and
move the pane-count control into the detail header.
2026-08-14 16:39:15 +07:00
6 changed files with 616 additions and 491 deletions
@@ -1,9 +1,9 @@
/** /**
* @file The compact lane tile used in the Workspace carousel. It carries only * @file The compact lane tile used in the Workspace's vertical lane list. It
* what you need to pick a lane — which lane, is it alive, what stage, how far — * carries only what you need to pick a lane — which lane, is it alive, what
* because the full card, its controls and its working-copy facts live in the * stage, how far — because the full card, its controls and its working-copy
* detail panel below. Keeping the tile small is what lets a dozen lanes stay * facts live in the detail panel beside it. Keeping the tile small and full
* scannable in one horizontal row. * width is what lets many lanes stay scannable in one scrolling column.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -42,7 +42,7 @@ export default function LaneStripCard({
aria-pressed={selected} aria-pressed={selected}
onClick={onSelect} onClick={onSelect}
title={lane.cwd} title={lane.cwd}
className={`w-56 shrink-0 snap-start rounded-lg border p-3 text-left shadow-sm transition-colors ${ className={`w-full shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${
selected selected
? "border-accent bg-accent/10" ? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3" : "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
+2
View File
@@ -90,6 +90,8 @@
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked", "git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
"kind.adopted": "adopted", "kind.adopted": "adopted",
"kind.managed": "managed", "kind.managed": "managed",
"laneDetail.hide": "Hide details",
"laneDetail.show": "Lane details",
"laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}", "laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}",
"locks.held_one": "{{count}} lock held", "locks.held_one": "{{count}} lock held",
"locks.held_other": "{{count}} locks held", "locks.held_other": "{{count}} locks held",
+2
View File
@@ -90,6 +90,8 @@
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi", "git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
"kind.adopted": "đã nhận", "kind.adopted": "đã nhận",
"kind.managed": "được quản lý", "kind.managed": "được quản lý",
"laneDetail.hide": "Ẩn chi tiết",
"laneDetail.show": "Chi tiết lane",
"laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}", "laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}",
"locks.held_one": "Đang giữ {{count}} khóa", "locks.held_one": "Đang giữ {{count}} khóa",
"locks.held_other": "Đang giữ {{count}} khóa", "locks.held_other": "Đang giữ {{count}} khóa",
+245 -189
View File
@@ -54,6 +54,36 @@ import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
// ── Page ────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────
function SplitLayoutToggle({
splitView,
setLayout,
}: {
splitView: SplitViewState;
setLayout: (layout: SplitLayout) => void;
}) {
const { t: tLanes } = useTranslation("lanes");
return (
<div className="flex items-center gap-1.5">
{([1, 2, 4] as const).map((n) => (
<button
key={n}
type="button"
aria-pressed={splitView.layout === n}
onClick={() => setLayout(n)}
className={`rounded border px-2 py-1 text-xs ${
splitView.layout === n
? "border-accent bg-accent/15 text-accent"
: "border-border text-fg-secondary hover:border-border-light"
}`}
>
{tLanes("splitView.paneCount", { count: n })}
</button>
))}
</div>
);
}
function ConsoleArea({ function ConsoleArea({
lanes, lanes,
selectedLaneId, selectedLaneId,
@@ -68,6 +98,7 @@ function ConsoleArea({
onHasActiveRunChange, onHasActiveRunChange,
onLaneCreated, onLaneCreated,
onLaneIdChange, onLaneIdChange,
showToggle = true,
}: { }: {
lanes: Lane[]; lanes: Lane[];
selectedLaneId: number | null; selectedLaneId: number | null;
@@ -82,28 +113,11 @@ function ConsoleArea({
onHasActiveRunChange: (val: boolean) => void; onHasActiveRunChange: (val: boolean) => void;
onLaneCreated: (lane: Lane) => void; onLaneCreated: (lane: Lane) => void;
onLaneIdChange: (id: number | null) => void; onLaneIdChange: (id: number | null) => void;
showToggle?: boolean;
}) { }) {
const { t: tLanes } = useTranslation("lanes");
return ( return (
<> <>
<div className="flex items-center gap-1.5"> {showToggle && <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />}
{([1, 2, 4] as const).map((n) => (
<button
key={n}
type="button"
aria-pressed={splitView.layout === n}
onClick={() => setLayout(n)}
className={`rounded border px-2 py-1 text-xs ${
splitView.layout === n
? "border-accent bg-accent/15 text-accent"
: "border-border text-fg-secondary hover:border-border-light"
}`}
>
{tLanes("splitView.paneCount", { count: n })}
</button>
))}
</div>
{splitView.layout === 1 ? ( {splitView.layout === 1 ? (
<LaneConsolePane <LaneConsolePane
lanes={lanes} lanes={lanes}
@@ -175,6 +189,11 @@ export function Workspace() {
// Split view state // Split view state
const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState()); const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState());
// Info block (pickers, LaneCard, PipelineMap, proof gallery) is always shown
// in single-pane view; in split view it starts collapsed so tall content
// never crowds the pane grid, and the user expands it on demand.
const [infoExpanded, setInfoExpanded] = useState(false);
const showInfo = splitView.layout === 1 || infoExpanded;
const setLayout = useCallback((layout: SplitLayout) => { const setLayout = useCallback((layout: SplitLayout) => {
setSplitView((prev) => { setSplitView((prev) => {
@@ -420,7 +439,10 @@ export function Workspace() {
try { try {
const { lane } = await api.lanes.update(id, { pipeline }); const { lane } = await api.lanes.update(id, { pipeline });
await refreshLanes(); await refreshLanes();
if (!lane.pipeline_nodes.some((n) => n.state === "current")) { // `stage` defaults to the DB sentinel "idle" until a driving session ever
// calls `ccam stage` — that's the normal state for most lanes and never
// matches a real pipeline node, so it isn't a mismatch worth surfacing.
if (lane.stage !== "idle" && !lane.pipeline_nodes.some((n) => n.state === "current")) {
setLaneActionError( setLaneActionError(
tLanes("pipelinePicker.stageMismatch", { tLanes("pipelinePicker.stageMismatch", {
stage: lane.stage, stage: lane.stage,
@@ -488,161 +510,217 @@ export function Workspace() {
</button> </button>
</div> </div>
{/* Lane carousel: pick a lane here, read it below. Horizontal scroll with {/* Lane list + detail live side by side so neither can cover the other:
snap so a dozen lanes stay in one row instead of a wall of cards. */} the list is a scrolling column (holds any number of lanes), the
<div detail/console area to its right never has to compete with it for
data-testid="lane-strip" vertical space. */}
className="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1" <div className="flex min-h-0 flex-1 gap-4">
> <div
{lanes.map((l) => ( data-testid="lane-strip"
<LaneStripCard className="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
key={l.id} >
lane={l} {lanes.map((l) => (
selected={selectedLaneId === l.id} <LaneStripCard
onSelect={() => setSelectedLaneId(l.id)} key={l.id}
/> lane={l}
))} selected={selectedLaneId === l.id}
{!lanes.length && ( onSelect={() => setSelectedLaneId(l.id)}
<p className="text-sm text-fg-muted"> />
{tLanes("emptyState")} <code>ccam lanes add --cwd $(pwd)</code> ))}
</p> {!lanes.length && (
)} <p className="text-sm text-fg-muted">
</div> {tLanes("emptyState")} <code>ccam lanes add --cwd $(pwd)</code>
</p>
)}
</div>
{/* The selected lane's pipeline, full width — the thing you actually {/* The selected lane's pipeline — the thing you actually come to this
come to this page to read. */} page to read, beside the list rather than stacked under it. */}
{currentLane && ( {currentLane && (
<section data-testid="lane-detail" className="card p-4"> <section
<div className="mb-3 flex flex-wrap items-baseline gap-2"> data-testid="lane-detail"
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted"> className="flex min-h-0 min-w-0 flex-1 flex-col card p-4"
{tLanes("cardId", { id: currentLane.id })} >
</span> <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<span className="truncate text-sm font-semibold text-fg-primary"> <span className="truncate text-sm font-semibold text-fg-primary">
{currentLane.title || currentLane.cwd} {tLanes("laneHeader", {
</span> id: currentLane.id,
<select title: currentLane.title || currentLane.cwd,
data-testid="pipeline-picker" pipeline: currentLane.pipeline_name,
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> </span>
)} <div className="flex items-center gap-2">
{features.length > 0 && ( {splitView.layout !== 1 && (
<select <button
data-testid="feature-picker" type="button"
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs" data-testid="lane-detail-toggle"
value={viewedFeatureSlug ?? ""} aria-expanded={infoExpanded}
onChange={(e) => setViewedFeatureSlug(e.target.value || null)} onClick={() => setInfoExpanded((v) => !v)}
> className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary"
<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")} {infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
</a> </button>
)} )}
{Object.entries(proofFeature.groups).map(([group, images]) => ( <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
<div key={group} className="mt-1"> </div>
<span className="text-xs text-fg-muted"> </div>
{group} · {images.length} {showInfo && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
<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>
<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> </span>
<div className="flex flex-wrap gap-1"> )}
{images.slice(0, 8).map((img) => ( {features.length > 0 && (
<img <select
key={img} data-testid="feature-picker"
loading="lazy" className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
className="h-16 w-16 rounded object-cover" value={viewedFeatureSlug ?? ""}
src={api.lanes.proof.imageUrl( onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
currentLane.id, >
proofFeature.slug, <option value="">{tLanes("features.live")}</option>
group, {features.map((f) => (
img <option key={f.slug} value={f.slug}>
)} {f.slug}
alt={img} {f.archived_at ? ` (${tLanes("features.archived")})` : ""}
/> </option>
))} ))}
{images.length > 8 && ( </select>
<span className="text-xs text-fg-muted">+{images.length - 8}</span> )}
</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>
</div> )}
))}
</div> </div>
)} )}
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3"> <div className="flex min-h-0 flex-1 flex-col gap-2 border-t border-border pt-3">
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
splitView={splitView}
setLayout={setLayout}
setPaneLaneId={setPaneLaneId}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={(lane) =>
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
}
onLaneIdChange={setSelectedLaneId}
showToggle={false}
/>
</div>
</section>
)}
{!currentLane && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<ConsoleArea <ConsoleArea
lanes={lanes} lanes={lanes}
selectedLaneId={selectedLaneId} selectedLaneId={selectedLaneId}
@@ -661,30 +739,8 @@ export function Workspace() {
onLaneIdChange={setSelectedLaneId} onLaneIdChange={setSelectedLaneId}
/> />
</div> </div>
</section> )}
)} </div>
{!currentLane && (
<div className="flex min-h-0 flex-col gap-2">
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
splitView={splitView}
setLayout={setLayout}
setPaneLaneId={setPaneLaneId}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={(lane) =>
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
}
onLaneIdChange={setSelectedLaneId}
/>
</div>
)}
<AddLaneModal <AddLaneModal
open={addLaneOpen} open={addLaneOpen}
@@ -5762,205 +5762,295 @@ exports[`screen snapshots > Run 1`] = `
</button> </button>
</div> </div>
<div <div
class="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1" class="flex min-h-0 flex-1 gap-4"
data-testid="lane-strip"
>
<p
class="text-sm text-fg-muted"
>
No lanes yet. Create one from a working directory:
<code>
ccam lanes add --cwd $(pwd)
</code>
</p>
</div>
<div
class="flex min-h-0 flex-col gap-2"
> >
<div <div
class="flex items-center gap-1.5" class="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
data-testid="lane-strip"
> >
<button <p
aria-pressed="true" class="text-sm text-fg-muted"
class="rounded border px-2 py-1 text-xs border-accent bg-accent/15 text-accent"
type="button"
> >
1 pane No lanes yet. Create one from a working directory:
</button>
<button <code>
aria-pressed="false" ccam lanes add --cwd $(pwd)
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light" </code>
type="button" </p>
>
2 pane
</button>
<button
aria-pressed="false"
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
type="button"
>
4 pane
</button>
</div> </div>
<div <div
class="flex min-h-0 flex-1 flex-col gap-5" class="flex min-h-0 flex-1 flex-col gap-2"
data-testid="console-body"
> >
<header
class="flex items-start gap-3"
>
<div
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
>
<svg
class="lucide lucide-play 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"
>
<polygon
points="6 3 20 12 6 21 6 3"
/>
</svg>
</div>
<div
class="min-w-0 flex-1"
>
<div
class="flex items-center gap-2"
>
<h1
class="text-lg font-semibold text-fg-primary"
>
Run Claude
</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 max-w-3xl"
>
Spin up a Claude Code session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.
</p>
</div>
<button
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
disabled=""
>
<svg
class="lucide lucide-list-ordered 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="M10 12h11"
/>
<path
d="M10 18h11"
/>
<path
d="M10 6h11"
/>
<path
d="M4 10h2"
/>
<path
d="M4 6h1v4"
/>
<path
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
/>
</svg>
Active runs
</button>
</header>
<div <div
class="rounded-xl border border-border bg-surface-1" class="flex items-center gap-1.5"
> >
<div <button
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]" aria-pressed="true"
class="rounded border px-2 py-1 text-xs border-accent bg-accent/15 text-accent"
type="button"
>
1 pane
</button>
<button
aria-pressed="false"
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
type="button"
>
2 pane
</button>
<button
aria-pressed="false"
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
type="button"
>
4 pane
</button>
</div>
<div
class="flex min-h-0 flex-1 flex-col gap-5"
data-testid="console-body"
>
<header
class="flex items-start gap-3"
> >
<div <div
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5" class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
> >
<button <svg
aria-pressed="true" class="lucide lucide-play w-4.5 h-4.5 text-accent"
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent" fill="none"
title="Start a fresh Claude Code session." height="24"
type="button" 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"
> >
New session <polygon
</button> points="6 3 20 12 6 21 6 3"
<button />
aria-pressed="false" </svg>
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary"
title="Pick a session from your history and continue the conversation. Cwd is locked to the original."
type="button"
>
Resume existing session
</button>
</div> </div>
</div>
<div
class="px-4 py-3 border-b border-border"
>
<label
class="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5"
>
Prompt
</label>
<textarea
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
placeholder="Ask Claude anything…"
rows="5"
/>
<div <div
class="mt-1 text-[10px] text-fg-muted" class="min-w-0 flex-1"
> >
Cmd+Enter / Ctrl+Enter to send
· / for slash commands · @ for file references
</div>
</div>
<div
class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4"
>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Working directory
</label>
<div <div
title="Absolute path. Defaults to the dashboard's own cwd." class="flex items-center gap-2"
> >
<h1
class="text-lg font-semibold text-fg-primary"
>
Run Claude
</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 max-w-3xl"
>
Spin up a Claude Code session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.
</p>
</div>
<button
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
disabled=""
>
<svg
class="lucide lucide-list-ordered 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="M10 12h11"
/>
<path
d="M10 18h11"
/>
<path
d="M10 6h11"
/>
<path
d="M4 10h2"
/>
<path
d="M4 6h1v4"
/>
<path
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
/>
</svg>
Active runs
</button>
</header>
<div
class="rounded-xl border border-border bg-surface-1"
>
<div
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]"
>
<div
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
>
<button
aria-pressed="true"
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent"
title="Start a fresh Claude Code session."
type="button"
>
New session
</button>
<button
aria-pressed="false"
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary"
title="Pick a session from your history and continue the conversation. Cwd is locked to the original."
type="button"
>
Resume existing session
</button>
</div>
</div>
<div
class="px-4 py-3 border-b border-border"
>
<label
class="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5"
>
Prompt
</label>
<textarea
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
placeholder="Ask Claude anything…"
rows="5"
/>
<div
class="mt-1 text-[10px] text-fg-muted"
>
Cmd+Enter / Ctrl+Enter to send
· / for slash commands · @ for file references
</div>
</div>
<div
class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4"
>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Working directory
</label>
<div <div
class="relative" title="Absolute path. Defaults to the dashboard's own cwd."
> >
<div <div
class="relative" class="relative"
> >
<div
class="relative"
>
<svg
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
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>
<input
autocomplete="off"
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
placeholder="Type to search or paste an absolute path…"
spellcheck="false"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Model
</label>
<div
class="space-y-1.5"
>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
Inherit from settings
</span>
<svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
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 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
</div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Permission mode
</label>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
acceptEdits (recommended)
</span>
<svg <svg
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none" class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none" fill="none"
height="24" height="24"
stroke="currentColor" stroke="currentColor"
@@ -5972,30 +6062,18 @@ exports[`screen snapshots > Run 1`] = `
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <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" d="m6 9 6 6 6-6"
/> />
</svg> </svg>
<input </button>
autocomplete="off"
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
placeholder="Type to search or paste an absolute path…"
spellcheck="false"
type="text"
value=""
/>
</div>
</div> </div>
</div> </div>
</div> <div>
<div> <label
<label class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1" >
> Thinking effort
Model </label>
</label>
<div
class="space-y-1.5"
>
<div <div
class="relative" class="relative"
> >
@@ -6006,7 +6084,7 @@ exports[`screen snapshots > Run 1`] = `
<span <span
class="truncate" class="truncate"
> >
Inherit from settings Default (model decides)
</span> </span>
<svg <svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0" class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
@@ -6028,109 +6106,35 @@ exports[`screen snapshots > Run 1`] = `
</div> </div>
</div> </div>
</div> </div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Permission mode
</label>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
acceptEdits (recommended)
</span>
<svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
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 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Thinking effort
</label>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
Default (model decides)
</span>
<svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
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 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
</div>
<div
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
>
<div <div
class="flex items-center gap-3 text-[11px] min-w-0" class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
/>
<button
class="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
> >
<svg <div
class="lucide lucide-play w-3.5 h-3.5" class="flex items-center gap-3 text-[11px] min-w-0"
fill="none" />
height="24" <button
stroke="currentColor" class="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
stroke-linecap="round" disabled=""
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
> >
<polygon <svg
points="6 3 20 12 6 21 6 3" class="lucide lucide-play w-3.5 h-3.5"
/> fill="none"
</svg> height="24"
Run stroke="currentColor"
</button> stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="6 3 20 12 6 21 6 3"
/>
</svg>
Run
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
+61
View File
@@ -80,6 +80,29 @@ function makeRunChild({ exitsOnKill }) {
return child; return child;
} }
// Puts a fake `claude` binary on PATH so a real `/start` spawns a real tmux
// session running THIS script instead of the system Claude Code CLI. Tests
// that mock tmux's own exec calls (to simulate a stuck/live session) still
// spawn this real process underneath — without the stub, that spawn launches
// the actual `claude` binary and, because the mock replaces the app's own
// kill-session call, the real process is never actually terminated, leaking
// a live tmux session + CLI process for good. Returns the restore function.
function stubClaudeBinary(name) {
const bin = path.join(ROOT, `${name}-bin`);
const claude = path.join(bin, "claude");
fs.mkdirSync(bin, { recursive: true });
fs.writeFileSync(
claude,
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n"
);
fs.chmodSync(claude, 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
return () => {
process.env.PATH = originalPath;
};
}
async function waitForProvisioning(id) { async function waitForProvisioning(id) {
const deadline = Date.now() + 5000; const deadline = Date.now() + 5000;
let response; let response;
@@ -818,6 +841,7 @@ describe("destructive lane lifecycle actions", () => {
fs.writeFileSync(sentinel, "still here\n"); fs.writeFileSync(sentinel, "still here\n");
// Start a run for the lane // Start a run for the lane
const restorePath = stubClaudeBinary("await-timeout");
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "stuck" }); const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "stuck" });
assert.equal(started.status, 200); assert.equal(started.status, 200);
const runId = started.body.lane.run_id; const runId = started.body.lane.run_id;
@@ -848,6 +872,15 @@ describe("destructive lane lifecycle actions", () => {
assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n"); assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n");
} finally { } finally {
tmux.__reset(); tmux.__reset();
// The mocked kill-session above only fools the app's own check — the
// real tmux session + claude stub spawned above is still alive and
// must be killed for real, or it leaks past this test run.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
} }
await request("DELETE", `/api/lanes/${lane.id}`); await request("DELETE", `/api/lanes/${lane.id}`);
}); });
@@ -889,6 +922,7 @@ describe("destructive lane lifecycle actions", () => {
const lane = await createManagedLane("start-twice"); const lane = await createManagedLane("start-twice");
// Start a run for the lane // Start a run for the lane
const restorePath = stubClaudeBinary("start-twice");
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" }); const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" });
assert.equal(started.status, 200); assert.equal(started.status, 200);
const runId = started.body.lane.run_id; const runId = started.body.lane.run_id;
@@ -915,6 +949,15 @@ describe("destructive lane lifecycle actions", () => {
assert.equal(after.body.lane.run_id, runId); assert.equal(after.body.lane.run_id, runId);
} finally { } finally {
tmux.__reset(); tmux.__reset();
// The real tmux session behind the "first" run is never reset/killed
// in this test, mocked or otherwise — kill it for real so it doesn't
// leak past this test run.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
} }
await request("DELETE", `/api/lanes/${lane.id}`); await request("DELETE", `/api/lanes/${lane.id}`);
@@ -1134,6 +1177,7 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
const lane = await adoptedLane("release-moved-on"); const lane = await adoptedLane("release-moved-on");
// Create a run for this lane. // Create a run for this lane.
const restorePath = stubClaudeBinary("release-moved-on");
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" }); const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
assert.equal(started.status, 200, JSON.stringify(started.body)); assert.equal(started.status, 200, JSON.stringify(started.body));
const runId = started.body.lane.run_id; const runId = started.body.lane.run_id;
@@ -1161,6 +1205,14 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
assert.equal(after.status, "running"); assert.equal(after.status, "running");
} finally { } finally {
tmux.__reset(); tmux.__reset();
// The app never calls kill-session here (healing preserves the "live"
// run) — kill the real tmux session directly so it doesn't leak.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
} }
}); });
@@ -1169,6 +1221,7 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
const lane = await adoptedLane("release-stale-run"); const lane = await adoptedLane("release-stale-run");
// Start a run for this lane. // Start a run for this lane.
const restorePath = stubClaudeBinary("release-stale-run");
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" }); const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
assert.equal(started.status, 200, JSON.stringify(started.body)); assert.equal(started.status, 200, JSON.stringify(started.body));
const runId = started.body.lane.run_id; const runId = started.body.lane.run_id;
@@ -1195,6 +1248,14 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
assert.equal(after.status, "idle", "status should be idle after run is gone"); assert.equal(after.status, "idle", "status should be idle after run is gone");
} finally { } finally {
tmux.__reset(); tmux.__reset();
// The app believes the session is already gone and never calls
// kill-session — kill the real tmux session directly so it doesn't leak.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
} }
}); });
}); });