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

The status/name/back-arrow row is gone; Tools sits at the tab bar's right
end and the pipeline/feature pickers show only with a template.
LaneConsolePane is reduced to the terminal (no title, no Active runs, no
New run): RunHistory.tsx, api.run.history and the page's active-sessions
poll are removed.
This commit is contained in:
2026-10-03 12:13:11 +07:00
parent 4df944c6aa
commit b7efba8747
17 changed files with 177 additions and 1941 deletions
+69 -141
View File
@@ -4,14 +4,12 @@
* (CLI spawns: Claude Code or Codex, determined per lane). The workspace shown
* is picked in the sidebar's project tree and named by `?lane=<id>` (mirrored to
* `agenthub.workspace.selected`, so a bare /workspace reopens the last one).
* Shows the selected lane's one-row header, pipeline map and run console (a
* Shows the selected lane's pipeline map (when a template is installed) and run console (a
* tab per live tmux slot, 0–3, with a `+` menu — `ConsoleTabs.tsx`), and the
* right panel —
* Files · Changes · Sessions. Runs are tied to lanes: starting a run
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
* isn't owned by any lane, calls POST /api/lanes/ensure first. Polls GET
* /api/sessions?status=active alongside the run list so Claude Code sessions
* started outside the dashboard also appear in the active-runs list.
* isn't owned by any lane, calls POST /api/lanes/ensure first.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -29,9 +27,8 @@
* ## Internal dependencies
* - `../lib/api` — REST client including lanes and run endpoints.
* - `../lib/types` — Lane, Lane Counts, run handles, etc.
* - `../lib/eventBus` — WebSocket subscription.
* - `../components/lanes/` — LaneCard, PipelineMap, DestructiveLaneModal.
* - `../components/run/` — RunConsole, RunSetup, RunHistory, ActiveRunsSwitcher.
* - `../components/run/` — ConsoleTabs, LaneConsolePane.
*
* ## Public surface
* - `Workspace` — merged page; see TSDoc on the symbol for behavior.
@@ -44,11 +41,10 @@
*
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Plus,
Columns2,
Layers,
@@ -61,14 +57,13 @@ import {
} from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { eventBus } from "../lib/eventBus";
import type { Lane, LaneFeature, ProofFeature } from "../lib/types";
import { keepIfSame } from "../lib/keepIfSame";
import { useLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import { ConsoleTabs } from "../components/run/ConsoleTabs";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard";
import LaneCard, { since, LaneToolsMenu } from "../components/lanes/LaneCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
@@ -129,11 +124,7 @@ function ConsoleArea({
selectedLaneId,
binaryStatus,
activeRuns,
externalSessions,
wsConnected,
onHasActiveRunChange,
onLaneCreated,
onLaneIdChange,
attachRequest,
onNewSession,
onOpenRun,
@@ -143,11 +134,7 @@ function ConsoleArea({
selectedLaneId: number | null;
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
activeRuns: RunListResponse | null;
externalSessions: Session[];
wsConnected: boolean;
onHasActiveRunChange: (val: boolean) => void;
onLaneCreated: (lane: Lane) => void;
onLaneIdChange: (id: number | null) => void;
/** The opened run (`?run=`): its tab is the active one. */
attachRequest: { runId: string; nonce: string } | null;
/** Back to the workspace home, where a new session is started. */
@@ -175,7 +162,7 @@ function ConsoleArea({
};
return (
<>
<div className="flex min-h-0 flex-1 flex-col">
{lane && (
<ConsoleTabs
lane={lane}
@@ -189,39 +176,39 @@ function ConsoleArea({
onRunsChanged();
if (slotOf(runId) === activeSlot) leave(activeSlot);
}}
// Per-workspace actions (mcp sync, sync-base check, readme).
actions={<LaneToolsMenu lane={lane} />}
/>
)}
{slots.map((slot) => (
<div key={slot} className={slot === activeSlot ? "contents" : "hidden"}>
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
slot={slot}
// The other tabs' runs: never attached twice.
hiddenRunIds={tabs.filter((r) => (r.slot ?? 0) !== slot).map((r) => r.id)}
showLaneSelector={false}
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
onHasActiveRunChange={slot === activeSlot ? onHasActiveRunChange : undefined}
onNewSession={onNewSession}
onRunExit={() => slot === activeSlot && leave(slot)}
attachRequest={
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
}
/>
</div>
))}
</>
<div
className={`flex min-h-0 flex-1 flex-col border border-border bg-surface-0 p-2 ${
lane ? "rounded-b-lg" : "rounded-lg"
}`}
>
{slots.map((slot) => (
<div key={slot} className={slot === activeSlot ? "contents" : "hidden"}>
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
slot={slot}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
onHasActiveRunChange={slot === activeSlot ? onHasActiveRunChange : undefined}
onNewSession={onNewSession}
onRunExit={() => slot === activeSlot && leave(slot)}
attachRequest={
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
}
/>
</div>
))}
</div>
</div>
);
}
export function Workspace() {
const { t: tLanes } = useTranslation("lanes");
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
const [searchParams, setSearchParams] = useSearchParams();
// Lane state: the list is the shared store the sidebar reads too; the
@@ -304,10 +291,6 @@ export function Workspace() {
// Run state kept at page level: shared across every pane, or drives the
// lane strip itself rather than any one pane's form.
const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
// Claude Code sessions running outside the dashboard (a `claude` the user
// started in a terminal tab). They are not dashboard runs, so the run list
// would otherwise show nothing while two agents are working.
const [externalSessions, setExternalSessions] = useState<Session[]>([]);
const [binaryStatus, setBinaryStatus] = useState<Record<
string,
{ found: boolean; path: string | null }
@@ -403,7 +386,7 @@ export function Workspace() {
.catch(() => undefined);
}, []);
// Both setters go through `keepIfSame`: these fire every 5 s below, and a
// Goes through `keepIfSame`: this fires every 5 s below, and a
// fresh-but-identical payload would otherwise re-render the lane strip, the
// lane card and the whole console area on every tick for nothing.
const refreshList = useCallback(() => {
@@ -411,12 +394,6 @@ export function Workspace() {
.list()
.then((r) => setActiveRuns(keepIfSame(r)))
.catch(() => undefined);
api.sessions
// Attach/resume go through `claude`, so a Codex session is not offered
// (filtered by the server, before its limit).
.list({ status: "active", limit: 50, sort_by: "updated_at", sort_desc: true, tool: "claude" })
.then((r) => setExternalSessions(keepIfSame(r.sessions)))
.catch(() => undefined);
}, []);
// Background poll so the run list and history reflect external changes
@@ -652,84 +629,48 @@ export function Workspace() {
currentLane && changesOpen && changesSize === "full" ? " hidden" : ""
}`}
>
{/* One header row, not two. The pipeline picker, the stage badges,
the feature picker and the router select used to sit on a second
row below this one; every one of them is a per-lane control that
belongs beside the lane's own name, and stacking them cost a whole
row of height on a panel whose point is the pipeline map further
down. The pipeline's NAME is no longer interpolated into
`laneHeader` either — the picker right next to it already says it.
They stay gated on `showInfo`, same as before the merge. */}
{currentLane && (
{/* No header row: the console's tab bar is the header. Only a
workspace with a pipeline template (dormant: none ships) keeps
its time-at-stage, pipeline and feature pickers here. */}
{currentLane && !noPipeline && (
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
{currentLane.liveness === "dead"
? tLanes("statusDead")
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{since(currentLane.stage_seconds)}
</span>
{/* Back to the workspace's home; renaming lives on the sidebar
row and the home's title, so here the name is only read. */}
<button
type="button"
data-testid="workspace-back-home"
onClick={() => setSearchParams({ lane: String(currentLane.id) })}
title={tLanes("home.back")}
aria-label={tLanes("home.back")}
className="shrink-0 rounded p-0.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
<select
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
value={currentLane.pipeline}
disabled={!!viewedFeature}
title={
viewedFeature
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
: undefined
}
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
>
<ArrowLeft className="h-4 w-4" />
</button>
<span
title={currentLane.cwd}
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
>
{currentLane.title || currentLane.cwd}
</span>
{/* A lane with no pipeline (non-claude, or no template
installed): no time-at-stage, no pipeline or feature
picker, no map. */}
{!noPipeline && (
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{since(currentLane.stage_seconds)}
</span>
)}
{!noPipeline && (
<select
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
value={currentLane.pipeline}
disabled={!!viewedFeature}
title={
viewedFeature
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
: undefined
}
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
>
{(pipelineTemplates.length
? pipelineTemplates
: [
{
id: currentLane.pipeline,
name: currentLane.pipeline_name,
nodes: [],
},
]
).map((p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
))}
</select>
)}
{(pipelineTemplates.length
? pipelineTemplates
: [
{
id: currentLane.pipeline,
name: currentLane.pipeline_name,
nodes: [],
},
]
).map((p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
))}
</select>
{/* No stage chip and no "auto: <stage>" chip here: the
PipelineMap right below this header already draws both
— the declared node as the current ring, the detected
one as its amber badge — so the chips were a second,
smaller copy of the same two facts. */}
{!noPipeline && features.length > 0 && (
{features.length > 0 && (
<select
data-testid="feature-picker"
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
@@ -745,11 +686,6 @@ export function Workspace() {
))}
</select>
)}
<div className="ml-auto flex items-center gap-2">
{/* Per-lane actions (mcp sync, sync-base check)
belong beside the lane they act on, not in a page header. */}
<LaneToolsMenu lane={currentLane} />
</div>
</div>
)}
{currentLane && (
@@ -823,11 +759,7 @@ export function Workspace() {
)}
</div>
)}
<div
className={`flex min-h-0 flex-1 flex-col gap-2 ${
currentLane ? "border-t border-border pt-3" : ""
}`}
>
<div className="flex min-h-0 flex-1 flex-col gap-2">
{replaying && (
<SessionReplay
lane={currentLane}
@@ -846,11 +778,7 @@ export function Workspace() {
selectedLaneId={selectedLaneId}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={() => void refreshLanes()}
onLaneIdChange={setSelectedLaneId}
attachRequest={attachRequest}
onNewSession={
currentLane
@@ -69,7 +69,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
api: {
run: {
list: r({ items: [] }),
history: r({ items: [] }),
binary: r({ found: true, path: "/usr/bin/claude" }),
cwds: r({ items: [HOME] }),
files: r({ items: [] }),
+5 -10
View File
@@ -173,13 +173,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
recordCall("GET", "/api/run/list");
return { runs: [], items: [] };
}),
history: vi.fn().mockImplementation(async (limit?: number, opts?: { laneId?: number }) => {
recordCall(
"GET",
`/api/run/history?limit=${limit}${opts?.laneId ? `&laneId=${opts.laneId}` : ""}`
);
return { items: [] };
}),
binary: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/binary");
return { found: true, path: "/usr/bin/claude" };
@@ -612,7 +605,7 @@ describe("Workspace — home and session view", () => {
expect(screen.getByTestId("lane-detail").parentElement).toHaveClass("hidden");
});
it("opens the session view for ?run=, and the back button returns home", async () => {
it("opens the session view for ?run=, headed by the tab bar alone", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
@@ -622,8 +615,10 @@ describe("Workspace — home and session view", () => {
await settle();
expect(screen.queryByTestId("workspace-home")).toBeNull();
expect(screen.getByTestId("lane-detail").parentElement).not.toHaveClass("hidden");
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
// No header row (status, back arrow, name) and no pane header: the tab bar is it.
expect(screen.queryByTestId("workspace-back-home")).toBeNull();
expect(screen.queryByText(/^Run /)).toBeNull();
expect(screen.getByTestId("console-tab-new")).toBeInTheDocument();
});
it("opens an ended session's replay for ?session=, hiding (not unmounting) the console", async () => {
@@ -5404,112 +5404,33 @@ exports[`screen snapshots > Run 1`] = `
data-testid="lane-detail"
>
<div
class="flex min-h-0 flex-1 flex-col gap-2 "
class="flex min-h-0 flex-1 flex-col gap-2"
>
<div
class="contents"
>
<div
class="contents"
class="flex min-h-0 flex-1 flex-col"
>
<div
class="flex min-h-0 flex-1 flex-col gap-5"
data-testid="console-body"
class="flex min-h-0 flex-1 flex-col border border-border bg-surface-0 p-2 rounded-lg"
>
<header
class="flex items-center gap-2.5"
<div
class="contents"
>
<div
class="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0"
>
<svg
class="lucide lucide-play w-3.5 h-3.5 text-accent"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="6 3 20 12 6 21 6 3"
/>
</svg>
</div>
<div
class="flex min-w-0 flex-1 items-center gap-2"
class="flex min-h-0 flex-1 flex-col gap-3"
data-testid="console-body"
>
<div
class="flex shrink-0 items-center gap-2"
class="flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
data-testid="pane-no-run"
>
<h1
class="text-base font-semibold text-fg-primary"
>
Run Claude
</h1>
<span
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
>
<span
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
/>
Live
</span>
<p>
No session in this pane. Start one from the workspace's prompt box.
</p>
</div>
<p
class="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block"
>
Streamed output, multi-turn, same hooks and analytics as your terminal.
</p>
</div>
<button
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
disabled=""
>
<svg
class="lucide lucide-list-ordered w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 12h11"
/>
<path
d="M10 18h11"
/>
<path
d="M10 6h11"
/>
<path
d="M4 10h2"
/>
<path
d="M4 6h1v4"
/>
<path
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
/>
</svg>
Active runs
</button>
</header>
<div
class="flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
data-testid="pane-no-run"
>
<p>
No session in this pane. Start one from the workspace's prompt box.
</p>
</div>
</div>
</div>
@@ -407,7 +407,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
},
run: {
list: r({ runs: [], items: [] }),
history: r({ items: [] }),
binary: r({ found: true, path: "/usr/bin/claude" }),
cwds: r({ items: [] }),
files: r({ items: [] }),