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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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í",
|
||||
|
||||
@@ -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
@@ -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: [] }),
|
||||
|
||||
@@ -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: [] }),
|
||||
|
||||
Reference in New Issue
Block a user