feat(workspace): the console tab bar is the session view's only header

The status/name/back-arrow row is gone; Tools sits at the tab bar's right
end and the pipeline/feature pickers show only with a template.
LaneConsolePane is reduced to the terminal (no title, no Active runs, no
New run): RunHistory.tsx, api.run.history and the page's active-sessions
poll are removed.
This commit is contained in:
2026-10-03 12:13:11 +07:00
parent 4df944c6aa
commit b7efba8747
17 changed files with 177 additions and 1941 deletions
+29 -20
View File
@@ -7,11 +7,13 @@
* (`POST /api/lanes/:id/start {shell: true}`) or an installed, enabled AI
* tool started bare on its default model, no prompt (a prompt, router or
* worktree is still the workspace home's prompt box) — plus a link to
* Settings → Agents. Four tabs is the server's limit (`MAX_PANE_SLOT`).
* Settings → Agents. Four tabs is the server's limit (`MAX_PANE_SLOT`). The
* bar is the console's only header; `actions` (the workspace's Tools menu)
* sits at its right end.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Plus, Settings2, SquareTerminal, X } from "lucide-react";
@@ -28,6 +30,7 @@ export function ConsoleTabs({
activeRunId,
onOpen,
onClosed,
actions,
}: {
lane: Lane;
/** The workspace's live runs, in slot order. */
@@ -36,6 +39,8 @@ export function ConsoleTabs({
onOpen: (runId: string) => void;
/** A tab's session was stopped. */
onClosed: (runId: string) => void;
/** Right end of the bar (the workspace's Tools menu). */
actions?: ReactNode;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
@@ -91,22 +96,23 @@ export function ConsoleTabs({
};
const item =
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
return (
<div className="flex shrink-0 items-center gap-1 border-b border-border">
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-end gap-0.5">
<div className="flex h-10 shrink-0 items-stretch rounded-t-lg border border-b-0 border-border bg-surface-2">
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-stretch">
{tabs.map((r) => {
const active = r.id === activeRunId;
return (
<div
key={r.id}
className={`group flex min-w-0 max-w-[14rem] items-center rounded-t-md border border-b-0 ${
className={`group relative flex min-w-0 max-w-[15rem] items-center border-r border-border ${
active
? "border-border bg-surface-2 text-fg-primary"
: "border-transparent text-fg-muted hover:bg-surface-2/60 hover:text-fg-secondary"
? "bg-surface-0 text-fg-primary"
: "text-fg-muted hover:bg-surface-3 hover:text-fg-secondary"
}`}
>
{active && <span className="absolute inset-x-0 top-0 h-0.5 bg-accent" />}
<button
type="button"
role="tab"
@@ -114,12 +120,12 @@ export function ConsoleTabs({
data-testid={`console-tab-${r.slot ?? 0}`}
onClick={() => onOpen(r.id)}
title={[r.model, r.promptPreview].filter(Boolean).join(" · ") || undefined}
className="flex min-w-0 items-center gap-1.5 py-1.5 pl-2.5 pr-1 text-xs"
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
>
{r.tool === "shell" ? (
<SquareTerminal className="h-3.5 w-3.5 shrink-0" />
<SquareTerminal className="h-4 w-4 shrink-0" />
) : (
<AiToolIcon tool={{ id: r.tool, label: label(r) }} className="h-3.5 w-3.5 !p-0" />
<AiToolIcon tool={{ id: r.tool, label: label(r) }} className="h-4 w-4 !p-0" />
)}
<span className="truncate">{label(r)}</span>
</button>
@@ -129,15 +135,17 @@ export function ConsoleTabs({
onClick={() => void close(r.id)}
aria-label={t("tabs.close")}
title={t("tabs.close")}
className="mr-1 shrink-0 rounded p-0.5 opacity-60 hover:bg-surface-4 hover:text-status-danger hover:opacity-100"
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-status-danger ${
active ? "" : "opacity-0 group-hover:opacity-100 focus:opacity-100"
}`}
>
<X className="h-3 w-3" />
<X className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
<div ref={menuRef} className="relative">
<div ref={menuRef} className="relative flex items-center px-1.5">
<button
type="button"
data-testid="console-tab-new"
@@ -147,7 +155,7 @@ export function ConsoleTabs({
aria-expanded={menuOpen}
aria-label={t("tabs.new")}
title={slot === null ? t("tabs.full") : t("tabs.new")}
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent"
className="rounded-md p-1.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent"
>
<Plus className="h-4 w-4" />
</button>
@@ -155,7 +163,7 @@ export function ConsoleTabs({
<div
role="menu"
data-testid="console-tab-menu"
className="absolute left-0 top-full z-50 mt-1 w-56 rounded-lg border border-border bg-surface-2 p-1 shadow-lg"
className="absolute left-0 top-full z-50 mt-1 w-64 rounded-xl border border-border bg-surface-2 p-1.5 shadow-lg"
>
<button
type="button"
@@ -166,7 +174,7 @@ export function ConsoleTabs({
<SquareTerminal className="h-4 w-4" />
{t("tabs.newTerminal")}
</button>
<div className="my-1 border-t border-border" />
<div className="my-1.5 border-t border-border" />
{toolIds.map((id) => {
const model = aiTools.byId(id)?.defaultModel;
return (
@@ -191,11 +199,11 @@ export function ConsoleTabs({
</button>
);
})}
<div className="my-1 border-t border-border" />
<div className="my-1.5 border-t border-border" />
<button
type="button"
role="menuitem"
className={item}
className={`${item} !text-fg-muted`}
onClick={() => navigate("/settings/agents")}
>
<Settings2 className="h-4 w-4" />
@@ -205,10 +213,11 @@ export function ConsoleTabs({
)}
</div>
{error && (
<span role="alert" className="ml-2 min-w-0 truncate text-xs text-status-danger">
<span role="alert" className="ml-2 min-w-0 self-center truncate text-xs text-status-danger">
{error}
</span>
)}
{actions && <div className="ml-auto flex items-center pr-2">{actions}</div>}
</div>
);
}
+38 -366
View File
@@ -1,81 +1,40 @@
/**
* @file LaneConsolePane.tsx
* @description One lane's run console: a TerminalView on the pane's tmux
* session, or — with no run — an empty pane whose button goes back to the
* workspace home, where a session starts with a prompt (its prompt box,
* `WorkspaceHome.tsx`; there is no run form in a pane — the tab bar's `+`,
* `ConsoleTabs.tsx`, starts a bare CLI or a shell). Moved out of
* Workspace.tsx so the Workspace page can keep one per TAB (slot 0–3) mounted,
* the inactive ones hidden with CSS. Owns its own handle/busy/runHistory state —
* nothing is shared between panes. `lanes`, `binaryStatus`, `activeRuns`, and
* `externalSessions` are supplied as props because they are global, not
* lane-specific, and fetching them per pane would mean N redundant identical
* requests for an N-pane layout.
* @description One console tab's body: a TerminalView on the tab's tmux
* session (`agenthub-lane-<id>[-<slot>]`), or — with no run — an empty pane
* whose button goes back to the workspace home, where a session starts with a
* prompt (a pane has no run form; the tab bar's `+`, `ConsoleTabs.tsx`, starts
* a bare CLI or a shell). No header of its own: the tab bar is the console's
* header. The Workspace page keeps one per tab (slot 0–3) mounted, the
* inactive ones hidden with CSS.
*
* That state is bound to the lane the pane currently shows: switching `laneId`
* swaps the whole pane over to the new lane — its history
* and its live tmux session — instead of leaving the previous lane's terminal
* on screen under a new lane's header.
*
* Each pane is one tmux session on its lane — every tab shows the same
* lane, tab `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming
* into a pane already holding a live run answers 409 ERUNLIVE rather than overwriting a
* prompt in flight — `onResumeFromHistory` catches
* that and attaches to the still-live session instead of failing outright
* (with a notice explaining what happened), since that session is still
* reachable and the user did not ask to kill it. `killRun` is the other half:
* the Active runs list's Kill button (`RunHistory.tsx`), for when the user
* DOES want that previous session gone so a fresh start/resume can proceed.
* The pane is bound to the lane it shows: switching `laneId` swaps it to the
* new lane's own live session for its slot, or an empty pane — never the
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
* tab) attaches to that tmux session only; nothing is started or resumed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Play, AlertCircle, Plus } from "lucide-react";
import { AlertCircle, Plus } from "lucide-react";
import { api } from "../../lib/api";
import type { DashboardRunHistoryItem, RunHandle, RunListResponse } from "../../lib/api";
import type { Session, Lane } from "../../lib/types";
import type { RunHandle, RunListResponse } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { TerminalView } from "./TerminalView";
import { ActiveRunsSwitcher } from "./RunHistory";
/** The claude lane a session's cwd belongs to: the longest path-boundary
* prefix, the same rule the server binds hook sessions with. External
* sessions are all claude — Codex emits no hooks. */
function owningLaneId(cwd: string, lanes: Lane[]): number | null {
let best: Lane | null = null;
for (const l of lanes) {
if (l.tool !== "claude") continue;
const root = l.cwd.replace(/\/+$/, "");
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
}
return best?.id ?? null;
}
export interface LaneConsolePaneProps {
lanes: Lane[];
laneId: number | null;
showLaneSelector: boolean;
onLaneIdChange: (id: number) => void;
onLaneCreated: (lane: Lane) => void;
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
activeRuns: RunListResponse | null;
/** Active Claude Code sessions started outside the dashboard, listed in the
* active-runs switcher alongside dashboard runs. */
externalSessions?: Session[];
wsConnected: boolean;
onHasActiveRunChange?: (active: boolean) => void;
/** Which tab this is. Every tab shows the SAME lane, each in its own
* tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane-<id>-<slot>`. */
/** Which tab this is: slot 0 is the lane's own session, 1–3
* `agenthub-lane-<id>-<slot>`. */
slot?: number;
/** Runs the OTHER panes are showing — left out of this pane's Active runs so
* one terminal is never attached twice side by side. */
hiddenRunIds?: string[];
/** Reports the run this pane shows (null on the setup form). */
onHandleChange?: (runId: string | null) => void;
/** A live run to show in this pane (a session picked in the sidebar). It
* attaches to that tmux session only — nothing is started or resumed. */
/** A live run to show in this pane (a session picked in the sidebar or a
* tab). It attaches to that tmux session only. */
attachRequest?: { runId: string; nonce: string } | null;
/** Opens the workspace home, where a new session is started. */
onNewSession?: () => void;
@@ -83,7 +42,7 @@ export interface LaneConsolePaneProps {
onRunExit?: () => void;
}
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
function paneRunId(laneId: number, slot: number): string {
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
}
@@ -91,106 +50,48 @@ function paneRunId(laneId: number, slot: number): string {
export function LaneConsolePane({
lanes,
laneId,
showLaneSelector,
onLaneIdChange,
onLaneCreated,
binaryStatus,
activeRuns,
externalSessions,
wsConnected,
onHasActiveRunChange,
slot = 0,
hiddenRunIds,
onHandleChange,
attachRequest,
onNewSession,
onRunExit,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
const { t: tCommon } = useTranslation("common");
const [handle, setHandle] = useState<RunHandle | null>(null);
const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
const [error, setError] = useState<string | null>(null);
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
const laneTool = currentLane?.tool ?? "claude";
// The page polls runs and sessions for EVERY lane; this pane's Active runs
// list is this lane's only. No lane selected keeps the full lists.
const laneRuns = useMemo(
() =>
laneId === null || !activeRuns
? activeRuns
: {
...activeRuns,
items: activeRuns.items.filter(
(r) => r.laneId === laneId && !hiddenRunIds?.includes(r.id)
),
},
[activeRuns, laneId, hiddenRunIds]
);
const laneSessions = useMemo(
() =>
laneId === null
? externalSessions
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
[externalSessions, laneId, lanes]
);
const aiTools = useAiTools();
// The tool of the run on screen — a session's own, which need not be the
// workspace's; the workspace's with no run.
const paneTool = handle?.tool ?? laneTool;
const toolLabel = aiTools.label(paneTool);
const paneTool = handle?.tool ?? currentLane?.tool ?? "claude";
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
}, [handle, onHasActiveRunChange]);
useEffect(() => {
onHandleChange?.(handle?.id ?? null);
}, [handle, onHandleChange]);
// `activeRuns` is re-fetched every few seconds by the page, so reading it
// through a ref keeps the lane-switch effect below off its identity.
const latest = useRef(activeRuns);
latest.current = activeRuns;
const refreshList = useCallback(() => {
if (laneId !== null) {
api.run
.history(50, { laneId })
.then((r) => setRunHistory(r.items))
.catch(() => undefined);
} else {
api.run
.history(50)
.then((r) => setRunHistory(r.items))
.catch(() => undefined);
}
}, [laneId]);
// `lanes` and `activeRuns` are re-fetched every few seconds by the page, so
// reading them through a ref keeps the lane-switch effect below off their
// identity — a background poll must not wipe a half-typed prompt.
const latest = useRef({ lanes, activeRuns });
latest.current = { lanes, activeRuns };
// A pane's history and terminal belong to the lane it shows, so switching
// lanes has to swap both. Re-attach right away when the new lane already
// has a live run: each lane sticks to its own tmux session.
// A pane's terminal belongs to the lane it shows, so switching lanes swaps
// it — to the new lane's own live session for this slot, if it has one.
const autoAttachedForLane = useRef<number | null>(null);
useEffect(() => {
const { activeRuns: runs } = latest.current;
setError(null);
setBusy(null);
const own = laneId === null ? null : paneRunId(laneId, slot);
setHandle(runs?.items.find((r) => r.id === own && r.status === "running") ?? null);
setHandle(latest.current?.items.find((r) => r.id === own && r.status === "running") ?? null);
autoAttachedForLane.current = null;
refreshList();
}, [laneId, slot, refreshList]);
}, [laneId, slot]);
// Runs after the lane-switch effect above, so a sidebar pick that changes
// lane AND run at once lands on the picked run, not the lane's own. Keyed on
// `laneId` too: on a cold load the pane mounts before the lane list does,
// and the switch effect then resets the pane to its own slot once `laneId`
// arrives — the request must land after that. Only this lane's own panes.
// arrives — the request must land after that. Only this lane's own tabs.
useEffect(() => {
if (!attachRequest || laneId === null) return;
if (!new RegExp(`^agenthub-lane-${laneId}(-[1-3])?$`).test(attachRequest.runId)) return;
@@ -214,18 +115,9 @@ export function LaneConsolePane({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attachRequest?.nonce, laneId]);
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
// slash command (or the codex stage-checklist prose) is sent as soon as the
// run starts, which is an extra prompt nobody asked for. The prompt is the
// user's to type; the pipeline is declared by `agenthub stage` instead.
// The switch effect above only sees whatever `activeRuns` the page already
// had loaded at that instant. Remounting this page (navigating away and
// back) starts `activeRuns` at null again, so a lane with a live run would
// otherwise show the setup form until the user switched lanes and back —
// the only path that re-ran the effect after the poll caught up. Re-check
// once `activeRuns` actually arrives, but only once per lane so it never
// fights a user-initiated "New Run".
// The switch effect only sees whatever `activeRuns` the page had loaded at
// that instant — null right after a remount. Re-check once the poll lands,
// once per lane.
useEffect(() => {
if (handle || laneId === null) return;
if (autoAttachedForLane.current === laneId) return;
@@ -237,229 +129,16 @@ export function LaneConsolePane({
}
}, [activeRuns, laneId, slot, handle]);
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]
);
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) {
const effectiveCwd = item.cwd;
// This pane's history is its own lane's (`refreshList`), whatever
// tool each session ran on. With no lane, a cwd can back both a
// claude and a codex lane — match the item's own tool.
let targetLaneId =
laneId ?? lanes.find((l) => l.cwd === effectiveCwd && l.tool === item.tool)?.id;
if (!targetLaneId) {
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd, tool: item.tool });
targetLaneId = ensureResult.lane.id;
onLaneCreated(ensureResult.lane);
}
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
slot,
prompt: "",
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
effort: item.effort || undefined,
resumeSessionId: item.session_id,
});
} catch (laneErr: unknown) {
// The lane's own pane is still busy with a PREVIOUS live session —
// that session is still active and reachable, so land on it
// instead of failing outright. Killing it (Active runs → Kill) is
// what actually frees the lane for this resume. Checked by `.code`
// — the server's `.message` is prose, never the literal string
// "ERUNLIVE" or "409".
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
onLaneIdChange(targetLaneId);
await attachToRun(paneRunId(targetLaneId, slot));
setError(t("errors.attachedToLiveInstead"));
return;
}
throw laneErr;
}
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
if (!startedId) {
throw new Error(t("errors.noRunIdReturned"));
}
fetched = await api.run.get(startedId);
onLaneIdChange(targetLaneId);
} else {
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);
refreshList();
} catch (err) {
const msg = err instanceof Error ? err.message : "unknown";
setError(t("errors.startFailed", { message: msg }));
} finally {
setBusy(null);
}
},
[busy, refreshList, t, lanes, laneId, slot, onLaneCreated, onLaneIdChange, attachToRun]
);
const onViewFromHistory = useCallback(
(item: DashboardRunHistoryItem) => {
if (item.session_id) void onResumeFromHistory(item);
},
[onResumeFromHistory]
);
const newRun = useCallback(() => {
setHandle(null);
setError(null);
}, []);
// Frees a lane blocked by a live run nobody wants anymore (see Active
// runs' Kill button) — the lane's own `run_id` self-heals to null the next
// time it's read (routes/lanes.js's healRunId), so no lane-side call here.
const killRun = useCallback(
async (id: string) => {
setBusy("kill");
setError(null);
try {
await api.run.kill(id);
if (handle?.id === id) newRun();
refreshList();
} catch (err) {
setError(
t("errors.killFailed", { message: err instanceof Error ? err.message : "unknown" })
);
} finally {
setBusy(null);
}
},
[handle, newRun, refreshList, t]
);
if (laneId === null && showLaneSelector) {
return (
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
<select
data-testid="pane-lane-select"
aria-label={tLanes("splitView.paneLaneLabel")}
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-secondary"
value=""
onChange={(e) => e.target.value && onLaneIdChange(Number(e.target.value))}
>
<option value="">{tLanes("splitView.pickLane")}</option>
{lanes.map((l) => (
<option key={l.id} value={l.id}>
{l.title || l.cwd}
</option>
))}
</select>
<p className="text-xs text-fg-muted">{tLanes("splitView.emptyPane")}</p>
</div>
);
}
return (
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
{showLaneSelector && (
<select
data-testid="pane-lane-select"
aria-label={tLanes("splitView.paneLaneLabel")}
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-secondary"
value={laneId ?? ""}
onChange={(e) => e.target.value && onLaneIdChange(Number(e.target.value))}
>
<option value="">{tLanes("splitView.pickLane")}</option>
{lanes.map((l) => (
<option key={l.id} value={l.id}>
{l.title || l.cwd}
</option>
))}
</select>
)}
{/* Title, live badge and blurb on ONE line: the blurb is a standing
description of the panel, not a message, so it does not earn the
second row it used to wrap onto. It drops out entirely below `sm`,
where the width would wrap it again. */}
<header className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0">
<Play className="w-3.5 h-3.5 text-accent" />
</div>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
<h1 className="text-base font-semibold text-fg-primary">
{t("title", { tool: toolLabel })}
</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="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block">
{t("subtitle", { tool: toolLabel })}
</p>
</div>
<ActiveRunsSwitcher
activeRuns={laneRuns}
currentHandleId={handle?.id || null}
onAttach={attachToRun}
runHistory={runHistory}
externalSessions={laneSessions}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onKill={killRun}
onRefresh={refreshList}
/>
</header>
{/* Not probed yet (a lane on a tool the page has not probed) is not "missing". */}
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-3">
{/* Not probed yet (a tool the page has not probed, or a shell) is not "missing". */}
{binaryStatus?.[paneTool] && !binaryStatus[paneTool].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", {
cli: aiTools.byId(paneTool)?.command ?? paneTool,
tool: toolLabel,
tool: aiTools.label(paneTool),
})}
</span>
</div>
@@ -488,23 +167,16 @@ export function LaneConsolePane({
) : (
<div className="flex-1 min-h-0 flex flex-col">
<TerminalView
runId={handle!.id}
runId={handle.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// the tab bar moves on (another tab, or the home's prompt box)
// rather than leaving a dead terminal the user has to click out of.
onExit={() => {
newRun();
refreshList();
setHandle(null);
onRunExit?.();
}}
/>
<button
onClick={onNewSession ?? 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>
-667
View File
@@ -1,667 +0,0 @@
/**
* @file RunHistory.tsx
* @description Past and live dashboard runs. Moved verbatim out of
* `pages/Run.tsx` so the Run page and the Workspace page can both mount the
* same run list.
*
* Two exported pieces, rendered exactly where the page rendered them before:
* - `ActiveRunsSwitcher` — the header button (live count, or total when
* nothing is running) that opens the modal;
* - `RunsModal` — the merged list itself: live in-memory handles from
* `activeRuns` deduped against persistent `runHistory`, newest first, with
* status / mode chip filters, a free-text search, and the per-row Attach /
* Resume / View / Kill actions.
*
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
* "Active runs" counts everything actually running in scope. They carry no tmux session
* the dashboard can attach to, so their only action is Resume, which spawns a
* fresh tmux-backed `claude --resume <session>` in that cwd — Kill is withheld
* from them for the same reason.
*
* Props only: no API call of its own. The page passes `activeRuns`,
* `runHistory` and `externalSessions` in and gets attach / resume / view /
* kill / refresh back out through callbacks; the 2 s refresh ticker the modal
* runs just calls `onRefresh`. Kill (two clicks, same confirm convention as
* discarding a file in the Changes panel) is what actually frees a lane whose
* pane is still busy with a previous session — `LaneConsolePane` needs it
* because starting or resuming into a lane already holding a live run answers
* 409 ERUNLIVE rather than overwriting a prompt in flight.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Play,
RefreshCw,
Info,
ExternalLink,
X,
ListOrdered,
Search,
RotateCcw,
Eye,
Square,
} from "lucide-react";
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
import type { Session } from "../../lib/types";
// Minimal StatusPill component (from deleted RunConsole)
function StatusPill({
status,
}: {
status: RunStatus | "completed" | "error" | "killed" | "abandoned";
}) {
const colors: Record<string, string> = {
running: "bg-status-success/10 text-status-success border-status-success/30",
gone: "bg-surface-3 text-fg-secondary border-border",
completed: "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30",
error: "bg-status-danger/10 text-status-danger border-status-danger/30",
killed: "bg-surface-3 text-fg-secondary border-border",
abandoned: "bg-surface-3 text-fg-secondary border-border",
};
const color = colors[status] || colors.abandoned;
return (
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${color}`}>{status}</span>
);
}
type RunStatusFilter =
| "all"
| "running"
| "spawning"
| "completed"
| "error"
| "killed"
| "abandoned";
export interface UnifiedRunRow {
id: string;
sessionId: string | null;
cwd: string;
model: string | null;
status: RunStatus | "completed" | "error" | "killed" | "abandoned";
promptPreview: string;
startedAt: number;
endedAt: number | null;
isLive: boolean;
/** Live Claude Code session this dashboard did not spawn — no tmux session to
* attach to, so Resume (a fresh `claude --resume` in its cwd) is the only
* action. */
external?: boolean;
}
export function ActiveRunsSwitcher({
activeRuns,
currentHandleId,
onAttach,
runHistory,
externalSessions = [],
onResumeFromHistory,
onViewFromHistory,
onKill,
onRefresh,
}: {
activeRuns: RunListResponse | null;
currentHandleId: string | null;
onAttach: (id: string) => void;
runHistory: DashboardRunHistoryItem[];
/** Sessions with `status: "active"` from GET /api/sessions. Remote-source and
* cwd-less sessions are ignored — neither can be resumed on this machine. */
externalSessions?: Session[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
/** Kills the row's tmux session (DELETE /api/run/:id). Live, dashboard-
* spawned rows only — an external session has no tmux session here to
* kill. */
onKill: (id: string) => void;
onRefresh: () => void;
}) {
const { t } = useTranslation("run");
const [open, setOpen] = useState(false);
// Lock body scroll while the modal is open and let Esc close it.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open]);
// Merge live in-memory handles + persistent history + externally started
// sessions into one row list. Live entries dedupe past-history entries with
// the same id; a session id already covered by a run row is never repeated as
// an external row.
const { rows, historyItems } = useMemo(() => {
const out: UnifiedRunRow[] = [];
const seen = new Set<string>();
const seenSessions = new Set<string>();
if (activeRuns) {
for (const r of activeRuns.items) {
seen.add(r.id);
if (r.sessionId) seenSessions.add(r.sessionId);
out.push({
id: r.id,
sessionId: r.sessionId,
cwd: r.cwd || "",
model: r.model,
status: r.status,
promptPreview: r.promptPreview || "",
startedAt: r.startedAt ? new Date(r.startedAt).getTime() : 0,
endedAt: null,
isLive: r.status === "running",
});
}
}
for (const h of runHistory) {
if (seen.has(h.id)) continue;
seen.add(h.id);
if (h.session_id) seenSessions.add(h.session_id);
const startedTs = new Date(h.started_at).getTime() || 0;
const endedTs = h.ended_at ? new Date(h.ended_at).getTime() : null;
out.push({
id: h.id,
sessionId: h.session_id,
cwd: h.cwd,
model: h.model,
status: h.status,
promptPreview: h.prompt_preview || "",
startedAt: startedTs,
endedAt: endedTs,
isLive: h.isLive,
});
}
// Externally started sessions: shown as live rows, and mirrored as
// synthetic history items so the existing resume path (which only reads
// session_id / cwd / model) works on them unchanged.
const synthetic: DashboardRunHistoryItem[] = [];
for (const s of externalSessions) {
if (!s.cwd) continue;
if (s.source && s.source !== "local") continue;
if (seenSessions.has(s.id)) continue;
seenSessions.add(s.id);
synthetic.push({
id: `session:${s.id}`,
session_id: s.id,
cwd: s.cwd,
model: s.model,
permission_mode: null,
effort: null,
resume_session_id: null,
prompt_preview: s.name,
status: "running",
exit_code: null,
started_at: s.started_at,
ended_at: null,
isLive: true,
tool: "claude",
});
out.push({
id: `session:${s.id}`,
sessionId: s.id,
cwd: s.cwd,
model: s.model,
status: "running",
promptPreview: s.name || "",
startedAt: new Date(s.started_at).getTime() || 0,
endedAt: null,
isLive: true,
external: true,
});
}
out.sort((a, b) => b.startedAt - a.startedAt);
return {
rows: out,
historyItems: synthetic.length ? [...runHistory, ...synthetic] : runHistory,
};
}, [activeRuns, runHistory, externalSessions]);
const liveCount = rows.filter((r) => r.isLive).length;
const totalCount = rows.length;
return (
<>
<button
onClick={() => setOpen(true)}
disabled={totalCount === 0}
className={`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 ${
liveCount > 0
? "border-status-success/40 bg-status-success/10 text-status-success hover:bg-status-success/15"
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
}`}
>
<ListOrdered className="w-3.5 h-3.5" />
{liveCount > 0 ? (
<>
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
{t("runs.viewActive_other", { count: liveCount })}
</>
) : (
<>
{t("runs.switcher")}
{totalCount > 0 && <span className="text-fg-muted font-mono">{totalCount}</span>}
</>
)}
</button>
{open && (
<RunsModal
rows={rows}
currentHandleId={currentHandleId}
onAttach={(id) => {
setOpen(false);
onAttach(id);
}}
onResume={(item) => {
setOpen(false);
onResumeFromHistory(item);
}}
onView={(item) => {
setOpen(false);
onViewFromHistory(item);
}}
onKill={onKill}
runHistory={historyItems}
onClose={() => setOpen(false)}
onRefresh={onRefresh}
/>
)}
</>
);
}
export function RunsModal({
rows,
currentHandleId,
onAttach,
onResume,
onView,
onKill,
runHistory,
onClose,
onRefresh,
}: {
rows: UnifiedRunRow[];
currentHandleId: string | null;
onAttach: (id: string) => void;
onResume: (item: DashboardRunHistoryItem) => void;
onView: (item: DashboardRunHistoryItem) => void;
onKill: (id: string) => void;
runHistory: DashboardRunHistoryItem[];
onClose: () => void;
onRefresh: () => void;
}) {
const { t } = useTranslation("run");
const [statusFilter, setStatusFilter] = useState<RunStatusFilter>("all");
const [search, setSearch] = useState("");
// Snappy refresh while the modal is the foreground UI: pull immediately
// on open + every 2 s after that. Combined with the page-level 5 s poll
// and the WS run_status broadcasts, this guarantees that any state
// change - lifecycle event, sibling tab, manual DB tweak, boot
// reconciliation - surfaces here within a couple of seconds.
useEffect(() => {
onRefresh();
const tick = setInterval(onRefresh, 2000);
return () => clearInterval(tick);
}, [onRefresh]);
const counts = useMemo(() => {
const byStatus: Record<string, number> = { all: rows.length };
for (const r of rows) {
byStatus[r.status] = (byStatus[r.status] || 0) + 1;
}
return { byStatus };
}, [rows]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return rows.filter((r) => {
if (statusFilter !== "all" && r.status !== statusFilter) return false;
if (!q) return true;
const hay =
r.promptPreview + "\n" + r.cwd + "\n" + (r.sessionId || "") + "\n" + (r.model || "");
return hay.toLowerCase().includes(q);
});
}, [rows, statusFilter, search]);
const historyById = useMemo(() => {
const m = new Map<string, DashboardRunHistoryItem>();
for (const h of runHistory) m.set(h.id, h);
return m;
}, [runHistory]);
const STATUSES: RunStatusFilter[] = [
"all",
"running",
"completed",
"error",
"killed",
"abandoned",
];
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center px-4 py-10 overflow-y-auto bg-black/60 backdrop-blur-sm animate-fade-in"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="w-full max-w-4xl rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/60 flex flex-col max-h-[85vh]">
{/* Header */}
<div className="flex items-center gap-3 px-5 py-3 border-b border-border flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-accent/15 inline-flex items-center justify-center">
<ListOrdered className="w-4 h-4 text-accent" />
</div>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-fg-primary">
{t("runs.modalTitle", "Dashboard runs")}
</h2>
<p className="text-[11px] text-fg-muted">
{t(
"runs.modalSubtitle",
"Every run started from this dashboard, regardless of status"
)}
</p>
</div>
<button
onClick={onRefresh}
className="w-7 h-7 rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 inline-flex items-center justify-center"
aria-label={t("runs.refresh", "Refresh")}
title={t("runs.refresh", "Refresh")}
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
<Link
to="/sessions"
onClick={onClose}
className="text-[11px] text-accent hover:text-accent/80 inline-flex items-center gap-1 mr-1"
>
{t("runs.allSessionsLink")}
</Link>
<button
onClick={onClose}
className="w-7 h-7 rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 inline-flex items-center justify-center"
aria-label={t("limitations.dismiss")}
>
<X className="w-4 h-4" />
</button>
</div>
{/* Filter bar */}
<div className="px-5 py-3 border-b border-border flex flex-col gap-2.5 flex-shrink-0">
<div className="flex items-center gap-2 bg-surface-2 border border-border rounded-md px-2.5 py-1.5">
<Search className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
<input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")}
className="flex-1 bg-transparent text-[12px] text-fg-primary placeholder:text-fg-muted focus:outline-none"
/>
{search && (
<button
onClick={() => setSearch("")}
className="text-fg-muted hover:text-fg-secondary text-[10px]"
aria-label="Clear"
>
<X className="w-3 h-3" />
</button>
)}
</div>
<div className="flex flex-wrap gap-3 items-center">
<FilterChipGroup
label={t("runs.filterStatus", "Status")}
value={statusFilter}
options={STATUSES.map((s) => ({
value: s,
label: s === "all" ? t("runs.allLabel", "All") : t(`status.${s}`),
count: counts.byStatus[s] || 0,
}))}
onChange={(v) => setStatusFilter(v as RunStatusFilter)}
/>
</div>
</div>
{/* List */}
<div className="flex-1 min-h-0 overflow-auto divide-y divide-border">
{filtered.length === 0 ? (
<div className="px-5 py-12 text-center text-[12px] text-fg-muted">
{rows.length === 0
? t(
"runs.modalEmpty",
"No dashboard runs yet. Start one below to populate this list."
)
: t("runs.modalEmptyFiltered", "No runs match these filters.")}
</div>
) : (
filtered.map((r) => {
const isCurrent = r.id === currentHandleId;
return (
<UnifiedRunRowView
key={r.id}
row={r}
isCurrent={isCurrent}
onAttach={() => onAttach(r.id)}
onResume={() => {
const h = historyById.get(r.id);
if (h) onResume(h);
}}
onView={() => {
const h = historyById.get(r.id);
if (h) onView(h);
}}
onKill={() => onKill(r.id)}
/>
);
})
)}
</div>
{/* Footer */}
<div className="px-5 py-2.5 border-t border-border bg-surface-2/40 flex items-center gap-2 flex-shrink-0">
<Info className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="text-[10.5px] text-fg-muted leading-relaxed flex-1">
{t("runs.scopeNote")}
</span>
<span className="text-[10.5px] text-fg-muted font-mono">
{filtered.length} / {rows.length}
</span>
</div>
</div>
</div>
);
}
function FilterChipGroup<T extends string>({
label,
value,
options,
onChange,
}: {
label: string;
value: T;
options: { value: T; label: string; count: number }[];
onChange: (v: T) => void;
}) {
return (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[10px] uppercase tracking-wider font-semibold text-fg-muted mr-1">
{label}
</span>
{options.map((opt) => {
const active = value === opt.value;
const dim = opt.count === 0 && opt.value !== "all";
return (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
disabled={dim}
className={`text-[10.5px] font-medium px-2 py-0.5 rounded-full border transition-colors disabled:opacity-40 ${
active
? "bg-accent/15 border-accent/50 text-accent"
: "bg-surface-2 border-border text-fg-secondary hover:bg-surface-3 hover:border-border-strong"
}`}
>
{opt.label}
<span className="ml-1 text-fg-muted font-mono">{opt.count}</span>
</button>
);
})}
</div>
);
}
function UnifiedRunRowView({
row,
isCurrent,
onAttach,
onResume,
onView,
onKill,
}: {
row: UnifiedRunRow;
isCurrent: boolean;
onAttach: () => void;
onResume: () => void;
onView: () => void;
onKill: () => void;
}) {
const { t } = useTranslation("run");
// Two clicks before the tmux session actually dies — same convention as
// discarding a file's changes in the Changes panel, for the one action
// here that cannot be undone.
const [confirmKill, setConfirmKill] = useState(false);
const startedDate = new Date(row.startedAt);
// 0 is "unknown", not the epoch — a run whose persisted row is missing has no
// start time, and printing "Jan 1, 08:00 AM" for it reads as real data.
const startedLabel =
!row.startedAt || isNaN(startedDate.getTime())
? "-"
: startedDate.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
// Without mode distinction, offer resume for any finished run with a session.
// An external session is live but has no attachable tmux session, so Resume
// (a new tmux-backed `claude --resume` in its cwd) is what it gets instead.
const canResume = !!row.sessionId && (!row.isLive || !!row.external);
const canView = !!row.sessionId && !row.isLive;
return (
<div
className={`px-5 py-3 transition-colors ${
isCurrent ? "bg-accent/[0.06]" : "hover:bg-surface-2/50"
}`}
>
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<StatusPill status={row.status} />
{row.isLive && (
<span className="text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 px-1.5 py-0.5 rounded-full inline-flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
{t("runs.liveBadge", "live")}
</span>
)}
{row.external && (
<span
className="text-[10px] font-semibold text-amber-700 dark:text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
title={t("runs.externalHint")}
>
{t("runs.externalBadge")}
</span>
)}
{isCurrent && (
<span className="text-[10px] font-semibold text-accent bg-accent/10 border border-accent/25 px-1.5 py-0.5 rounded-full">
{t("runs.currentBadge", "current")}
</span>
)}
<span className="ml-auto inline-flex items-center gap-1.5">
{row.isLive && !row.external && !isCurrent && (
<button
onClick={onAttach}
className="inline-flex items-center gap-1 rounded-md border border-status-success/40 bg-status-success/10 hover:bg-status-success/20 text-status-success px-2 py-0.5 text-[10.5px] font-medium transition-colors"
>
<Play className="w-3 h-3" />
{t("runs.attachLabel", "Attach")}
</button>
)}
{canResume && (
<button
onClick={onResume}
title={row.external ? t("runs.externalHint") : undefined}
className="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-2 py-0.5 text-[10.5px] font-medium transition-colors"
>
<RotateCcw className="w-3 h-3" />
{t("resume.resumeOption", "Resume")}
</button>
)}
{canView && (
<button
onClick={onView}
className="inline-flex items-center gap-1 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary px-2 py-0.5 text-[10.5px] font-medium transition-colors"
>
<Eye className="w-3 h-3" />
{t("runs.viewLabel", "View")}
</button>
)}
{row.isLive && !row.external && (
<button
onClick={() => {
if (!confirmKill) {
setConfirmKill(true);
return;
}
setConfirmKill(false);
onKill();
}}
onBlur={() => setConfirmKill(false)}
title={
confirmKill
? t("runs.killConfirm", "Click again to kill this session — cannot be undone")
: t("runs.killLabel", "Kill")
}
className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10.5px] font-medium transition-colors ${
confirmKill
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
: "border-border bg-surface-2 hover:border-status-danger/40 hover:bg-status-danger/10 text-fg-secondary hover:text-status-danger"
}`}
>
<Square className="w-3 h-3" />
{confirmKill ? t("runs.killConfirmShort", "Sure?") : t("runs.killLabel", "Kill")}
</button>
)}
</span>
</div>
{row.promptPreview && (
<div className="text-[12px] text-fg-secondary line-clamp-2 leading-snug">
{row.promptPreview}
</div>
)}
<div className="font-mono text-[10px] text-fg-muted truncate mt-1">{row.cwd}</div>
<div className="text-[10px] text-fg-muted mt-0.5 flex items-center gap-2 flex-wrap">
<span>{startedLabel}</span>
{row.model && <span className="font-mono text-fg-muted">· {row.model}</span>}
{row.sessionId && (
<Link
to={`/sessions/${encodeURIComponent(row.sessionId)}`}
className="inline-flex items-center gap-1 text-fg-muted hover:text-fg-secondary transition-colors"
title={t("actions.viewSession")}
>
<ExternalLink className="w-2.5 h-2.5" />
<span className="font-mono">{row.sessionId.slice(0, 8)}</span>
</Link>
)}
</div>
</div>
);
}
@@ -1,8 +1,8 @@
/**
* @file LaneConsolePane.test.tsx
* @description Test suite for the LaneConsolePane component: a pane shows its
* lane's terminal or an empty state whose New session goes to the workspace
* home — a pane has no run form of its own.
* @description Test suite for the LaneConsolePane component: a tab's body shows
* its lane's terminal or an empty state whose New session goes to the
* workspace home — no run form and no header of its own.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -38,7 +38,6 @@ vi.mock("../../../lib/api", () => ({
},
run: {
list: vi.fn().mockResolvedValue({ items: [] }),
history: vi.fn().mockResolvedValue({ items: [] }),
get: vi.fn(),
start: vi.fn(),
},
@@ -85,16 +84,12 @@ function baseProps() {
return {
lanes: [LANE],
laneId: 1,
showLaneSelector: false,
onLaneIdChange: vi.fn(),
onLaneCreated: vi.fn(),
binaryStatus: {
claude: { found: true, path: "/usr/local/bin/claude" },
codex: { found: true, path: "/usr/local/bin/codex" },
antigravity: { found: true, path: "/usr/local/bin/agy" },
},
activeRuns: { items: [] },
wsConnected: true,
};
}
@@ -113,7 +108,7 @@ describe("LaneConsolePane", () => {
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("goes back to the home when a single pane's run exits, and on New run", async () => {
it("has no header: no title, no Active runs, no New run - the tab bar is the header", async () => {
const live = {
id: "agenthub-lane-1",
laneId: 1,
@@ -128,22 +123,19 @@ describe("LaneConsolePane", () => {
promptPreview: null,
tool: "claude" as const,
};
const onNewSession = vi.fn();
const onRunExit = vi.fn();
render(
<LaneConsolePane
{...baseProps()}
activeRuns={{ items: [live] }}
onNewSession={onNewSession}
onRunExit={onNewSession}
/>
<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} onRunExit={onRunExit} />
);
await screen.findByTestId("terminal-view");
fireEvent.click(screen.getByRole("button", { name: "New run" }));
expect(onNewSession).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("heading")).toBeNull();
expect(screen.queryByRole("button", { name: "New run" })).toBeNull();
expect(screen.queryByRole("button", { name: /active run/i })).toBeNull();
// The run exits: the tab bar is told, and the pane is left empty.
fireEvent.click(screen.getByTestId("fake-pane-exit"));
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
expect(onNewSession).toHaveBeenCalledTimes(2);
expect(onRunExit).toHaveBeenCalledOnce();
expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
});
@@ -185,138 +177,12 @@ describe("LaneConsolePane", () => {
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
});
it("shows a lane dropdown only when showLaneSelector is true", () => {
const { rerender } = render(<LaneConsolePane {...baseProps()} showLaneSelector />);
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
rerender(<LaneConsolePane {...baseProps()} showLaneSelector={false} />);
expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument();
});
it("renders the console body when laneId is null and showLaneSelector is false (layout-1, fresh install)", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={false} />);
it("renders the console body when laneId is null (fresh install)", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} />);
expect(screen.getByTestId("console-body")).toBeInTheDocument();
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
});
it("attaches to the lane's still-live session instead of failing when Resume hits ERUNLIVE", async () => {
const HISTORY_ITEM = {
id: "run-old",
session_id: "sess-old",
cwd: "/workspace/a",
model: null,
permission_mode: null,
effort: null,
resume_session_id: null,
prompt_preview: "earlier prompt",
status: "abandoned" as const,
exit_code: null,
started_at: new Date(1000).toISOString(),
ended_at: new Date(2000).toISOString(),
isLive: false,
tool: "claude" as const,
};
(api.run.history as ReturnType<typeof vi.fn>).mockResolvedValue({ items: [HISTORY_ITEM] });
(api.lanes.action as ReturnType<typeof vi.fn>).mockRejectedValue(
// Matches the real shape `request()` throws: a plain, human-readable
// `.message` plus a `.code` a caller actually branches on.
Object.assign(new Error("lane already has a live run"), { code: "ERUNLIVE", status: 409 })
);
(api.lanes.list as ReturnType<typeof vi.fn>).mockResolvedValue({
lanes: [{ ...LANE, run_id: "run-live" }],
});
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "run-live",
laneId: 1,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
});
render(
<MemoryRouter>
<LaneConsolePane {...baseProps()} />
</MemoryRouter>
);
fireEvent.click(await screen.findByRole("button", { name: /active runs/i }));
const resumeButtons = await screen.findAllByRole("button", {
name: "Resume existing session",
});
fireEvent.click(resumeButtons[0]!);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-live")
);
expect(screen.getByText(/attached to it instead/i)).toBeInTheDocument();
});
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
});
it("lists only this lane's runs and sessions in Active runs, not every lane's", () => {
const run = (id: string, laneId: number, promptPreview: string) => ({
id,
laneId,
status: "running",
cwd: "/workspace/x",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: "2026-09-24T01:00:00Z",
promptPreview,
});
const session = (id: string, cwd: string, name: string) =>
({
id,
cwd,
name,
model: null,
source: "local",
started_at: "2026-09-24T01:00:00Z",
}) as never;
render(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
lanes={[
LANE,
{ ...LANE, id: 2, cwd: "/workspace/b" },
// Nested inside lane 1: its sessions are ITS own, by longest prefix.
{ ...LANE, id: 3, cwd: "/workspace/a/nested" },
]}
activeRuns={{ items: [run("r1", 1, "mine"), run("r2", 2, "other lane's")] as never }}
externalSessions={[
session("s1", "/workspace/a/src", "my session"),
session("s2", "/workspace/b", "b session"),
session("s3", "/workspace/a/nested/x", "nested session"),
session("s4", "/workspace/ab", "sibling prefix"),
]}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: /2 active runs/ }));
expect(screen.getByText("mine")).toBeTruthy();
expect(screen.getByText("my session")).toBeTruthy();
expect(screen.queryByText("other lane's")).toBeNull();
expect(screen.queryByText("b session")).toBeNull();
expect(screen.queryByText("nested session")).toBeNull();
expect(screen.queryByText("sibling prefix")).toBeNull();
});
describe("split panes on one lane", () => {
describe("tabs on one lane", () => {
const run = (id: string, promptPreview = id) => ({
id,
laneId: 1,
@@ -342,24 +208,6 @@ describe("LaneConsolePane", () => {
)
);
});
it("leaves runs other panes show out of Active runs", () => {
render(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
slot={1}
activeRuns={{
items: [run("agenthub-lane-1", "shown"), run("agenthub-lane-1-3", "free")],
}}
hiddenRunIds={["agenthub-lane-1"]}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: /1 active run/ }));
expect(screen.getByText("free")).toBeTruthy();
expect(screen.queryByText("shown")).toBeNull();
});
});
it("attaches to a run picked in the sidebar without starting or resuming anything", async () => {
@@ -1,338 +0,0 @@
/**
* @file RunHistory.test.tsx
* @description Pins the props-only boundary of `ActiveRunsSwitcher` / `RunsModal`
* after their move out of `pages/Run.tsx`: the switcher counts live runs and
* opens the list, the list merges live in-memory handles with persistent history
* (live entries winning on a shared id) newest first, marks the live and current
* rows, filters by status / mode / free text, and fires attach / resume / view
* with the right run — attach by run id, resume and view with the matching
* history item.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { MemoryRouter } from "react-router-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { ActiveRunsSwitcher, RunsModal, type UnifiedRunRow } from "../RunHistory";
import type { DashboardRunHistoryItem, RunListResponse } from "../../../lib/api";
import type { Session } from "../../../lib/types";
const LIVE_ID = "run-live";
const PAST_ID = "run-past";
const HEADLESS_ID = "run-headless";
const activeRuns = {
activeCount: 1,
maxConcurrent: 2,
items: [
{
id: LIVE_ID,
sessionId: "sess-live",
cwd: "/tmp/live",
model: "claude-opus-5",
status: "running",
startedAt: "2000-01-01T00:50:00Z",
promptPreview: "the live prompt",
endedAt: null,
},
],
} as unknown as RunListResponse;
function historyItem(over: Partial<DashboardRunHistoryItem>): DashboardRunHistoryItem {
return {
id: PAST_ID,
session_id: "sess-past",
cwd: "/tmp/past",
model: "sonnet",
status: "completed",
prompt_preview: "the past prompt",
started_at: new Date(2000).toISOString(),
ended_at: new Date(2500).toISOString(),
exit_code: 0,
permission_mode: "acceptEdits",
effort: "",
resume_session_id: null,
isLive: false,
...over,
} as DashboardRunHistoryItem;
}
const PAST = historyItem({});
const HEADLESS = historyItem({
id: HEADLESS_ID,
session_id: "sess-headless",
cwd: "/tmp/headless",
prompt_preview: "the headless prompt",
started_at: new Date(1000).toISOString(),
});
function externalSession(over: Partial<Session> = {}): Session {
return {
id: "sess-external",
name: "the external prompt",
status: "active",
cwd: "/tmp/external",
model: "claude-opus-5",
started_at: new Date(3000).toISOString(),
ended_at: null,
updated_at: new Date(3000).toISOString(),
source: "local",
...over,
} as unknown as Session;
}
function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRunsSwitcher>> = {}) {
const spies = {
onAttach: vi.fn(),
onResumeFromHistory: vi.fn(),
onViewFromHistory: vi.fn(),
onKill: vi.fn(),
onRefresh: vi.fn(),
};
const utils = render(
<MemoryRouter>
<ActiveRunsSwitcher
activeRuns={activeRuns}
currentHandleId={null}
runHistory={[PAST, HEADLESS]}
{...spies}
{...overrides}
/>
</MemoryRouter>
);
return { ...utils, spies };
}
function row(id: string, over: Partial<UnifiedRunRow> = {}): UnifiedRunRow {
return {
id,
sessionId: `sess-${id}`,
cwd: `/tmp/${id}`,
model: "sonnet",
status: "abandoned",
promptPreview: `prompt of ${id}`,
startedAt: 1000,
endedAt: 2000,
isLive: false,
...over,
};
}
function renderModal(
rows: UnifiedRunRow[],
overrides: Partial<React.ComponentProps<typeof RunsModal>> = {}
) {
const spies = {
onAttach: vi.fn(),
onResume: vi.fn(),
onView: vi.fn(),
onKill: vi.fn(),
onClose: vi.fn(),
onRefresh: vi.fn(),
};
const utils = render(
<MemoryRouter>
<RunsModal
rows={rows}
currentHandleId={null}
runHistory={[PAST, HEADLESS]}
{...spies}
{...overrides}
/>
</MemoryRouter>
);
return { ...utils, spies };
}
/** The nth "All" chip — index 0 is the status group, 1 is the mode group. */
function allChip(nth: number): HTMLElement {
const hits = screen.getAllByText(i18n.t("run:runs.allLabel", "All"));
const hit = hits[nth];
if (!hit) throw new Error(`no "All" chip at index ${nth}`);
return hit;
}
/** A filter chip, told apart from the same word appearing in a row's status
* pill or mode badge by being a `<button>`. */
function chip(label: string): HTMLElement {
const hit = screen.getAllByText(label).find((el) => el.tagName === "BUTTON");
if (!hit) throw new Error(`no filter chip labelled ${label}`);
return hit;
}
const openModal = () =>
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
});
describe("ActiveRunsSwitcher", () => {
it("labels the button with the live count and opens the list", () => {
renderSwitcher();
openModal();
expect(screen.getByText("the live prompt")).toBeTruthy();
expect(screen.getByText("the past prompt")).toBeTruthy();
});
it("falls back to the total count when nothing is live, and disables at zero", () => {
const { unmount } = renderSwitcher({ activeRuns: null });
expect(screen.getByText(i18n.t("run:runs.switcher"))).toBeTruthy();
expect(screen.getByText("2")).toBeTruthy();
unmount();
renderSwitcher({ activeRuns: null, runHistory: [] });
const button = screen.getByText(i18n.t("run:runs.switcher")).closest("button");
expect((button as HTMLButtonElement).disabled).toBe(true);
});
it("lists live runs first and marks the live one", () => {
renderSwitcher();
openModal();
const prompts = screen
.getAllByText(/the (live|past|headless) prompt/)
.map((el) => el.textContent);
expect(prompts).toEqual(["the live prompt", "the past prompt", "the headless prompt"]);
expect(screen.getAllByText("live")).toHaveLength(1);
});
it("prefers the live handle over a history row with the same id", () => {
renderSwitcher({ runHistory: [historyItem({ id: LIVE_ID, prompt_preview: "stale copy" })] });
openModal();
expect(screen.getByText("the live prompt")).toBeTruthy();
expect(screen.queryByText("stale copy")).toBeNull();
});
it("counts and lists a session started outside the dashboard, resumable not attachable", () => {
const { spies } = renderSwitcher({
activeRuns: null,
runHistory: [],
externalSessions: [externalSession()],
});
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
expect(screen.getByText("the external prompt")).toBeTruthy();
expect(screen.getByText(i18n.t("run:runs.externalBadge"))).toBeTruthy();
// No tmux session of ours to attach to — Resume is the only action.
expect(screen.queryByText(i18n.t("run:runs.attachLabel", "Attach"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
expect(spies.onResumeFromHistory).toHaveBeenCalledWith(
expect.objectContaining({
id: "session:sess-external",
session_id: "sess-external",
cwd: "/tmp/external",
status: "running",
isLive: true,
})
);
});
it("skips external sessions already covered by a run, remote ones, and cwd-less ones", () => {
renderSwitcher({
runHistory: [],
externalSessions: [
externalSession({ id: "sess-live" }), // same session as the live run
externalSession({ id: "sess-remote", source: "remote-1" }),
externalSession({ id: "sess-nocwd", cwd: null }),
],
});
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
expect(screen.getByText("the live prompt")).toBeTruthy();
expect(screen.queryByText("the external prompt")).toBeNull();
expect(screen.queryByText(i18n.t("run:runs.externalBadge"))).toBeNull();
});
it("shows a dash, not the epoch, for a row with no start time", () => {
// A run whose persisted row is missing arrives with startedAt 0. Rendering
// that as "Jan 1, 08:00 AM" presents a gap in the data as real data.
renderModal([row("no-start", { startedAt: 0 })]);
expect(screen.getByText("-")).toBeTruthy();
expect(screen.queryByText(/Jan 1,/)).toBeNull();
});
it("fires attach with the run id of the row that was clicked", () => {
const { spies } = renderSwitcher();
openModal();
fireEvent.click(screen.getByText(i18n.t("run:runs.attachLabel", "Attach")));
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
// Attaching closes the list, which is what the page relies on.
expect(screen.queryByText("the past prompt")).toBeNull();
});
});
describe("RunsModal", () => {
it("offers Attach only for a live row that is not the current one", () => {
const { unmount } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })], {
currentHandleId: LIVE_ID,
});
expect(screen.queryByText(i18n.t("run:runs.attachLabel", "Attach"))).toBeNull();
expect(screen.getByText(i18n.t("run:runs.currentBadge", "current"))).toBeTruthy();
unmount();
const { spies } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })]);
fireEvent.click(screen.getByText(i18n.t("run:runs.attachLabel", "Attach")));
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
});
it("needs two clicks to kill a live row, and never offers Kill for an external one", () => {
const { spies, unmount } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })]);
const killButton = screen.getByText(i18n.t("run:runs.killLabel", "Kill"));
fireEvent.click(killButton);
expect(spies.onKill).not.toHaveBeenCalled();
fireEvent.click(screen.getByText(i18n.t("run:runs.killConfirmShort", "Sure?")));
expect(spies.onKill).toHaveBeenCalledWith(LIVE_ID);
unmount();
renderModal([row(LIVE_ID, { isLive: true, status: "running", external: true })]);
expect(screen.queryByText(i18n.t("run:runs.killLabel", "Kill"))).toBeNull();
});
it("fires resume with the history item behind a finished row", () => {
const { spies } = renderModal([row(PAST_ID)]);
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
expect(spies.onResume).toHaveBeenCalledWith(PAST);
});
it("fires view for a finished row", () => {
const { spies } = renderModal([row(HEADLESS_ID)]);
fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel")));
expect(spies.onView).toHaveBeenCalledWith(HEADLESS);
});
it("filters by status and by free text", () => {
const rows = [
row("a", { status: "running", isLive: true, promptPreview: "alpha" }),
row("b", { status: "killed", promptPreview: "bravo" }),
row("c", { status: "abandoned", promptPreview: "charlie" }),
];
renderModal(rows);
fireEvent.click(chip(i18n.t("run:status.killed")));
expect(screen.getByText("bravo")).toBeTruthy();
expect(screen.queryByText("alpha")).toBeNull();
fireEvent.click(allChip(0));
fireEvent.change(
screen.getByPlaceholderText(
i18n.t("run:runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")
),
{ target: { value: "alpha" } }
);
expect(screen.getByText("alpha")).toBeTruthy();
expect(screen.queryByText("charlie")).toBeNull();
});
it("polls onRefresh while it is the foreground UI", () => {
vi.useFakeTimers();
try {
const { spies } = renderModal([row("a")]);
expect(spies.onRefresh).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(4000);
expect(spies.onRefresh).toHaveBeenCalledTimes(3);
} finally {
vi.useRealTimers();
}
});
});
-5
View File
@@ -226,14 +226,10 @@
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
"runtime.stop": "■ down",
"runtime.toggleTitle": "Boot or stop this workspace's own application stack (separate from its Claude run)",
"splitView.emptyPane": "No workspace selected for this pane.",
"splitView.paneLaneLabel": "Pane workspace selector",
"splitView.pickLane": "Pick a workspace",
"status.failed": "failed",
"status.idle": "idle",
"status.provisioning": "provisioning",
"status.running": "running",
"statusDead": "DEAD",
"title": "Workspaces",
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
"worktrees.heading_one": "{{count}} worktree",
@@ -388,7 +384,6 @@
"home.remove": "Remove",
"home.missing": "missing",
"home.unsupported": "{{tool}} cannot take instructions, context files or extra folders: they are kept, but its sessions start without them.",
"home.back": "Back to the workspace home",
"home.close": "Close",
"tabs.label": "Session tabs",
"tabs.new": "New tab",
+2 -36
View File
@@ -1,15 +1,13 @@
{
"emptyPane": "No session in this pane. Start one from the workspace's prompt box.",
"actions": {
"newRun": "New run",
"newSession": "New session",
"send": "Send",
"sending": "Sending…",
"start": "Run",
"starting": "Starting…",
"stop": "Stop",
"stopping": "Stopping…",
"viewSession": "View session"
"stopping": "Stopping…"
},
"autocomplete": {
"fileHint": "Type @ to reference a file",
@@ -36,15 +34,11 @@
},
"errors": {
"attachFailed": "Couldn't attach to run: {{message}}",
"attachedToLiveInstead": "This workspace's previous session is still running, so nothing new could start — attached to it instead. Kill it from Active runs to start fresh.",
"cwdRequired": "Working directory required to start a run",
"killFailed": "Couldn't stop: {{message}}",
"laneCreateFailed": "Failed to create workspace: {{message}}",
"noLaneSelected": "No workspace selected or could not create one",
"noRunIdReturned": "No run_id returned from workspace start",
"runStartedButNotAttached": "Run is live but couldn't attach to console: {{message}}",
"sendFailed": "Couldn't send: {{message}}",
"startFailed": "Couldn't start run: {{message}}"
"sendFailed": "Couldn't send: {{message}}"
},
"events": {
"claude": "Claude",
@@ -110,9 +104,6 @@
"sandboxWarning": "Codex will run commands with no sandbox.",
"shortcut": "Enter to send · Shift+Enter for newline"
},
"limitations": {
"dismiss": "Got it"
},
"mode": {
"conversation": "Conversation",
"conversationHint": "Multi-turn - keep typing follow-ups while the agent works.",
@@ -131,32 +122,9 @@
"pickSession": "Pick a session…",
"preview": "Last activity",
"resumeHint": "Pick a session from your history and continue the conversation. Cwd is locked to the original.",
"resumeOption": "Resume existing session",
"search": "Search by ID, cwd, or status…",
"selectedBadge": "Resuming"
},
"runs": {
"allSessionsLink": "See all Claude Code sessions →",
"attached": "Attached to existing run",
"externalBadge": "external",
"externalHint": "Started outside the dashboard, so there is no terminal to attach to. Resume opens a new tmux-backed `claude --resume` of this session in its folder.",
"scopeNote": "Runs started from this dashboard, plus Claude Code sessions running outside it.",
"started": "Started {{when}}",
"switcher": "Active runs",
"switcherEmpty": "No active runs",
"viewActive": "View active run",
"viewActive_other": "{{count}} active runs",
"viewLabel": "View"
},
"status": {
"abandoned": "Abandoned",
"completed": "Completed",
"error": "Error",
"idle": "Idle",
"killed": "Killed",
"running": "Running",
"spawning": "Spawning…"
},
"statusHeader": {
"cwd": "Cwd",
"duration": "Elapsed",
@@ -166,8 +134,6 @@
"permissionMode": "Mode",
"session": "Session"
},
"subtitle": "Streamed output, multi-turn, same hooks and analytics as your terminal.",
"title": "Run {{tool}}",
"tokens": {
"cacheRead": "Cache hit",
"cost": "Cost",
-5
View File
@@ -226,14 +226,10 @@
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
"runtime.stop": "■ dừng",
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của workspace này (khác với phiên Claude của nó)",
"splitView.emptyPane": "Chưa chọn workspace cho ô này.",
"splitView.paneLaneLabel": "Bộ chọn workspace cho ô",
"splitView.pickLane": "Chọn workspace",
"status.failed": "thất bại",
"status.idle": "rảnh",
"status.provisioning": "đang khởi tạo",
"status.running": "đang chạy",
"statusDead": "ĐÃ CHẾT",
"title": "Workspace",
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
"worktrees.heading_one": "{{count}} worktree",
@@ -388,7 +384,6 @@
"home.remove": "Gỡ",
"home.missing": "không còn",
"home.unsupported": "{{tool}} không nhận được hướng dẫn, file ngữ cảnh hay thư mục bổ sung: chúng vẫn được giữ, nhưng session của nó khởi động mà không có chúng.",
"home.back": "Quay lại trang workspace",
"home.close": "Đóng",
"tabs.label": "Các tab phiên",
"tabs.new": "Tab mới",
+2 -36
View File
@@ -1,15 +1,13 @@
{
"emptyPane": "Pane này chưa có phiên nào. Hãy tạo phiên từ ô prompt của workspace.",
"actions": {
"newRun": "Lượt chạy mới",
"newSession": "Phiên mới",
"send": "Gửi",
"sending": "Đang gửi…",
"start": "Chạy",
"starting": "Đang khởi động…",
"stop": "Dừng",
"stopping": "Đang dừng…",
"viewSession": "Xem phiên"
"stopping": "Đang dừng…"
},
"autocomplete": {
"fileHint": "Gõ @ để tham chiếu file",
@@ -35,15 +33,11 @@
},
"errors": {
"attachFailed": "Không gắn được vào run: {{message}}",
"attachedToLiveInstead": "Phiên trước của workspace này vẫn đang chạy nên không thể bắt đầu phiên mới — đã gắn vào phiên đang chạy thay vào đó. Bấm Kill trong Active runs để bắt đầu lại từ đầu.",
"cwdRequired": "Cần thư mục làm việc để bắt đầu run",
"killFailed": "Không dừng được: {{message}}",
"laneCreateFailed": "Không thể tạo workspace: {{message}}",
"noLaneSelected": "Chưa chọn workspace hoặc không thể tạo workspace",
"noRunIdReturned": "Không trả về ID run từ khởi động workspace",
"runStartedButNotAttached": "Run đã khởi động nhưng không thể gắn vào bảng điều khiển: {{message}}",
"sendFailed": "Không gửi được: {{message}}",
"startFailed": "Không khởi động được: {{message}}"
"sendFailed": "Không gửi được: {{message}}"
},
"events": {
"claude": "Claude",
@@ -109,9 +103,6 @@
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.",
"shortcut": "Enter để gửi · Shift+Enter xuống dòng"
},
"limitations": {
"dismiss": "Đã rõ"
},
"mode": {
"conversation": "Hội thoại",
"conversationHint": "Đa lượt - tiếp tục gõ trong khi agent xử lý.",
@@ -130,32 +121,9 @@
"pickSession": "Chọn phiên…",
"preview": "Hoạt động gần nhất",
"resumeHint": "Chọn một phiên từ lịch sử để tiếp tục. cwd được khoá theo phiên gốc.",
"resumeOption": "Tiếp tục phiên đã có",
"search": "Tìm theo ID, cwd hoặc status…",
"selectedBadge": "Đang tiếp tục"
},
"runs": {
"allSessionsLink": "Xem tất cả phiên Claude Code →",
"attached": "Đã gắn vào run đang chạy",
"externalBadge": "ngoài dashboard",
"externalHint": "Phiên này khởi chạy ngoài dashboard nên không có terminal để attach. Resume sẽ mở một `claude --resume` mới trong tmux tại đúng thư mục đó.",
"scopeNote": "Các run khởi chạy từ dashboard này, cùng những phiên Claude Code đang chạy bên ngoài.",
"started": "Bắt đầu lúc {{when}}",
"switcher": "Run đang chạy",
"switcherEmpty": "Không có run đang chạy",
"viewActive": "Xem run đang chạy",
"viewActive_other": "{{count}} run đang chạy",
"viewLabel": "Xem"
},
"status": {
"abandoned": "Bị bỏ",
"completed": "Hoàn tất",
"error": "Lỗi",
"idle": "Rảnh",
"killed": "Đã dừng",
"running": "Đang chạy",
"spawning": "Khởi động…"
},
"statusHeader": {
"cwd": "Cwd",
"duration": "Đã chạy",
@@ -165,8 +133,6 @@
"permissionMode": "Mode",
"session": "Session"
},
"subtitle": "Stream trực tiếp, hội thoại đa lượt, cùng hooks và analytics như ở terminal.",
"title": "Chạy {{tool}}",
"tokens": {
"cacheRead": "Cache hit",
"cost": "Chi phí",
-38
View File
@@ -119,7 +119,6 @@
* - `RunStartArgs` — exported API; see TSDoc on the symbol for behavior.
* - `RunHandle` — exported API; see TSDoc on the symbol for behavior.
* - `RunListResponse` — exported API; see TSDoc on the symbol for behavior.
* - `DashboardRunHistoryItem` — exported API; see TSDoc on the symbol for behavior.
* - `CwdSuggestion` — exported API; see TSDoc on the symbol for behavior.
* - `ModelChoice` — exported API; see TSDoc on the symbol for behavior.
* - `EffortChoice` — exported API; see TSDoc on the symbol for behavior.
@@ -309,11 +308,6 @@
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **DashboardRunHistoryItem**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **CwdSuggestion**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
@@ -2109,12 +2103,6 @@ export const api = {
run: {
/** GET /api/run - lanes with a live tmux-backed run, computed fresh from tmux state. */
list: () => request<RunListResponse>("/run"),
/** GET /api/run/history - persisted run history from `dashboard_runs`. */
history: (limit = 50, options?: { laneId?: number }) => {
const qs = new URLSearchParams({ limit: String(limit) });
if (options?.laneId !== undefined) qs.set("laneId", String(options.laneId));
return request<{ items: DashboardRunHistoryItem[] }>(`/run/history?${qs.toString()}`);
},
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
binary: (tool: LaneTool = "claude") =>
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
@@ -3960,32 +3948,6 @@ export interface RunListResponse {
items: RunHandle[];
}
/**
* A row from the persistent `dashboard_runs` sqlite table - every run ever
* spawned via /api/run, including completed / errored / killed ones long
* after the in-memory handle has been reaped.
*
* Field names are snake_case here (they mirror the DB columns) whereas the live
* {@link RunHandle} uses camelCase; the `isLive` flag bridges the two by telling
* the UI whether a matching live handle still exists for this row.
*/
export interface DashboardRunHistoryItem {
id: string;
session_id: string | null;
cwd: string;
model: string | null;
permission_mode: PermissionMode | null;
effort: EffortLevel | null;
resume_session_id: string | null;
prompt_preview: string | null;
status: "running" | "killed" | "abandoned";
exit_code: number | null;
started_at: string;
ended_at: string | null;
isLive: boolean;
tool: LaneTool;
}
/** One suggested working directory for the Run page's cwd picker. */
export interface CwdSuggestion {
/** "dashboard" = this server's own cwd; "home" = user's home dir; "recent"
+69 -141
View File
@@ -4,14 +4,12 @@
* (CLI spawns: Claude Code or Codex, determined per lane). The workspace shown
* is picked in the sidebar's project tree and named by `?lane=<id>` (mirrored to
* `agenthub.workspace.selected`, so a bare /workspace reopens the last one).
* Shows the selected lane's one-row header, pipeline map and run console (a
* Shows the selected lane's pipeline map (when a template is installed) and run console (a
* tab per live tmux slot, 0–3, with a `+` menu — `ConsoleTabs.tsx`), and the
* right panel —
* Files · Changes · Sessions. Runs are tied to lanes: starting a run
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
* isn't owned by any lane, calls POST /api/lanes/ensure first. Polls GET
* /api/sessions?status=active alongside the run list so Claude Code sessions
* started outside the dashboard also appear in the active-runs list.
* isn't owned by any lane, calls POST /api/lanes/ensure first.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -29,9 +27,8 @@
* ## 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.
* - `../components/run/` — ConsoleTabs, LaneConsolePane.
*
* ## Public surface
* - `Workspace` — merged page; see TSDoc on the symbol for behavior.
@@ -44,11 +41,10 @@
*
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Plus,
Columns2,
Layers,
@@ -61,14 +57,13 @@ import {
} from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { eventBus } from "../lib/eventBus";
import type { Lane, LaneFeature, ProofFeature } from "../lib/types";
import { keepIfSame } from "../lib/keepIfSame";
import { useLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import { ConsoleTabs } from "../components/run/ConsoleTabs";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard";
import LaneCard, { since, LaneToolsMenu } from "../components/lanes/LaneCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
@@ -129,11 +124,7 @@ function ConsoleArea({
selectedLaneId,
binaryStatus,
activeRuns,
externalSessions,
wsConnected,
onHasActiveRunChange,
onLaneCreated,
onLaneIdChange,
attachRequest,
onNewSession,
onOpenRun,
@@ -143,11 +134,7 @@ function ConsoleArea({
selectedLaneId: number | null;
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
activeRuns: RunListResponse | null;
externalSessions: Session[];
wsConnected: boolean;
onHasActiveRunChange: (val: boolean) => void;
onLaneCreated: (lane: Lane) => void;
onLaneIdChange: (id: number | null) => void;
/** The opened run (`?run=`): its tab is the active one. */
attachRequest: { runId: string; nonce: string } | null;
/** Back to the workspace home, where a new session is started. */
@@ -175,7 +162,7 @@ function ConsoleArea({
};
return (
<>
<div className="flex min-h-0 flex-1 flex-col">
{lane && (
<ConsoleTabs
lane={lane}
@@ -189,39 +176,39 @@ function ConsoleArea({
onRunsChanged();
if (slotOf(runId) === activeSlot) leave(activeSlot);
}}
// Per-workspace actions (mcp sync, sync-base check, readme).
actions={<LaneToolsMenu lane={lane} />}
/>
)}
{slots.map((slot) => (
<div key={slot} className={slot === activeSlot ? "contents" : "hidden"}>
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
slot={slot}
// The other tabs' runs: never attached twice.
hiddenRunIds={tabs.filter((r) => (r.slot ?? 0) !== slot).map((r) => r.id)}
showLaneSelector={false}
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
onHasActiveRunChange={slot === activeSlot ? onHasActiveRunChange : undefined}
onNewSession={onNewSession}
onRunExit={() => slot === activeSlot && leave(slot)}
attachRequest={
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
}
/>
</div>
))}
</>
<div
className={`flex min-h-0 flex-1 flex-col border border-border bg-surface-0 p-2 ${
lane ? "rounded-b-lg" : "rounded-lg"
}`}
>
{slots.map((slot) => (
<div key={slot} className={slot === activeSlot ? "contents" : "hidden"}>
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
slot={slot}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
onHasActiveRunChange={slot === activeSlot ? onHasActiveRunChange : undefined}
onNewSession={onNewSession}
onRunExit={() => slot === activeSlot && leave(slot)}
attachRequest={
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
}
/>
</div>
))}
</div>
</div>
);
}
export function Workspace() {
const { t: tLanes } = useTranslation("lanes");
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
const [searchParams, setSearchParams] = useSearchParams();
// Lane state: the list is the shared store the sidebar reads too; the
@@ -304,10 +291,6 @@ export function Workspace() {
// 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);
// Claude Code sessions running outside the dashboard (a `claude` the user
// started in a terminal tab). They are not dashboard runs, so the run list
// would otherwise show nothing while two agents are working.
const [externalSessions, setExternalSessions] = useState<Session[]>([]);
const [binaryStatus, setBinaryStatus] = useState<Record<
string,
{ found: boolean; path: string | null }
@@ -403,7 +386,7 @@ export function Workspace() {
.catch(() => undefined);
}, []);
// Both setters go through `keepIfSame`: these fire every 5 s below, and a
// Goes through `keepIfSame`: this fires every 5 s below, and a
// fresh-but-identical payload would otherwise re-render the lane strip, the
// lane card and the whole console area on every tick for nothing.
const refreshList = useCallback(() => {
@@ -411,12 +394,6 @@ export function Workspace() {
.list()
.then((r) => setActiveRuns(keepIfSame(r)))
.catch(() => undefined);
api.sessions
// Attach/resume go through `claude`, so a Codex session is not offered
// (filtered by the server, before its limit).
.list({ status: "active", limit: 50, sort_by: "updated_at", sort_desc: true, tool: "claude" })
.then((r) => setExternalSessions(keepIfSame(r.sessions)))
.catch(() => undefined);
}, []);
// Background poll so the run list and history reflect external changes
@@ -652,84 +629,48 @@ export function Workspace() {
currentLane && changesOpen && changesSize === "full" ? " hidden" : ""
}`}
>
{/* One header row, not two. The pipeline picker, the stage badges,
the feature picker and the router select used to sit on a second
row below this one; every one of them is a per-lane control that
belongs beside the lane's own name, and stacking them cost a whole
row of height on a panel whose point is the pipeline map further
down. The pipeline's NAME is no longer interpolated into
`laneHeader` either — the picker right next to it already says it.
They stay gated on `showInfo`, same as before the merge. */}
{currentLane && (
{/* No header row: the console's tab bar is the header. Only a
workspace with a pipeline template (dormant: none ships) keeps
its time-at-stage, pipeline and feature pickers here. */}
{currentLane && !noPipeline && (
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
{currentLane.liveness === "dead"
? tLanes("statusDead")
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{since(currentLane.stage_seconds)}
</span>
{/* Back to the workspace's home; renaming lives on the sidebar
row and the home's title, so here the name is only read. */}
<button
type="button"
data-testid="workspace-back-home"
onClick={() => setSearchParams({ lane: String(currentLane.id) })}
title={tLanes("home.back")}
aria-label={tLanes("home.back")}
className="shrink-0 rounded p-0.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
<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)}
>
<ArrowLeft className="h-4 w-4" />
</button>
<span
title={currentLane.cwd}
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
>
{currentLane.title || currentLane.cwd}
</span>
{/* A lane with no pipeline (non-claude, or no template
installed): no time-at-stage, no pipeline or feature
picker, no map. */}
{!noPipeline && (
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{since(currentLane.stage_seconds)}
</span>
)}
{!noPipeline && (
<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>
)}
{(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>
{/* No stage chip and no "auto: <stage>" chip here: the
PipelineMap right below this header already draws both
— the declared node as the current ring, the detected
one as its amber badge — so the chips were a second,
smaller copy of the same two facts. */}
{!noPipeline && features.length > 0 && (
{features.length > 0 && (
<select
data-testid="feature-picker"
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
@@ -745,11 +686,6 @@ export function Workspace() {
))}
</select>
)}
<div className="ml-auto flex items-center gap-2">
{/* Per-lane actions (mcp sync, sync-base check)
belong beside the lane they act on, not in a page header. */}
<LaneToolsMenu lane={currentLane} />
</div>
</div>
)}
{currentLane && (
@@ -823,11 +759,7 @@ export function Workspace() {
)}
</div>
)}
<div
className={`flex min-h-0 flex-1 flex-col gap-2 ${
currentLane ? "border-t border-border pt-3" : ""
}`}
>
<div className="flex min-h-0 flex-1 flex-col gap-2">
{replaying && (
<SessionReplay
lane={currentLane}
@@ -846,11 +778,7 @@ export function Workspace() {
selectedLaneId={selectedLaneId}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={() => void refreshLanes()}
onLaneIdChange={setSelectedLaneId}
attachRequest={attachRequest}
onNewSession={
currentLane
@@ -69,7 +69,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
api: {
run: {
list: r({ items: [] }),
history: r({ items: [] }),
binary: r({ found: true, path: "/usr/bin/claude" }),
cwds: r({ items: [HOME] }),
files: r({ items: [] }),
+5 -10
View File
@@ -173,13 +173,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
recordCall("GET", "/api/run/list");
return { runs: [], items: [] };
}),
history: vi.fn().mockImplementation(async (limit?: number, opts?: { laneId?: number }) => {
recordCall(
"GET",
`/api/run/history?limit=${limit}${opts?.laneId ? `&laneId=${opts.laneId}` : ""}`
);
return { items: [] };
}),
binary: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/binary");
return { found: true, path: "/usr/bin/claude" };
@@ -612,7 +605,7 @@ describe("Workspace — home and session view", () => {
expect(screen.getByTestId("lane-detail").parentElement).toHaveClass("hidden");
});
it("opens the session view for ?run=, and the back button returns home", async () => {
it("opens the session view for ?run=, headed by the tab bar alone", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
@@ -622,8 +615,10 @@ describe("Workspace — home and session view", () => {
await settle();
expect(screen.queryByTestId("workspace-home")).toBeNull();
expect(screen.getByTestId("lane-detail").parentElement).not.toHaveClass("hidden");
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
// No header row (status, back arrow, name) and no pane header: the tab bar is it.
expect(screen.queryByTestId("workspace-back-home")).toBeNull();
expect(screen.queryByText(/^Run /)).toBeNull();
expect(screen.getByTestId("console-tab-new")).toBeInTheDocument();
});
it("opens an ended session's replay for ?session=, hiding (not unmounting) the console", async () => {
@@ -5404,112 +5404,33 @@ exports[`screen snapshots > Run 1`] = `
data-testid="lane-detail"
>
<div
class="flex min-h-0 flex-1 flex-col gap-2 "
class="flex min-h-0 flex-1 flex-col gap-2"
>
<div
class="contents"
>
<div
class="contents"
class="flex min-h-0 flex-1 flex-col"
>
<div
class="flex min-h-0 flex-1 flex-col gap-5"
data-testid="console-body"
class="flex min-h-0 flex-1 flex-col border border-border bg-surface-0 p-2 rounded-lg"
>
<header
class="flex items-center gap-2.5"
<div
class="contents"
>
<div
class="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0"
>
<svg
class="lucide lucide-play w-3.5 h-3.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="flex min-w-0 flex-1 items-center gap-2"
class="flex min-h-0 flex-1 flex-col gap-3"
data-testid="console-body"
>
<div
class="flex shrink-0 items-center gap-2"
class="flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
data-testid="pane-no-run"
>
<h1
class="text-base 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>
<p>
No session in this pane. Start one from the workspace's prompt box.
</p>
</div>
<p
class="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block"
>
Streamed output, multi-turn, same hooks and analytics as your terminal.
</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="flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
data-testid="pane-no-run"
>
<p>
No session in this pane. Start one from the workspace's prompt box.
</p>
</div>
</div>
</div>
@@ -407,7 +407,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
},
run: {
list: r({ runs: [], items: [] }),
history: r({ items: [] }),
binary: r({ found: true, path: "/usr/bin/claude" }),
cwds: r({ items: [] }),
files: r({ items: [] }),