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
|
* (`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
|
* 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
|
* 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>
|
* @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 { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Plus, Settings2, SquareTerminal, X } from "lucide-react";
|
import { Plus, Settings2, SquareTerminal, X } from "lucide-react";
|
||||||
@@ -28,6 +30,7 @@ export function ConsoleTabs({
|
|||||||
activeRunId,
|
activeRunId,
|
||||||
onOpen,
|
onOpen,
|
||||||
onClosed,
|
onClosed,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
lane: Lane;
|
lane: Lane;
|
||||||
/** The workspace's live runs, in slot order. */
|
/** The workspace's live runs, in slot order. */
|
||||||
@@ -36,6 +39,8 @@ export function ConsoleTabs({
|
|||||||
onOpen: (runId: string) => void;
|
onOpen: (runId: string) => void;
|
||||||
/** A tab's session was stopped. */
|
/** A tab's session was stopped. */
|
||||||
onClosed: (runId: string) => void;
|
onClosed: (runId: string) => void;
|
||||||
|
/** Right end of the bar (the workspace's Tools menu). */
|
||||||
|
actions?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -91,22 +96,23 @@ export function ConsoleTabs({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const item =
|
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 (
|
return (
|
||||||
<div className="flex shrink-0 items-center gap-1 border-b border-border">
|
<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-end gap-0.5">
|
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-stretch">
|
||||||
{tabs.map((r) => {
|
{tabs.map((r) => {
|
||||||
const active = r.id === activeRunId;
|
const active = r.id === activeRunId;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={r.id}
|
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
|
active
|
||||||
? "border-border bg-surface-2 text-fg-primary"
|
? "bg-surface-0 text-fg-primary"
|
||||||
: "border-transparent text-fg-muted hover:bg-surface-2/60 hover:text-fg-secondary"
|
: "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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -114,12 +120,12 @@ export function ConsoleTabs({
|
|||||||
data-testid={`console-tab-${r.slot ?? 0}`}
|
data-testid={`console-tab-${r.slot ?? 0}`}
|
||||||
onClick={() => onOpen(r.id)}
|
onClick={() => onOpen(r.id)}
|
||||||
title={[r.model, r.promptPreview].filter(Boolean).join(" · ") || undefined}
|
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" ? (
|
{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>
|
<span className="truncate">{label(r)}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -129,15 +135,17 @@ export function ConsoleTabs({
|
|||||||
onClick={() => void close(r.id)}
|
onClick={() => void close(r.id)}
|
||||||
aria-label={t("tabs.close")}
|
aria-label={t("tabs.close")}
|
||||||
title={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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div ref={menuRef} className="relative">
|
<div ref={menuRef} className="relative flex items-center px-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="console-tab-new"
|
data-testid="console-tab-new"
|
||||||
@@ -147,7 +155,7 @@ export function ConsoleTabs({
|
|||||||
aria-expanded={menuOpen}
|
aria-expanded={menuOpen}
|
||||||
aria-label={t("tabs.new")}
|
aria-label={t("tabs.new")}
|
||||||
title={slot === null ? t("tabs.full") : 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" />
|
<Plus className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -155,7 +163,7 @@ export function ConsoleTabs({
|
|||||||
<div
|
<div
|
||||||
role="menu"
|
role="menu"
|
||||||
data-testid="console-tab-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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -166,7 +174,7 @@ export function ConsoleTabs({
|
|||||||
<SquareTerminal className="h-4 w-4" />
|
<SquareTerminal className="h-4 w-4" />
|
||||||
{t("tabs.newTerminal")}
|
{t("tabs.newTerminal")}
|
||||||
</button>
|
</button>
|
||||||
<div className="my-1 border-t border-border" />
|
<div className="my-1.5 border-t border-border" />
|
||||||
{toolIds.map((id) => {
|
{toolIds.map((id) => {
|
||||||
const model = aiTools.byId(id)?.defaultModel;
|
const model = aiTools.byId(id)?.defaultModel;
|
||||||
return (
|
return (
|
||||||
@@ -191,11 +199,11 @@ export function ConsoleTabs({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="my-1 border-t border-border" />
|
<div className="my-1.5 border-t border-border" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className={item}
|
className={`${item} !text-fg-muted`}
|
||||||
onClick={() => navigate("/settings/agents")}
|
onClick={() => navigate("/settings/agents")}
|
||||||
>
|
>
|
||||||
<Settings2 className="h-4 w-4" />
|
<Settings2 className="h-4 w-4" />
|
||||||
@@ -205,10 +213,11 @@ export function ConsoleTabs({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{actions && <div className="ml-auto flex items-center pr-2">{actions}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,40 @@
|
|||||||
/**
|
/**
|
||||||
* @file LaneConsolePane.tsx
|
* @file LaneConsolePane.tsx
|
||||||
* @description One lane's run console: a TerminalView on the pane's tmux
|
* @description One console tab's body: a TerminalView on the tab's tmux
|
||||||
* session, or — with no run — an empty pane whose button goes back to the
|
* session (`agenthub-lane-<id>[-<slot>]`), or — with no run — an empty pane
|
||||||
* workspace home, where a session starts with a prompt (its prompt box,
|
* whose button goes back to the workspace home, where a session starts with a
|
||||||
* `WorkspaceHome.tsx`; there is no run form in a pane — the tab bar's `+`,
|
* prompt (a pane has no run form; the tab bar's `+`, `ConsoleTabs.tsx`, starts
|
||||||
* `ConsoleTabs.tsx`, starts a bare CLI or a shell). Moved out of
|
* a bare CLI or a shell). No header of its own: the tab bar is the console's
|
||||||
* Workspace.tsx so the Workspace page can keep one per TAB (slot 0–3) mounted,
|
* header. The Workspace page keeps one per tab (slot 0–3) mounted, the
|
||||||
* the inactive ones hidden with CSS. Owns its own handle/busy/runHistory state —
|
* inactive ones hidden with CSS.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* That state is bound to the lane the pane currently shows: switching `laneId`
|
* The pane is bound to the lane it shows: switching `laneId` swaps it to the
|
||||||
* swaps the whole pane over to the new lane — its history
|
* new lane's own live session for its slot, or an empty pane — never the
|
||||||
* and its live tmux session — instead of leaving the previous lane's terminal
|
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
|
||||||
* on screen under a new lane's header.
|
* tab) attaches to that tmux session only; nothing is started or resumed.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { useTranslation } from "react-i18next";
|
||||||
import { Play, AlertCircle, Plus } from "lucide-react";
|
import { AlertCircle, Plus } from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import type { DashboardRunHistoryItem, RunHandle, RunListResponse } from "../../lib/api";
|
import type { RunHandle, RunListResponse } from "../../lib/api";
|
||||||
import type { Session, Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
import { TerminalView } from "./TerminalView";
|
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 {
|
export interface LaneConsolePaneProps {
|
||||||
lanes: Lane[];
|
lanes: Lane[];
|
||||||
laneId: number | null;
|
laneId: number | null;
|
||||||
showLaneSelector: boolean;
|
|
||||||
onLaneIdChange: (id: number) => void;
|
|
||||||
onLaneCreated: (lane: Lane) => void;
|
|
||||||
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
|
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
|
||||||
activeRuns: RunListResponse | 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;
|
onHasActiveRunChange?: (active: boolean) => void;
|
||||||
/** Which tab this is. Every tab shows the SAME lane, each in its own
|
/** Which tab this is: slot 0 is the lane's own session, 1–3
|
||||||
* tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane-<id>-<slot>`. */
|
* `agenthub-lane-<id>-<slot>`. */
|
||||||
slot?: number;
|
slot?: number;
|
||||||
/** Runs the OTHER panes are showing — left out of this pane's Active runs so
|
/** A live run to show in this pane (a session picked in the sidebar or a
|
||||||
* one terminal is never attached twice side by side. */
|
* tab). It attaches to that tmux session only. */
|
||||||
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. */
|
|
||||||
attachRequest?: { runId: string; nonce: string } | null;
|
attachRequest?: { runId: string; nonce: string } | null;
|
||||||
/** Opens the workspace home, where a new session is started. */
|
/** Opens the workspace home, where a new session is started. */
|
||||||
onNewSession?: () => void;
|
onNewSession?: () => void;
|
||||||
@@ -83,7 +42,7 @@ export interface LaneConsolePaneProps {
|
|||||||
onRunExit?: () => void;
|
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 {
|
function paneRunId(laneId: number, slot: number): string {
|
||||||
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
|
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
|
||||||
}
|
}
|
||||||
@@ -91,106 +50,48 @@ function paneRunId(laneId: number, slot: number): string {
|
|||||||
export function LaneConsolePane({
|
export function LaneConsolePane({
|
||||||
lanes,
|
lanes,
|
||||||
laneId,
|
laneId,
|
||||||
showLaneSelector,
|
|
||||||
onLaneIdChange,
|
|
||||||
onLaneCreated,
|
|
||||||
binaryStatus,
|
binaryStatus,
|
||||||
activeRuns,
|
activeRuns,
|
||||||
externalSessions,
|
|
||||||
wsConnected,
|
|
||||||
onHasActiveRunChange,
|
onHasActiveRunChange,
|
||||||
slot = 0,
|
slot = 0,
|
||||||
hiddenRunIds,
|
|
||||||
onHandleChange,
|
|
||||||
attachRequest,
|
attachRequest,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onRunExit,
|
onRunExit,
|
||||||
}: LaneConsolePaneProps) {
|
}: LaneConsolePaneProps) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const { t: tLanes } = useTranslation("lanes");
|
|
||||||
const { t: tCommon } = useTranslation("common");
|
|
||||||
|
|
||||||
const [handle, setHandle] = useState<RunHandle | null>(null);
|
const [handle, setHandle] = useState<RunHandle | null>(null);
|
||||||
const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
|
|
||||||
|
|
||||||
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
|
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();
|
const aiTools = useAiTools();
|
||||||
// The tool of the run on screen — a session's own, which need not be the
|
// The tool of the run on screen — a session's own, which need not be the
|
||||||
// workspace's; the workspace's with no run.
|
// workspace's; the workspace's with no run.
|
||||||
const paneTool = handle?.tool ?? laneTool;
|
const paneTool = handle?.tool ?? currentLane?.tool ?? "claude";
|
||||||
const toolLabel = aiTools.label(paneTool);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onHasActiveRunChange?.(handle !== null);
|
onHasActiveRunChange?.(handle !== null);
|
||||||
}, [handle, onHasActiveRunChange]);
|
}, [handle, onHasActiveRunChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
// `activeRuns` is re-fetched every few seconds by the page, so reading it
|
||||||
onHandleChange?.(handle?.id ?? null);
|
// through a ref keeps the lane-switch effect below off its identity.
|
||||||
}, [handle, onHandleChange]);
|
const latest = useRef(activeRuns);
|
||||||
|
latest.current = activeRuns;
|
||||||
|
|
||||||
const refreshList = useCallback(() => {
|
// A pane's terminal belongs to the lane it shows, so switching lanes swaps
|
||||||
if (laneId !== null) {
|
// it — to the new lane's own live session for this slot, if it has one.
|
||||||
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.
|
|
||||||
const autoAttachedForLane = useRef<number | null>(null);
|
const autoAttachedForLane = useRef<number | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { activeRuns: runs } = latest.current;
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setBusy(null);
|
|
||||||
const own = laneId === null ? null : paneRunId(laneId, slot);
|
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;
|
autoAttachedForLane.current = null;
|
||||||
refreshList();
|
}, [laneId, slot]);
|
||||||
}, [laneId, slot, refreshList]);
|
|
||||||
|
|
||||||
// Runs after the lane-switch effect above, so a sidebar pick that changes
|
// 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
|
// 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,
|
// `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`
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!attachRequest || laneId === null) return;
|
if (!attachRequest || laneId === null) return;
|
||||||
if (!new RegExp(`^agenthub-lane-${laneId}(-[1-3])?$`).test(attachRequest.runId)) 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [attachRequest?.nonce, laneId]);
|
}, [attachRequest?.nonce, laneId]);
|
||||||
|
|
||||||
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
|
// The switch effect only sees whatever `activeRuns` the page had loaded at
|
||||||
// slash command (or the codex stage-checklist prose) is sent as soon as the
|
// that instant — null right after a remount. Re-check once the poll lands,
|
||||||
// run starts, which is an extra prompt nobody asked for. The prompt is the
|
// once per lane.
|
||||||
// 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".
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (handle || laneId === null) return;
|
if (handle || laneId === null) return;
|
||||||
if (autoAttachedForLane.current === laneId) return;
|
if (autoAttachedForLane.current === laneId) return;
|
||||||
@@ -237,229 +129,16 @@ export function LaneConsolePane({
|
|||||||
}
|
}
|
||||||
}, [activeRuns, laneId, slot, handle]);
|
}, [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 (
|
return (
|
||||||
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
|
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-3">
|
||||||
{showLaneSelector && (
|
{/* Not probed yet (a tool the page has not probed, or a shell) is not "missing". */}
|
||||||
<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". */}
|
|
||||||
{binaryStatus?.[paneTool] && !binaryStatus[paneTool].found && (
|
{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">
|
<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" />
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{t("binary.missing", {
|
{t("binary.missing", {
|
||||||
cli: aiTools.byId(paneTool)?.command ?? paneTool,
|
cli: aiTools.byId(paneTool)?.command ?? paneTool,
|
||||||
tool: toolLabel,
|
tool: aiTools.label(paneTool),
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -488,23 +167,16 @@ export function LaneConsolePane({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
<TerminalView
|
<TerminalView
|
||||||
runId={handle!.id}
|
runId={handle.id}
|
||||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
// 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)
|
// 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.
|
// rather than leaving a dead terminal the user has to click out of.
|
||||||
onExit={() => {
|
onExit={() => {
|
||||||
newRun();
|
setHandle(null);
|
||||||
refreshList();
|
|
||||||
onRunExit?.();
|
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>
|
||||||
)}
|
)}
|
||||||
</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
|
* @file LaneConsolePane.test.tsx
|
||||||
* @description Test suite for the LaneConsolePane component: a pane shows its
|
* @description Test suite for the LaneConsolePane component: a tab's body shows
|
||||||
* lane's terminal or an empty state whose New session goes to the workspace
|
* its lane's terminal or an empty state whose New session goes to the
|
||||||
* home — a pane has no run form of its own.
|
* workspace home — no run form and no header of its own.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -38,7 +38,6 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
},
|
},
|
||||||
run: {
|
run: {
|
||||||
list: vi.fn().mockResolvedValue({ items: [] }),
|
list: vi.fn().mockResolvedValue({ items: [] }),
|
||||||
history: vi.fn().mockResolvedValue({ items: [] }),
|
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
start: vi.fn(),
|
start: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -85,16 +84,12 @@ function baseProps() {
|
|||||||
return {
|
return {
|
||||||
lanes: [LANE],
|
lanes: [LANE],
|
||||||
laneId: 1,
|
laneId: 1,
|
||||||
showLaneSelector: false,
|
|
||||||
onLaneIdChange: vi.fn(),
|
|
||||||
onLaneCreated: vi.fn(),
|
|
||||||
binaryStatus: {
|
binaryStatus: {
|
||||||
claude: { found: true, path: "/usr/local/bin/claude" },
|
claude: { found: true, path: "/usr/local/bin/claude" },
|
||||||
codex: { found: true, path: "/usr/local/bin/codex" },
|
codex: { found: true, path: "/usr/local/bin/codex" },
|
||||||
antigravity: { found: true, path: "/usr/local/bin/agy" },
|
antigravity: { found: true, path: "/usr/local/bin/agy" },
|
||||||
},
|
},
|
||||||
activeRuns: { items: [] },
|
activeRuns: { items: [] },
|
||||||
wsConnected: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +108,7 @@ describe("LaneConsolePane", () => {
|
|||||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
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 = {
|
const live = {
|
||||||
id: "agenthub-lane-1",
|
id: "agenthub-lane-1",
|
||||||
laneId: 1,
|
laneId: 1,
|
||||||
@@ -128,22 +123,19 @@ describe("LaneConsolePane", () => {
|
|||||||
promptPreview: null,
|
promptPreview: null,
|
||||||
tool: "claude" as const,
|
tool: "claude" as const,
|
||||||
};
|
};
|
||||||
const onNewSession = vi.fn();
|
const onRunExit = vi.fn();
|
||||||
render(
|
render(
|
||||||
<LaneConsolePane
|
<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} onRunExit={onRunExit} />
|
||||||
{...baseProps()}
|
|
||||||
activeRuns={{ items: [live] }}
|
|
||||||
onNewSession={onNewSession}
|
|
||||||
onRunExit={onNewSession}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
await screen.findByTestId("terminal-view");
|
await screen.findByTestId("terminal-view");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "New run" }));
|
expect(screen.queryByRole("heading")).toBeNull();
|
||||||
expect(onNewSession).toHaveBeenCalledTimes(1);
|
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"));
|
fireEvent.click(screen.getByTestId("fake-pane-exit"));
|
||||||
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
|
||||||
expect(onNewSession).toHaveBeenCalledTimes(2);
|
expect(onRunExit).toHaveBeenCalledOnce();
|
||||||
expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
|
expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,138 +177,12 @@ describe("LaneConsolePane", () => {
|
|||||||
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a lane dropdown only when showLaneSelector is true", () => {
|
it("renders the console body when laneId is null (fresh install)", () => {
|
||||||
const { rerender } = render(<LaneConsolePane {...baseProps()} showLaneSelector />);
|
render(<LaneConsolePane {...baseProps()} laneId={null} />);
|
||||||
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} />);
|
|
||||||
expect(screen.getByTestId("console-body")).toBeInTheDocument();
|
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 () => {
|
describe("tabs on one lane", () => {
|
||||||
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", () => {
|
|
||||||
const run = (id: string, promptPreview = id) => ({
|
const run = (id: string, promptPreview = id) => ({
|
||||||
id,
|
id,
|
||||||
laneId: 1,
|
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 () => {
|
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.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
|
||||||
"runtime.stop": "■ down",
|
"runtime.stop": "■ down",
|
||||||
"runtime.toggleTitle": "Boot or stop this workspace's own application stack (separate from its Claude run)",
|
"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.failed": "failed",
|
||||||
"status.idle": "idle",
|
"status.idle": "idle",
|
||||||
"status.provisioning": "provisioning",
|
"status.provisioning": "provisioning",
|
||||||
"status.running": "running",
|
"status.running": "running",
|
||||||
"statusDead": "DEAD",
|
|
||||||
"title": "Workspaces",
|
"title": "Workspaces",
|
||||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
|
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
|
||||||
"worktrees.heading_one": "{{count}} worktree",
|
"worktrees.heading_one": "{{count}} worktree",
|
||||||
@@ -388,7 +384,6 @@
|
|||||||
"home.remove": "Remove",
|
"home.remove": "Remove",
|
||||||
"home.missing": "missing",
|
"home.missing": "missing",
|
||||||
"home.unsupported": "{{tool}} cannot take instructions, context files or extra folders: they are kept, but its sessions start without them.",
|
"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",
|
"home.close": "Close",
|
||||||
"tabs.label": "Session tabs",
|
"tabs.label": "Session tabs",
|
||||||
"tabs.new": "New tab",
|
"tabs.new": "New tab",
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
{
|
{
|
||||||
"emptyPane": "No session in this pane. Start one from the workspace's prompt box.",
|
"emptyPane": "No session in this pane. Start one from the workspace's prompt box.",
|
||||||
"actions": {
|
"actions": {
|
||||||
"newRun": "New run",
|
|
||||||
"newSession": "New session",
|
"newSession": "New session",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"sending": "Sending…",
|
"sending": "Sending…",
|
||||||
"start": "Run",
|
"start": "Run",
|
||||||
"starting": "Starting…",
|
"starting": "Starting…",
|
||||||
"stop": "Stop",
|
"stop": "Stop",
|
||||||
"stopping": "Stopping…",
|
"stopping": "Stopping…"
|
||||||
"viewSession": "View session"
|
|
||||||
},
|
},
|
||||||
"autocomplete": {
|
"autocomplete": {
|
||||||
"fileHint": "Type @ to reference a file",
|
"fileHint": "Type @ to reference a file",
|
||||||
@@ -36,15 +34,11 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"attachFailed": "Couldn't attach to run: {{message}}",
|
"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",
|
"cwdRequired": "Working directory required to start a run",
|
||||||
"killFailed": "Couldn't stop: {{message}}",
|
|
||||||
"laneCreateFailed": "Failed to create workspace: {{message}}",
|
"laneCreateFailed": "Failed to create workspace: {{message}}",
|
||||||
"noLaneSelected": "No workspace selected or could not create one",
|
"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}}",
|
"runStartedButNotAttached": "Run is live but couldn't attach to console: {{message}}",
|
||||||
"sendFailed": "Couldn't send: {{message}}",
|
"sendFailed": "Couldn't send: {{message}}"
|
||||||
"startFailed": "Couldn't start run: {{message}}"
|
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"claude": "Claude",
|
"claude": "Claude",
|
||||||
@@ -110,9 +104,6 @@
|
|||||||
"sandboxWarning": "Codex will run commands with no sandbox.",
|
"sandboxWarning": "Codex will run commands with no sandbox.",
|
||||||
"shortcut": "Enter to send · Shift+Enter for newline"
|
"shortcut": "Enter to send · Shift+Enter for newline"
|
||||||
},
|
},
|
||||||
"limitations": {
|
|
||||||
"dismiss": "Got it"
|
|
||||||
},
|
|
||||||
"mode": {
|
"mode": {
|
||||||
"conversation": "Conversation",
|
"conversation": "Conversation",
|
||||||
"conversationHint": "Multi-turn - keep typing follow-ups while the agent works.",
|
"conversationHint": "Multi-turn - keep typing follow-ups while the agent works.",
|
||||||
@@ -131,32 +122,9 @@
|
|||||||
"pickSession": "Pick a session…",
|
"pickSession": "Pick a session…",
|
||||||
"preview": "Last activity",
|
"preview": "Last activity",
|
||||||
"resumeHint": "Pick a session from your history and continue the conversation. Cwd is locked to the original.",
|
"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…",
|
"search": "Search by ID, cwd, or status…",
|
||||||
"selectedBadge": "Resuming"
|
"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": {
|
"statusHeader": {
|
||||||
"cwd": "Cwd",
|
"cwd": "Cwd",
|
||||||
"duration": "Elapsed",
|
"duration": "Elapsed",
|
||||||
@@ -166,8 +134,6 @@
|
|||||||
"permissionMode": "Mode",
|
"permissionMode": "Mode",
|
||||||
"session": "Session"
|
"session": "Session"
|
||||||
},
|
},
|
||||||
"subtitle": "Streamed output, multi-turn, same hooks and analytics as your terminal.",
|
|
||||||
"title": "Run {{tool}}",
|
|
||||||
"tokens": {
|
"tokens": {
|
||||||
"cacheRead": "Cache hit",
|
"cacheRead": "Cache hit",
|
||||||
"cost": "Cost",
|
"cost": "Cost",
|
||||||
|
|||||||
@@ -226,14 +226,10 @@
|
|||||||
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
|
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
|
||||||
"runtime.stop": "■ dừng",
|
"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ó)",
|
"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.failed": "thất bại",
|
||||||
"status.idle": "rảnh",
|
"status.idle": "rảnh",
|
||||||
"status.provisioning": "đang khởi tạo",
|
"status.provisioning": "đang khởi tạo",
|
||||||
"status.running": "đang chạy",
|
"status.running": "đang chạy",
|
||||||
"statusDead": "ĐÃ CHẾT",
|
|
||||||
"title": "Workspace",
|
"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",
|
"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",
|
"worktrees.heading_one": "{{count}} worktree",
|
||||||
@@ -388,7 +384,6 @@
|
|||||||
"home.remove": "Gỡ",
|
"home.remove": "Gỡ",
|
||||||
"home.missing": "không còn",
|
"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.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",
|
"home.close": "Đóng",
|
||||||
"tabs.label": "Các tab phiên",
|
"tabs.label": "Các tab phiên",
|
||||||
"tabs.new": "Tab mới",
|
"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.",
|
"emptyPane": "Pane này chưa có phiên nào. Hãy tạo phiên từ ô prompt của workspace.",
|
||||||
"actions": {
|
"actions": {
|
||||||
"newRun": "Lượt chạy mới",
|
|
||||||
"newSession": "Phiên mới",
|
"newSession": "Phiên mới",
|
||||||
"send": "Gửi",
|
"send": "Gửi",
|
||||||
"sending": "Đang gửi…",
|
"sending": "Đang gửi…",
|
||||||
"start": "Chạy",
|
"start": "Chạy",
|
||||||
"starting": "Đang khởi động…",
|
"starting": "Đang khởi động…",
|
||||||
"stop": "Dừng",
|
"stop": "Dừng",
|
||||||
"stopping": "Đang dừng…",
|
"stopping": "Đang dừng…"
|
||||||
"viewSession": "Xem phiên"
|
|
||||||
},
|
},
|
||||||
"autocomplete": {
|
"autocomplete": {
|
||||||
"fileHint": "Gõ @ để tham chiếu file",
|
"fileHint": "Gõ @ để tham chiếu file",
|
||||||
@@ -35,15 +33,11 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"attachFailed": "Không gắn được vào run: {{message}}",
|
"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",
|
"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}}",
|
"laneCreateFailed": "Không thể tạo workspace: {{message}}",
|
||||||
"noLaneSelected": "Chưa chọn workspace hoặc không thể tạo workspace",
|
"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}}",
|
"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}}",
|
"sendFailed": "Không gửi được: {{message}}"
|
||||||
"startFailed": "Không khởi động được: {{message}}"
|
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"claude": "Claude",
|
"claude": "Claude",
|
||||||
@@ -109,9 +103,6 @@
|
|||||||
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.",
|
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.",
|
||||||
"shortcut": "Enter để gửi · Shift+Enter xuống dòng"
|
"shortcut": "Enter để gửi · Shift+Enter xuống dòng"
|
||||||
},
|
},
|
||||||
"limitations": {
|
|
||||||
"dismiss": "Đã rõ"
|
|
||||||
},
|
|
||||||
"mode": {
|
"mode": {
|
||||||
"conversation": "Hội thoại",
|
"conversation": "Hội thoại",
|
||||||
"conversationHint": "Đa lượt - tiếp tục gõ trong khi agent xử lý.",
|
"conversationHint": "Đa lượt - tiếp tục gõ trong khi agent xử lý.",
|
||||||
@@ -130,32 +121,9 @@
|
|||||||
"pickSession": "Chọn phiên…",
|
"pickSession": "Chọn phiên…",
|
||||||
"preview": "Hoạt động gần nhất",
|
"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.",
|
"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…",
|
"search": "Tìm theo ID, cwd hoặc status…",
|
||||||
"selectedBadge": "Đang tiếp tục"
|
"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": {
|
"statusHeader": {
|
||||||
"cwd": "Cwd",
|
"cwd": "Cwd",
|
||||||
"duration": "Đã chạy",
|
"duration": "Đã chạy",
|
||||||
@@ -165,8 +133,6 @@
|
|||||||
"permissionMode": "Mode",
|
"permissionMode": "Mode",
|
||||||
"session": "Session"
|
"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": {
|
"tokens": {
|
||||||
"cacheRead": "Cache hit",
|
"cacheRead": "Cache hit",
|
||||||
"cost": "Chi phí",
|
"cost": "Chi phí",
|
||||||
|
|||||||
@@ -119,7 +119,6 @@
|
|||||||
* - `RunStartArgs` — exported API; see TSDoc on the symbol for behavior.
|
* - `RunStartArgs` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `RunHandle` — 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.
|
* - `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.
|
* - `CwdSuggestion` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `ModelChoice` — 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.
|
* - `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.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* 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**
|
* **CwdSuggestion**
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
@@ -2109,12 +2103,6 @@ export const api = {
|
|||||||
run: {
|
run: {
|
||||||
/** GET /api/run - lanes with a live tmux-backed run, computed fresh from tmux state. */
|
/** GET /api/run - lanes with a live tmux-backed run, computed fresh from tmux state. */
|
||||||
list: () => request<RunListResponse>("/run"),
|
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). */
|
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
|
||||||
binary: (tool: LaneTool = "claude") =>
|
binary: (tool: LaneTool = "claude") =>
|
||||||
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
|
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
|
||||||
@@ -3960,32 +3948,6 @@ export interface RunListResponse {
|
|||||||
items: RunHandle[];
|
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. */
|
/** One suggested working directory for the Run page's cwd picker. */
|
||||||
export interface CwdSuggestion {
|
export interface CwdSuggestion {
|
||||||
/** "dashboard" = this server's own cwd; "home" = user's home dir; "recent"
|
/** "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
|
* (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
|
* 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).
|
* `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
|
* tab per live tmux slot, 0–3, with a `+` menu — `ConsoleTabs.tsx`), and the
|
||||||
* right panel —
|
* right panel —
|
||||||
* Files · Changes · Sessions. Runs are tied to lanes: starting a run
|
* 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
|
* 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
|
* isn't owned by any lane, calls POST /api/lanes/ensure first.
|
||||||
* /api/sessions?status=active alongside the run list so Claude Code sessions
|
|
||||||
* started outside the dashboard also appear in the active-runs list.
|
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -29,9 +27,8 @@
|
|||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api` — REST client including lanes and run endpoints.
|
* - `../lib/api` — REST client including lanes and run endpoints.
|
||||||
* - `../lib/types` — Lane, Lane Counts, run handles, etc.
|
* - `../lib/types` — Lane, Lane Counts, run handles, etc.
|
||||||
* - `../lib/eventBus` — WebSocket subscription.
|
|
||||||
* - `../components/lanes/` — LaneCard, PipelineMap, DestructiveLaneModal.
|
* - `../components/lanes/` — LaneCard, PipelineMap, DestructiveLaneModal.
|
||||||
* - `../components/run/` — RunConsole, RunSetup, RunHistory, ActiveRunsSwitcher.
|
* - `../components/run/` — ConsoleTabs, LaneConsolePane.
|
||||||
*
|
*
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `Workspace` — merged page; see TSDoc on the symbol for behavior.
|
* - `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 { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
|
||||||
Plus,
|
Plus,
|
||||||
Columns2,
|
Columns2,
|
||||||
Layers,
|
Layers,
|
||||||
@@ -61,14 +57,13 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
import type { Lane, LaneFeature, ProofFeature } from "../lib/types";
|
||||||
import { eventBus } from "../lib/eventBus";
|
|
||||||
import { keepIfSame } from "../lib/keepIfSame";
|
import { keepIfSame } from "../lib/keepIfSame";
|
||||||
import { useLanes } from "../hooks/useLanes";
|
import { useLanes } from "../hooks/useLanes";
|
||||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||||
import { ConsoleTabs } from "../components/run/ConsoleTabs";
|
import { ConsoleTabs } from "../components/run/ConsoleTabs";
|
||||||
import PipelineMap from "../components/lanes/PipelineMap";
|
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 { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||||
@@ -129,11 +124,7 @@ function ConsoleArea({
|
|||||||
selectedLaneId,
|
selectedLaneId,
|
||||||
binaryStatus,
|
binaryStatus,
|
||||||
activeRuns,
|
activeRuns,
|
||||||
externalSessions,
|
|
||||||
wsConnected,
|
|
||||||
onHasActiveRunChange,
|
onHasActiveRunChange,
|
||||||
onLaneCreated,
|
|
||||||
onLaneIdChange,
|
|
||||||
attachRequest,
|
attachRequest,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onOpenRun,
|
onOpenRun,
|
||||||
@@ -143,11 +134,7 @@ function ConsoleArea({
|
|||||||
selectedLaneId: number | null;
|
selectedLaneId: number | null;
|
||||||
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
|
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
|
||||||
activeRuns: RunListResponse | null;
|
activeRuns: RunListResponse | null;
|
||||||
externalSessions: Session[];
|
|
||||||
wsConnected: boolean;
|
|
||||||
onHasActiveRunChange: (val: boolean) => void;
|
onHasActiveRunChange: (val: boolean) => void;
|
||||||
onLaneCreated: (lane: Lane) => void;
|
|
||||||
onLaneIdChange: (id: number | null) => void;
|
|
||||||
/** The opened run (`?run=`): its tab is the active one. */
|
/** The opened run (`?run=`): its tab is the active one. */
|
||||||
attachRequest: { runId: string; nonce: string } | null;
|
attachRequest: { runId: string; nonce: string } | null;
|
||||||
/** Back to the workspace home, where a new session is started. */
|
/** Back to the workspace home, where a new session is started. */
|
||||||
@@ -175,7 +162,7 @@ function ConsoleArea({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
{lane && (
|
{lane && (
|
||||||
<ConsoleTabs
|
<ConsoleTabs
|
||||||
lane={lane}
|
lane={lane}
|
||||||
@@ -189,39 +176,39 @@ function ConsoleArea({
|
|||||||
onRunsChanged();
|
onRunsChanged();
|
||||||
if (slotOf(runId) === activeSlot) leave(activeSlot);
|
if (slotOf(runId) === activeSlot) leave(activeSlot);
|
||||||
}}
|
}}
|
||||||
|
// Per-workspace actions (mcp sync, sync-base check, readme).
|
||||||
|
actions={<LaneToolsMenu lane={lane} />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{slots.map((slot) => (
|
<div
|
||||||
<div key={slot} className={slot === activeSlot ? "contents" : "hidden"}>
|
className={`flex min-h-0 flex-1 flex-col border border-border bg-surface-0 p-2 ${
|
||||||
<LaneConsolePane
|
lane ? "rounded-b-lg" : "rounded-lg"
|
||||||
lanes={lanes}
|
}`}
|
||||||
laneId={selectedLaneId}
|
>
|
||||||
slot={slot}
|
{slots.map((slot) => (
|
||||||
// The other tabs' runs: never attached twice.
|
<div key={slot} className={slot === activeSlot ? "contents" : "hidden"}>
|
||||||
hiddenRunIds={tabs.filter((r) => (r.slot ?? 0) !== slot).map((r) => r.id)}
|
<LaneConsolePane
|
||||||
showLaneSelector={false}
|
lanes={lanes}
|
||||||
onLaneIdChange={onLaneIdChange}
|
laneId={selectedLaneId}
|
||||||
onLaneCreated={onLaneCreated}
|
slot={slot}
|
||||||
binaryStatus={binaryStatus}
|
binaryStatus={binaryStatus}
|
||||||
activeRuns={activeRuns}
|
activeRuns={activeRuns}
|
||||||
externalSessions={externalSessions}
|
onHasActiveRunChange={slot === activeSlot ? onHasActiveRunChange : undefined}
|
||||||
wsConnected={wsConnected}
|
onNewSession={onNewSession}
|
||||||
onHasActiveRunChange={slot === activeSlot ? onHasActiveRunChange : undefined}
|
onRunExit={() => slot === activeSlot && leave(slot)}
|
||||||
onNewSession={onNewSession}
|
attachRequest={
|
||||||
onRunExit={() => slot === activeSlot && leave(slot)}
|
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
|
||||||
attachRequest={
|
}
|
||||||
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
|
/>
|
||||||
}
|
</div>
|
||||||
/>
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Workspace() {
|
export function Workspace() {
|
||||||
const { t: tLanes } = useTranslation("lanes");
|
const { t: tLanes } = useTranslation("lanes");
|
||||||
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
// Lane state: the list is the shared store the sidebar reads too; the
|
// 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
|
// Run state kept at page level: shared across every pane, or drives the
|
||||||
// lane strip itself rather than any one pane's form.
|
// lane strip itself rather than any one pane's form.
|
||||||
const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
|
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<
|
const [binaryStatus, setBinaryStatus] = useState<Record<
|
||||||
string,
|
string,
|
||||||
{ found: boolean; path: string | null }
|
{ found: boolean; path: string | null }
|
||||||
@@ -403,7 +386,7 @@ export function Workspace() {
|
|||||||
.catch(() => undefined);
|
.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
|
// fresh-but-identical payload would otherwise re-render the lane strip, the
|
||||||
// lane card and the whole console area on every tick for nothing.
|
// lane card and the whole console area on every tick for nothing.
|
||||||
const refreshList = useCallback(() => {
|
const refreshList = useCallback(() => {
|
||||||
@@ -411,12 +394,6 @@ export function Workspace() {
|
|||||||
.list()
|
.list()
|
||||||
.then((r) => setActiveRuns(keepIfSame(r)))
|
.then((r) => setActiveRuns(keepIfSame(r)))
|
||||||
.catch(() => undefined);
|
.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
|
// Background poll so the run list and history reflect external changes
|
||||||
@@ -652,84 +629,48 @@ export function Workspace() {
|
|||||||
currentLane && changesOpen && changesSize === "full" ? " hidden" : ""
|
currentLane && changesOpen && changesSize === "full" ? " hidden" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* One header row, not two. The pipeline picker, the stage badges,
|
{/* No header row: the console's tab bar is the header. Only a
|
||||||
the feature picker and the router select used to sit on a second
|
workspace with a pipeline template (dormant: none ships) keeps
|
||||||
row below this one; every one of them is a per-lane control that
|
its time-at-stage, pipeline and feature pickers here. */}
|
||||||
belongs beside the lane's own name, and stacking them cost a whole
|
{currentLane && !noPipeline && (
|
||||||
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 && (
|
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
<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="shrink-0 tabular-nums text-xs text-fg-muted">
|
||||||
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
|
{since(currentLane.stage_seconds)}
|
||||||
{currentLane.liveness === "dead"
|
|
||||||
? tLanes("statusDead")
|
|
||||||
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
|
||||||
</span>
|
</span>
|
||||||
{/* Back to the workspace's home; renaming lives on the sidebar
|
<select
|
||||||
row and the home's title, so here the name is only read. */}
|
data-testid="pipeline-picker"
|
||||||
<button
|
aria-label={tLanes("pipelinePicker.label")}
|
||||||
type="button"
|
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||||
data-testid="workspace-back-home"
|
value={currentLane.pipeline}
|
||||||
onClick={() => setSearchParams({ lane: String(currentLane.id) })}
|
disabled={!!viewedFeature}
|
||||||
title={tLanes("home.back")}
|
title={
|
||||||
aria-label={tLanes("home.back")}
|
viewedFeature
|
||||||
className="shrink-0 rounded p-0.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
{(pipelineTemplates.length
|
||||||
</button>
|
? pipelineTemplates
|
||||||
<span
|
: [
|
||||||
title={currentLane.cwd}
|
{
|
||||||
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
|
id: currentLane.pipeline,
|
||||||
>
|
name: currentLane.pipeline_name,
|
||||||
{currentLane.title || currentLane.cwd}
|
nodes: [],
|
||||||
</span>
|
},
|
||||||
{/* A lane with no pipeline (non-claude, or no template
|
]
|
||||||
installed): no time-at-stage, no pipeline or feature
|
).map((p) => (
|
||||||
picker, no map. */}
|
<option key={p.id} value={p.id}>
|
||||||
{!noPipeline && (
|
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||||
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
</option>
|
||||||
{since(currentLane.stage_seconds)}
|
))}
|
||||||
</span>
|
</select>
|
||||||
)}
|
|
||||||
{!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>
|
|
||||||
)}
|
|
||||||
{/* No stage chip and no "auto: <stage>" chip here: the
|
{/* No stage chip and no "auto: <stage>" chip here: the
|
||||||
PipelineMap right below this header already draws both
|
PipelineMap right below this header already draws both
|
||||||
— the declared node as the current ring, the detected
|
— the declared node as the current ring, the detected
|
||||||
one as its amber badge — so the chips were a second,
|
one as its amber badge — so the chips were a second,
|
||||||
smaller copy of the same two facts. */}
|
smaller copy of the same two facts. */}
|
||||||
{!noPipeline && features.length > 0 && (
|
{features.length > 0 && (
|
||||||
<select
|
<select
|
||||||
data-testid="feature-picker"
|
data-testid="feature-picker"
|
||||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
||||||
@@ -745,11 +686,6 @@ export function Workspace() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentLane && (
|
{currentLane && (
|
||||||
@@ -823,11 +759,7 @@ export function Workspace() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||||
className={`flex min-h-0 flex-1 flex-col gap-2 ${
|
|
||||||
currentLane ? "border-t border-border pt-3" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{replaying && (
|
{replaying && (
|
||||||
<SessionReplay
|
<SessionReplay
|
||||||
lane={currentLane}
|
lane={currentLane}
|
||||||
@@ -846,11 +778,7 @@ export function Workspace() {
|
|||||||
selectedLaneId={selectedLaneId}
|
selectedLaneId={selectedLaneId}
|
||||||
binaryStatus={binaryStatus}
|
binaryStatus={binaryStatus}
|
||||||
activeRuns={activeRuns}
|
activeRuns={activeRuns}
|
||||||
externalSessions={externalSessions}
|
|
||||||
wsConnected={wsConnected}
|
|
||||||
onHasActiveRunChange={setPaneHasActiveRun}
|
onHasActiveRunChange={setPaneHasActiveRun}
|
||||||
onLaneCreated={() => void refreshLanes()}
|
|
||||||
onLaneIdChange={setSelectedLaneId}
|
|
||||||
attachRequest={attachRequest}
|
attachRequest={attachRequest}
|
||||||
onNewSession={
|
onNewSession={
|
||||||
currentLane
|
currentLane
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
api: {
|
api: {
|
||||||
run: {
|
run: {
|
||||||
list: r({ items: [] }),
|
list: r({ items: [] }),
|
||||||
history: r({ items: [] }),
|
|
||||||
binary: r({ found: true, path: "/usr/bin/claude" }),
|
binary: r({ found: true, path: "/usr/bin/claude" }),
|
||||||
cwds: r({ items: [HOME] }),
|
cwds: r({ items: [HOME] }),
|
||||||
files: r({ items: [] }),
|
files: r({ items: [] }),
|
||||||
|
|||||||
@@ -173,13 +173,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
recordCall("GET", "/api/run/list");
|
recordCall("GET", "/api/run/list");
|
||||||
return { runs: [], items: [] };
|
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 () => {
|
binary: vi.fn().mockImplementation(async () => {
|
||||||
recordCall("GET", "/api/run/binary");
|
recordCall("GET", "/api/run/binary");
|
||||||
return { found: true, path: "/usr/bin/claude" };
|
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");
|
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(
|
render(
|
||||||
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
|
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
|
||||||
<Workspace />
|
<Workspace />
|
||||||
@@ -622,8 +615,10 @@ describe("Workspace — home and session view", () => {
|
|||||||
await settle();
|
await settle();
|
||||||
expect(screen.queryByTestId("workspace-home")).toBeNull();
|
expect(screen.queryByTestId("workspace-home")).toBeNull();
|
||||||
expect(screen.getByTestId("lane-detail").parentElement).not.toHaveClass("hidden");
|
expect(screen.getByTestId("lane-detail").parentElement).not.toHaveClass("hidden");
|
||||||
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
|
// No header row (status, back arrow, name) and no pane header: the tab bar is it.
|
||||||
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
|
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 () => {
|
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"
|
data-testid="lane-detail"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex min-h-0 flex-1 flex-col gap-2 "
|
class="flex min-h-0 flex-1 flex-col gap-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="contents"
|
class="contents"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="contents"
|
class="flex min-h-0 flex-1 flex-col"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex min-h-0 flex-1 flex-col gap-5"
|
class="flex min-h-0 flex-1 flex-col border border-border bg-surface-0 p-2 rounded-lg"
|
||||||
data-testid="console-body"
|
|
||||||
>
|
>
|
||||||
<header
|
<div
|
||||||
class="flex items-center gap-2.5"
|
class="contents"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0"
|
class="flex min-h-0 flex-1 flex-col gap-3"
|
||||||
>
|
data-testid="console-body"
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<p>
|
||||||
class="text-base font-semibold text-fg-primary"
|
No session in this pane. Start one from the workspace's prompt box.
|
||||||
>
|
</p>
|
||||||
Run Claude
|
|
||||||
</h1>
|
|
||||||
<span
|
|
||||||
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
|
|
||||||
/>
|
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -407,7 +407,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
},
|
},
|
||||||
run: {
|
run: {
|
||||||
list: r({ runs: [], items: [] }),
|
list: r({ runs: [], items: [] }),
|
||||||
history: r({ items: [] }),
|
|
||||||
binary: r({ found: true, path: "/usr/bin/claude" }),
|
binary: r({ found: true, path: "/usr/bin/claude" }),
|
||||||
cwds: r({ items: [] }),
|
cwds: r({ items: [] }),
|
||||||
files: r({ items: [] }),
|
files: r({ items: [] }),
|
||||||
|
|||||||
+5
-19
@@ -395,39 +395,25 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
|||||||
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
||||||
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
||||||
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
|
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
|
||||||
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Files / Changes panel, with that run's tab active; the header's back arrow returns to the home. A browser notification for a workspace with a live pane opens it here directly.
|
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Files / Changes panel, with that run's tab active; the sidebar's workspace row returns to the home. A browser notification for a workspace with a live pane opens it here directly.
|
||||||
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
|
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
|
||||||
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||||
- **Reordering** — drag a row onto another, or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
|
- **Reordering** — drag a row onto another, or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
|
||||||
- **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
|
- **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
|
||||||
- **Workspace-detail header row** — one row, and every per-workspace control lives on it: liveness + status, the workspace's name (read-only here — renaming is on the sidebar row; no "Workspace 23 ·" prefix — the id is on the row), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (time-on-stage and both pickers only while a pipeline template is installed; there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (mcp sync / sync-base check / create-update readme, hidden when the workspace declares no runtime profile). The detail block under it (the workspace card, the PipelineMap, the proof gallery) is always shown — there is no info toggle any more — and Delete lives in the sidebar row menu. Tools sits here rather than in Settings because every one of its actions runs against one workspace's `cwd` — in Settings it would need a workspace picker just to say which. **Create/update readme file** (Claude workspaces only — Codex has no slash commands) starts the workspace's terminal run with `/sg-all:sg-md-writing` as its first message, so the workspace opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-workspace modal: creating a workspace and writing its docs are separate decisions, and the second is worth re-running long after the first.
|
- **No header row** — the session view has no header of its own: the console's tab bar is it (status, the workspace's name and the back arrow are gone — the name is on the sidebar row and the home). Only a workspace with an installed pipeline template shows its time-on-stage, pipeline picker and feature picker above the console; there are no stage chips — the declared stage and the `auto: <stage>` detection both render in the PipelineMap. The **Tools** menu (mcp sync / sync-base check / create-update readme, hidden when the workspace declares no runtime profile) sits at the right end of the tab bar. The detail block (the workspace card, the PipelineMap, the proof gallery) is always shown, and Delete lives in the sidebar row menu. Tools sits on the workspace rather than in Settings because every one of its actions runs against one workspace's `cwd`. **Create/update readme file** (Claude workspaces only — Codex has no slash commands) starts the workspace's terminal run with `/sg-all:sg-md-writing` as its first message, so the workspace opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command.
|
||||||
- **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states").
|
- **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states").
|
||||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. **New run** under the terminal goes back to the home; when the active tab's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) the next open tab is shown, or the home when none is left; a tab with nothing to show offers **New session**, which opens the home too.
|
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when the active tab's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) the next open tab is shown, or the home when none is left; a tab with nothing to show offers **New session**, which opens the home too.
|
||||||
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
|
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
|
||||||
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`), one tab per live tmux session of the workspace — at most four, its slots 0–3: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–3 are `agenthub-lane-<id>-<slot>`. A tab shows its AI tool's icon and name (or **Terminal**); the active tab is the URL's `?run=`, so the Changes panel and Analyst follow it. Every tab with a live run stays mounted and the inactive ones are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops its session (`DELETE /api/run/<runId>`) and moves to the next tab, or the home. The **+** menu opens a new tab in the first free slot: **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With four tabs open **+** is disabled. EVERY tab is bound to the sidebar's selection, and a tab's **Active runs** leaves out the other tabs' runs, so one terminal is never attached twice. The 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone.
|
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`), one tab per live tmux session of the workspace — at most four, its slots 0–3: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–3 are `agenthub-lane-<id>-<slot>`. A tab shows its AI tool's icon and name (or **Terminal**); the active tab is the URL's `?run=`, so the Changes panel and Analyst follow it. Every tab with a live run stays mounted and the inactive ones are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops its session (`DELETE /api/run/<runId>`) and moves to the next tab, or the home. The **+** menu opens a new tab in the first free slot: **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With four tabs open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. The 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone.
|
||||||
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
|
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
|
||||||
|
|
||||||
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
|
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
|
||||||
|
|
||||||
**A console pane follows the workspace it shows.** Selecting another workspace on the workspace list swaps every pane's cwd, run history and terminal over to the new workspace. If the new workspace already has a live session for that pane's slot in `GET /api/run`, the pane re-attaches to it immediately, so each pane sticks to its own `agenthub-lane-<id>[-<slot>]` tmux session; if it has none, the pane is empty. Nothing of the previous workspace (a half-typed prompt, its terminal) carries over.
|
**A console pane follows the workspace it shows.** Selecting another workspace on the workspace list swaps every pane's cwd, run history and terminal over to the new workspace. If the new workspace already has a live session for that pane's slot in `GET /api/run`, the pane re-attaches to it immediately, so each pane sticks to its own `agenthub-lane-<id>[-<slot>]` tmux session; if it has none, the pane is empty. Nothing of the previous workspace (a half-typed prompt, its terminal) carries over.
|
||||||
|
|
||||||
### Active runs list
|
|
||||||
|
|
||||||
The **Active runs** button in the console header opens the merged run list. It shows three sources in one place, newest first:
|
|
||||||
|
|
||||||
1. live in-memory tmux runs from `GET /api/run`,
|
|
||||||
2. persisted dashboard runs from `GET /api/run/history`,
|
|
||||||
3. Claude Code sessions running **outside** the dashboard — `GET /api/sessions?status=active`, i.e. a `claude` the user started by hand in a terminal tab. These carry an amber `external` badge, and the button's live count includes the ones its workspace owns (next paragraph), so two hand-started agents in the workspace's folder read as "2 active runs".
|
|
||||||
|
|
||||||
An external session is deduped against a dashboard run with the same `session_id`, and sessions from a remote data source (`source !== "local"`) or without a `cwd` are skipped — neither can be resumed on this machine.
|
|
||||||
|
|
||||||
External rows have **no Attach action**: the dashboard owns no tmux session for them, so there is no PTY to bridge. Their action is **Resume**, which does what resuming from history does — `POST /api/lanes/ensure` for the session's `cwd`, then `POST /api/lanes/:id/start` with `resumeSessionId` — spawning a *new* tmux-backed `claude --resume <session>` in that folder. The original terminal keeps running; resuming gives you a second Claude Code process on the same transcript, not a view of the first one.
|
|
||||||
|
|
||||||
Live, dashboard-spawned rows (not external — there is no tmux session to kill for those) also carry a **Kill** button, `DELETE /api/run/:id` behind a two-click confirm. This is how you free a workspace stuck on a previous live session when you actually want it gone, rather than attached to.
|
|
||||||
|
|
||||||
**Start and Resume are create-or-reuse, and never silently swallow the request.** When the workspace's `agenthub-lane-<id>` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `agenthub lanes shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `agenthub lanes shell` answered `200` while doing nothing at all.
|
**Start and Resume are create-or-reuse, and never silently swallow the request.** When the workspace's `agenthub-lane-<id>` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `agenthub lanes shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `agenthub lanes shell` answered `200` while doing nothing at all.
|
||||||
|
|
||||||
**A workspace whose `run_id` still points at a live run refuses a second Start/Resume outright** — `POST /api/lanes/:id/start` answers `409 ERUNLIVE` rather than typing a new prompt or `--resume` over a session that is still in flight. The console does not surface that raw error: `LaneConsolePane` catches it, re-reads the workspace, and attaches to the run it is already holding instead — with a notice explaining that the previous session is still live, so nothing new started. Killing that session (Active runs list → **Kill**, two clicks, the same confirm convention as discarding a file in the Changes panel) is what actually frees the workspace for a fresh Start or a different Resume; the kill goes straight through `DELETE /api/run/:id`, and the workspace's own `run_id`/`status` self-heal to idle the next time anything reads the workspace (`healRunId` in `routes/lanes.js`), so no separate workspace-side call is needed.
|
**A workspace whose `run_id` still points at a live run refuses a second Start/Resume outright** — `POST /api/lanes/:id/start` answers `409 ERUNLIVE` rather than typing a new prompt or `--resume` over a session that is still in flight. The dashboard starts a session only in a slot with no live run (the home, a resume and the tab bar's `+` all take the first free one), and a tab's **×** is what frees a slot (`DELETE /api/run/:id`); the workspace's own `run_id`/`status` self-heal to idle the next time anything reads the workspace (`healRunId` in `routes/lanes.js`), so no separate workspace-side call is needed.
|
||||||
|
|
||||||
The UI operates on a working directory (`cwd`), not a workspace id. Starting a run in a `cwd` that no workspace owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a workspace for that path; a `cwd` an existing workspace already owns is matched from the loaded workspace list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`.
|
The UI operates on a working directory (`cwd`), not a workspace id. Starting a run in a `cwd` that no workspace owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a workspace for that path; a `cwd` an existing workspace already owns is matched from the loaded workspace list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user