feat(renderer): full-width shell topbar; retire per-view topbars and review dashboard (#195)

* feat(renderer): full-width shell topbar; retire per-view topbars and review dashboard

The shell now owns a single full-width ShellTopbar (status pill, history
arrows, kanban/inspector toggles) with the sidebar pinned below it,
replacing the per-view Topbar/DashboardTopbar pair; board pages get a
lightweight DashboardSubhead. The standalone review dashboard and its
/review(s) routes are removed — review state lives on the PR board.
Approved divergence from the AO reference (full-height sidebar) recorded
in DESIGN.md; macOS traffic lights re-centered on the 56px header row.

Also hardens the session view around rrp v4:
- inspector defaultSize re-derived per panel mount (orchestrator → worker
  navigation kept SessionView mounted while the panel remounted), and the
  imperative expand/collapse effect no longer races panel registration
- onResize writes gated on data-separator="active" so flex-grow
  transition frames can't bounce the store (dead-looking toggle button)
- findProjectOrchestrator skips terminated orchestrators so the topbar
  offers Spawn instead of attaching to a dead zellij session
- inspector resize handle gets a visible 1px divider at rest
- playwright specs for history arrows + inspector toggle; test-results/
  gitignored

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
yyovil 2026-06-12 15:20:04 +05:30 committed by GitHub
parent 7c97ee79cd
commit 09c16e5bf8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 620 additions and 680 deletions

3
.gitignore vendored
View File

@ -53,3 +53,6 @@ Thumbs.db
# electron-forge / vite build output
.vite/
dist-electron/
# playwright artifacts
frontend/test-results/

View File

@ -41,6 +41,12 @@ resizable`, react-resizable-panels v4 `collapsible` panel + imperative API,
content keeps a stable min-width (yyork-style, no mid-animation reflow). Toggled
by a `PanelRight` icon button in the session topbar and ⌘⇧B; open state + split
width persist. The AO reference keeps the rail always visible.
- **Approved divergence (2026-06-12):** the shell topbar spans the full window
width and the sidebar is pinned below it (`top-14`), so the sidebar's right
border stops at the header instead of cutting through the macOS traffic-light
strip (user-requested). The AO reference keeps a full-height sidebar with the
header beside it. On macOS the header always pads past the lights + TitlebarNav
cluster (`.is-under-titlebar-nav`, 180px).
## Product Context

View File

@ -0,0 +1,25 @@
import { expect, test } from "@playwright/test";
// Repro for the titlebar history arrows: navigate home → project → back,
// then the forward arrow must be enabled and actually traverse forward.
test("titlebar back/forward arrows traverse history", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("Projects")).toBeVisible();
// Navigate: home → session view (in-app push).
await page.getByRole("button", { name: "Open refactor-mux" }).click();
await expect(page).toHaveURL(/sessions\/refactor-mux/);
const back = page.getByRole("button", { name: "Go back" });
const forward = page.getByRole("button", { name: "Go forward" });
await expect(forward).toBeDisabled();
await expect(back).toBeEnabled();
await back.click();
await expect(page).not.toHaveURL(/sessions\/refactor-mux/);
await expect(forward).toBeEnabled();
await forward.click();
await expect(page).toHaveURL(/sessions\/refactor-mux/);
});

View File

@ -0,0 +1,26 @@
import { expect, test } from "@playwright/test";
// Regression for the dead inspector toggle: rrp v4 derives panel sizes from
// the observed DOM layout, so the flex-grow transition animating an
// imperative expand()/collapse() fired onResize with transient sizes.
// SessionView mirrored every onResize into the ui-store, so a mid-collapse
// frame read as "dragged back open" and re-expanded the panel — the topbar
// button did nothing visible — and a mount-time 0-size event flipped fresh
// profiles to collapsed. Only real separator drags may write back; this needs
// the real rrp + CSS pipeline, which the mocked unit tests can't exercise.
test("topbar button collapses and reopens the inspector rail", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Open refactor-mux" }).click();
await expect(page).toHaveURL(/sessions\/refactor-mux/);
// Fresh profile: the rail must mount open, not get toggled shut by
// mount-time layout events.
const inspector = page.locator("#inspector");
await expect(inspector).toBeVisible();
await page.getByRole("button", { name: "Close inspector panel" }).click();
await expect(inspector).toBeHidden();
await page.getByRole("button", { name: "Open inspector panel" }).click();
await expect(inspector).toBeVisible();
});

View File

@ -88,7 +88,11 @@ function createWindow(): void {
title: "Agent Orchestrator",
backgroundColor: "#0f1014",
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 14, y: 14 },
// Lights visually centered at y=28 — the 56px topbar/.titlebar-nav center
// line — so lights + nav cluster + header content share one row. macOS
// draws the 12pt disc 2pt below the given y (measured: center = y + 8),
// hence 20, not 22.
trafficLightPosition: { x: 14, y: 20 },
webPreferences: {
preload: preloadPath(),
contextIsolation: true,

View File

@ -0,0 +1,11 @@
// The board subhead (mc-board .dashboard-main__subhead): a 21px bold title with
// a muted one-line subtitle, optionally a trailing count.
export function DashboardSubhead({ title, subtitle, count }: { title: string; subtitle: string; count?: number }) {
return (
<div className="flex items-baseline gap-3 px-[18px] pt-[22px]">
<h1 className="text-[21px] font-bold tracking-[-0.025em] text-foreground">{title}</h1>
{typeof count === "number" && <span className="font-mono text-[13px] text-passive">{count}</span>}
<span className="text-[12.5px] text-passive">{subtitle}</span>
</div>
);
}

View File

@ -1,139 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Waypoints } from "lucide-react";
import { useState } from "react";
import { findProjectOrchestrator } from "../types/workspace";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { useUiStore } from "../stores/ui-store";
import { cn } from "../lib/utils";
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
const dragStyle = isMac ? ({ WebkitAppRegion: "drag" } as React.CSSProperties) : undefined;
const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined;
type DashboardTab = "coding" | "reviews";
type DashboardTopbarProps = {
/** Which top-nav tab reads as active (omit on the PR board, which is neither). */
activeTab?: DashboardTab;
/** When set, the project crumb scopes to one project. */
projectId?: string;
projectLabel?: string;
};
// The dashboard header (mc-board .dashboard-app-header): project crumb · Coding/
// Reviews tabs | bell · Orchestrator (board ↔ terminal).
// Shared verbatim across the board, review, and PR screens so navigating between
// them keeps one stable top strip (agent-orchestrator surfaces them as tabs).
export function DashboardTopbar({ activeTab, projectId, projectLabel = "agent-orchestrator" }: DashboardTopbarProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isSpawning, setIsSpawning] = useState(false);
const isSidebarOpen = useUiStore((state) => state.isSidebarOpen);
const all = useWorkspaceQuery().data ?? [];
const orchestrator = projectId ? findProjectOrchestrator(all, projectId) : undefined;
const openOrchestrator = async () => {
if (!projectId) return;
if (orchestrator) {
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId: orchestrator.id },
});
return;
}
setIsSpawning(true);
try {
const sessionId = await spawnOrchestrator(projectId);
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId },
});
} catch (error) {
console.error("Failed to spawn orchestrator:", error);
} finally {
setIsSpawning(false);
}
};
return (
<header
className={cn("dashboard-app-header", isMac && !isSidebarOpen && "is-under-titlebar-nav")}
style={dragStyle}
>
<div className="session-topbar__lead">
<div className="topbar-project-line">
<span className="dashboard-app-header__project">{projectLabel}</span>
<nav aria-label="Workspace mode" className="dashboard-app-header__tabs">
<button
className={cn("dashboard-app-header__tab", activeTab === "coding" && "is-active")}
onClick={() =>
void navigate(projectId ? { to: "/projects/$projectId", params: { projectId } } : { to: "/" })
}
style={noDragStyle}
type="button"
>
Coding
</button>
<button
className={cn("dashboard-app-header__tab", activeTab === "reviews" && "is-active")}
onClick={() => void navigate({ to: "/review" })}
style={noDragStyle}
type="button"
>
Reviews
</button>
</nav>
</div>
</div>
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
{projectId ? (
orchestrator ? (
<button
aria-label="Orchestrator"
className="dashboard-app-header__primary-btn"
onClick={() =>
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId: orchestrator.id },
})
}
style={noDragStyle}
type="button"
>
<Waypoints className="h-3.5 w-3.5" aria-hidden="true" />
Orchestrator
</button>
) : (
<button
aria-label="Spawn Orchestrator"
className="dashboard-app-header__primary-btn"
disabled={isSpawning}
onClick={() => void openOrchestrator()}
style={noDragStyle}
type="button"
>
<Waypoints className="h-3.5 w-3.5" aria-hidden="true" />
{isSpawning ? "Spawning…" : "Spawn Orchestrator"}
</button>
)
) : null}
</div>
</header>
);
}
// The board subhead (mc-board .dashboard-main__subhead): a 21px bold title with
// a muted one-line subtitle, optionally a trailing count.
export function DashboardSubhead({ title, subtitle, count }: { title: string; subtitle: string; count?: number }) {
return (
<div className="flex items-baseline gap-3 px-[18px] pt-[22px]">
<h1 className="text-[21px] font-bold tracking-[-0.025em] text-foreground">{title}</h1>
{typeof count === "number" && <span className="font-mono text-[13px] text-passive">{count}</span>}
<span className="text-[12.5px] text-passive">{subtitle}</span>
</div>
);
}

View File

@ -3,7 +3,7 @@ import { useState } from "react";
import type { components } from "../../api/schema";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { DashboardSubhead, DashboardTopbar } from "./DashboardTopbar";
import { DashboardSubhead } from "./DashboardSubhead";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Label } from "./ui/label";
@ -51,7 +51,6 @@ export function ProjectSettingsForm({ projectId }: { projectId: string }) {
return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
<DashboardTopbar activeTab="coding" projectId={projectId} projectLabel={query.data.name} />
<DashboardSubhead title="Settings" subtitle={query.data.path} />
<div className="min-h-0 flex-1 overflow-y-auto p-[18px]">
<SettingsBody

View File

@ -4,7 +4,7 @@ import { useState } from "react";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import type { WorkspaceSession } from "../types/workspace";
import { DashboardSubhead, DashboardTopbar } from "./DashboardTopbar";
import { DashboardSubhead } from "./DashboardSubhead";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
@ -46,7 +46,6 @@ export function PullRequestsPage() {
return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
<DashboardTopbar />
<DashboardSubhead
title="Pull requests"
subtitle="Open PRs across every agent session, ready to resolve and merge."

View File

@ -1,171 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Play } from "lucide-react";
import { useState } from "react";
import type { components } from "../../api/schema";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery";
import { DashboardSubhead, DashboardTopbar } from "./DashboardTopbar";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardContent } from "./ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { cn } from "../lib/utils";
type ReviewRun = components["schemas"]["ReviewRun"];
const reviewsKey = ["reviews"] as const;
const statusTone: Record<string, string> = {
pending: "border-warning/40 bg-warning/10 text-warning",
complete: "border-success/40 bg-success/10 text-success",
sent: "border-accent/40 bg-accent-weak text-accent",
};
// The code-review board, ported from agent-orchestrator's ReviewDashboard onto
// the daemon's reviews API (GET/POST /api/v1/reviews). Lists review runs and
// their findings; lets you start a run for a session and send its findings.
export function ReviewDashboard() {
const queryClient = useQueryClient();
const sessions = (useWorkspaceQuery().data ?? []).flatMap((w) => w.sessions);
const [target, setTarget] = useState<string>("");
const reviews = useQuery({
queryKey: reviewsKey,
queryFn: async () => {
const { data, error } = await apiClient.GET("/api/v1/reviews");
if (error) throw new Error(apiErrorMessage(error));
return data?.reviews ?? [];
},
});
const execute = useMutation({
mutationFn: async (sessionId: string) => {
const { error } = await apiClient.POST("/api/v1/reviews/execute", { body: { sessionId } });
if (error) throw new Error(apiErrorMessage(error));
},
onSuccess: () => void queryClient.invalidateQueries({ queryKey: reviewsKey }),
});
const runs = (reviews.data ?? []).slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
<DashboardTopbar activeTab="reviews" />
<DashboardSubhead
title="Reviews"
subtitle="Code-review runs and their findings, ready to send back."
count={runs.length}
/>
<div className="flex items-center gap-2 px-[18px] pt-3">
<Select value={target} onValueChange={setTarget}>
<SelectTrigger className="h-8 w-56 text-[12px]">
<SelectValue placeholder="Select a worker…" />
</SelectTrigger>
<SelectContent>
{sessions.length === 0 ? (
<SelectItem value="__none__" disabled>
No workers
</SelectItem>
) : (
sessions.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.title}
</SelectItem>
))
)}
</SelectContent>
</Select>
<Button
size="sm"
variant="primary"
className="h-8 px-3 text-[12px]"
disabled={!target || target === "__none__" || execute.isPending}
onClick={() => execute.mutate(target)}
>
<Play className="h-3 w-3" aria-hidden="true" />
{execute.isPending ? "Starting…" : "Run review"}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-[18px]">
{reviews.isError ? (
<p className="py-10 text-center text-[12px] text-passive">Could not load reviews.</p>
) : runs.length === 0 ? (
<p className="py-10 text-center text-[12px] text-passive">
No review runs yet. Pick a worker and <span className="text-foreground">Run review</span>.
</p>
) : (
<div className="mx-auto flex max-w-2xl flex-col gap-2.5">
{runs.map((run) => (
<ReviewRunCard
key={run.id}
run={run}
sessionTitle={sessions.find((s) => s.id === run.sessionId)?.title}
/>
))}
</div>
)}
</div>
</div>
);
}
function ReviewRunCard({ run, sessionTitle }: { run: ReviewRun; sessionTitle?: string }) {
const queryClient = useQueryClient();
const send = useMutation({
mutationFn: async () => {
const { error } = await apiClient.POST("/api/v1/reviews/{id}/send", { params: { path: { id: run.id } } });
if (error) throw new Error(apiErrorMessage(error));
},
onSuccess: () => void queryClient.invalidateQueries({ queryKey: reviewsKey }),
});
return (
<Card className="gap-0 py-0">
<CardContent className="flex flex-col gap-2 p-3">
<div className="flex items-center gap-2">
<span className="font-mono text-[12px] text-muted-foreground">{run.id}</span>
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">{sessionTitle ?? run.sessionId}</span>
<Badge variant="outline" className={cn("h-5 px-1.5 text-[10px] font-medium", statusTone[run.status])}>
{run.status}
</Badge>
{run.status !== "sent" && (
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-[11px]"
disabled={send.isPending}
onClick={() => send.mutate()}
>
{send.isPending ? "Sending…" : "Send"}
</Button>
)}
</div>
{run.findings.length === 0 ? (
<p className="font-mono text-[11px] text-passive">no findings</p>
) : (
<ul className="flex flex-col gap-1">
{run.findings.map((f) => (
<li key={f.id} className="flex items-start gap-2 text-[11px]">
<span
className={cn(
"mt-0.5 font-mono",
f.severity === "error" ? "text-error" : f.severity === "warning" ? "text-warning" : "text-passive",
)}
>
{f.severity}
</span>
<span className="min-w-0 flex-1">
<span className="font-mono text-passive">
{f.path}:{f.line}
</span>{" "}
<span className="text-muted-foreground">{f.body}</span>
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}

View File

@ -42,12 +42,10 @@ const { workspaces, panels } = vi.hoisted(() => {
return { workspaces, panels: new Map<string, PanelEntry>() };
});
// The terminal, inspector body, and topbar pull in xterm/router/SSE machinery
// irrelevant to the split under test.
// The terminal and inspector body pull in xterm/SSE machinery irrelevant to
// the split under test. (The topbar is shell-owned — see ShellTopbar.)
vi.mock("./CenterPane", () => ({ CenterPane: () => <div /> }));
vi.mock("./SessionInspector", () => ({ SessionInspector: () => <div /> }));
vi.mock("./Topbar", () => ({ Topbar: () => <header /> }));
vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() }));
vi.mock("../lib/shell-context", () => ({
useShell: () => ({ daemonStatus: { state: "ready" } }),
}));
@ -60,7 +58,17 @@ vi.mock("../hooks/useWorkspaceQuery", () => ({
// fake imperative handle per panel instead.
vi.mock("./ui/resizable", () => ({
ResizablePanelGroup: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
ResizableHandle: () => <div data-testid="resize-handle" />,
ResizableHandle: ({ elementRef }: { elementRef?: Ref<HTMLDivElement | null> }) => (
<div
data-separator="inactive"
data-testid="resize-handle"
ref={(el) => {
if (elementRef && typeof elementRef === "object") {
(elementRef as { current: HTMLDivElement | null }).current = el;
}
}}
/>
),
ResizablePanel: ({
children,
id,
@ -177,6 +185,8 @@ describe("SessionView", () => {
it("syncs drag resizes back into the store and persists the split", () => {
render(<SessionView sessionId="sess-1" />);
const entry = panels.get("inspector")!;
// rrp marks the separator active for the duration of a pointer drag.
screen.getByTestId("resize-handle").setAttribute("data-separator", "active");
// Dragging past minSize collapses the panel → store follows.
act(() => entry.onResize?.({ asPercentage: 0, inPixels: 0 }));
@ -188,12 +198,56 @@ describe("SessionView", () => {
expect(window.localStorage.getItem("ao.inspector.split")).toBe("31.5");
});
// Regression: rrp v4 reports observed DOM sizes, so the flex-grow
// transition animating an imperative collapse fires onResize with transient
// non-zero sizes. Mirroring those into the store re-opened the panel
// mid-animation — the topbar toggle looked dead and a mount-time 0-size
// event flipped a fresh profile to collapsed. Only drag events (separator
// active) may write back.
it("ignores onResize churn while the separator is not being dragged", () => {
render(<SessionView sessionId="sess-1" />);
const entry = panels.get("inspector")!;
// Mount-time/layout event at 0% must not collapse the store…
act(() => entry.onResize?.({ asPercentage: 0, inPixels: 0 }));
expect(useUiStore.getState().isInspectorOpen).toBe(true);
// …and a mid-collapse transition frame must not re-open or persist.
act(() => useUiStore.getState().toggleInspector());
act(() => entry.onResize?.({ asPercentage: 12.4, inPixels: 160 }));
expect(useUiStore.getState().isInspectorOpen).toBe(false);
expect(window.localStorage.getItem("ao.inspector.split")).toBeNull();
});
it("restores the persisted split width", () => {
window.localStorage.setItem("ao.inspector.split", "40");
render(<SessionView sessionId="sess-1" />);
expect(panelSizes("inspector")[0]).toBe("40%");
});
// Regression: rrp only derives a panel's constraints one commit after it
// registers into a live group. Driving the imperative API in the commit
// where the inspector mounts (orchestrator → worker navigation; SessionView
// itself stays mounted) threw "Panel constraints not found for Panel
// inspector" and unwound the route to the error boundary. The panel must
// mount already in sync via defaultSize instead.
it("mounts the inspector in sync when navigating from an orchestrator session, without the imperative API", () => {
useUiStore.setState({ isInspectorOpen: false });
const { rerender } = render(<SessionView sessionId="sess-orch" />);
expect(screen.queryByTestId("panel-inspector")).not.toBeInTheDocument();
// Toggled open while on the orchestrator (shell topbar button) — the
// panel that mounts later must pick this up from defaultSize alone.
act(() => useUiStore.getState().toggleInspector());
rerender(<SessionView sessionId="sess-1" />);
expect(panelSizes("inspector")[0]).toMatch(/^[1-9]\d*(\.\d+)?%$/);
const handle = panels.get("inspector")!.handle;
expect(handle.expand).not.toHaveBeenCalled();
expect(handle.collapse).not.toHaveBeenCalled();
expect(handle.resize).not.toHaveBeenCalled();
});
it("renders no inspector panel or handle for orchestrator sessions", () => {
render(<SessionView sessionId="sess-orch" />);

View File

@ -1,9 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useRef } from "react";
import type { PanelImperativeHandle, PanelSize } from "react-resizable-panels";
import { CenterPane } from "./CenterPane";
import { SessionInspector } from "./SessionInspector";
import { Topbar } from "./Topbar";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable";
import { useUiStore } from "../stores/ui-store";
import { useShell } from "../lib/shell-context";
@ -23,24 +21,22 @@ function initialSplitPercent(): number {
type SessionViewProps = {
sessionId: string;
/** When entered via /projects/$projectId/sessions/... — used for the back-nav target. */
projectId?: string;
};
// The session detail screen: the persistent terminal + git rail. Rendered by
// both the project-scoped and cross-project session routes. The terminal lives
// here (not in the shell) — switching sessions only changes route params, so
// TanStack Router keeps this component mounted and the terminal re-points its
// mux without remounting (useTerminalSession). Leaving for the board unmounts
// it; the server's output ring replays on return.
// The session detail screen: the persistent terminal + git rail, under the
// shell-owned ShellTopbar. Rendered by both the project-scoped and
// cross-project session routes. The terminal lives here (not in the shell) —
// switching sessions only changes route params, so TanStack Router keeps this
// component mounted and the terminal re-points its mux without remounting
// (useTerminalSession). Leaving for the board unmounts it; a fresh server-side
// zellij attach repaints the pane on return.
//
// The split is shadcn's resizable (react-resizable-panels v4) with a fully
// collapsible inspector: the panel is `collapsible` and driven to 0% via the
// imperative API from the ui-store (Topbar button / ⌘⇧B), animated by the
// imperative API from the ui-store (topbar button / ⌘⇧B), animated by the
// flex-grow transition in styles.css. Content keeps a stable min-width inside
// the clipped panel so nothing reflows mid-animation; split width persists.
export function SessionView({ sessionId, projectId }: SessionViewProps) {
const navigate = useNavigate();
export function SessionView({ sessionId }: SessionViewProps) {
const workspaceQuery = useWorkspaceQuery();
const workspaces = workspaceQuery.data ?? [];
const { theme } = useUiStore();
@ -48,24 +44,32 @@ export function SessionView({ sessionId, projectId }: SessionViewProps) {
const toggleInspector = useUiStore((state) => state.toggleInspector);
const { daemonStatus } = useShell();
const inspectorRef = useRef<PanelImperativeHandle | null>(null);
const inspectorSeparatorRef = useRef<HTMLDivElement | null>(null);
const session = workspaces.flatMap((workspace) => workspace.sessions).find((s) => s.id === sessionId);
const isOrchestrator = session ? isOrchestratorSession(session) : false;
const workspace =
(session && workspaces.find((w) => w.id === session.workspaceId)) ??
(projectId ? workspaces.find((w) => w.id === projectId) : undefined);
// Orchestrator sessions are terminal-only; only worker sessions have the rail.
const hasInspector = !isOrchestrator;
// Frozen at mount: rrp re-registers the panel (a layout effect keyed on
// defaultSize, among others) whenever this prop's identity changes, and the
// imperative collapse()/expand() below can race that re-registration within
// the same commit — rrp then throws "Panel constraints not found for Panel
// Computed when the inspector panel mounts and frozen while it stays
// mounted: rrp re-registers the panel (a layout effect keyed on defaultSize,
// among others) whenever this prop's identity changes, and the imperative
// collapse()/expand() below can race that re-registration within the same
// commit — rrp then throws "Panel constraints not found for Panel
// inspector", which unwinds the whole route to the router's CatchBoundary
// (the toggle button looks dead and the session view is torn down).
// defaultSize only matters at first mount; afterwards the imperative API
// owns the size, so it must never track live open/closed state.
const [inspectorDefaultSize] = useState(() => (isInspectorOpen ? `${initialSplitPercent()}%` : "0%"));
// Re-derived per panel mount (not once per SessionView mount — navigating
// orchestrator → worker keeps this component mounted while the panel
// remounts) so a freshly mounted panel reflects the store on its own,
// without an imperative fix-up in the mount commit. Afterwards the
// imperative API owns the size, so this must never track live open state.
const inspectorDefaultSizeRef = useRef<string | null>(null);
if (!hasInspector) {
inspectorDefaultSizeRef.current = null;
} else if (inspectorDefaultSizeRef.current === null) {
inspectorDefaultSizeRef.current = isInspectorOpen ? `${initialSplitPercent()}%` : "0%";
}
const inspectorDefaultSize = inspectorDefaultSizeRef.current ?? "0%";
useEffect(() => {
if (!hasInspector) return;
@ -79,8 +83,14 @@ export function SessionView({ sessionId, projectId }: SessionViewProps) {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [hasInspector, toggleInspector]);
// Drive the collapsible panel from the store so the Topbar button, ⌘⇧B, and
// drag-to-collapse all stay in sync.
// Drive the collapsible panel from the store so the topbar button, ⌘⇧B, and
// drag-to-collapse all stay in sync. hasInspector must NOT be a dep: when
// the inspector panel mounts into the already-live group (orchestrator →
// worker navigation), rrp only derives the new panel's constraints in the
// next commit, so an expand()/collapse() in the mount commit throws "Panel
// constraints not found for Panel inspector" and unwinds the route. The
// panel mounts in sync via inspectorDefaultSize above; only later toggles
// need the imperative API, by which point registration has settled.
useEffect(() => {
const panel = inspectorRef.current;
if (!panel) return;
@ -92,11 +102,22 @@ export function SessionView({ sessionId, projectId }: SessionViewProps) {
} else {
panel.collapse();
}
}, [hasInspector, isInspectorOpen]);
}, [isInspectorOpen]);
// Persist drags and mirror collapse state (dragging past minSize collapses)
// back into the store. Read the store imperatively to avoid a stale closure.
// Gated on an actively dragged separator: rrp v4 derives sizes from the
// observed DOM layout, so the flex-grow transition that animates
// expand()/collapse() (styles.css) fires onResize with transient
// mid-animation sizes too. Writing those back turned the imperative
// collapse into a feedback loop — a mid-collapse size read as "dragged
// back open", re-toggled the store, and the panel bounced back (the
// topbar button looked dead). rrp marks the separator
// data-separator="active" only during a pointer drag — the same hook the
// transition-suppressing CSS keys on, so drag writes are never transition
// frames.
const handleInspectorResize = (size: PanelSize) => {
if (inspectorSeparatorRef.current?.getAttribute("data-separator") !== "active") return;
const open = useUiStore.getState().isInspectorOpen;
if (size.asPercentage > 0) {
window.localStorage?.setItem(inspectorSplitStorageKey, String(size.asPercentage));
@ -116,16 +137,6 @@ export function SessionView({ sessionId, projectId }: SessionViewProps) {
return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
<Topbar
onOpenBoard={() =>
workspace
? void navigate({ to: "/projects/$projectId", params: { projectId: workspace.id } })
: void navigate({ to: "/" })
}
projectLabel={workspace?.name}
session={session}
view={isOrchestrator ? "orchestrator" : "session"}
/>
<ResizablePanelGroup className="session-split min-h-0 flex-1" id="session-workspace" orientation="horizontal">
{/* react-resizable-panels v4: bare numbers are PIXELS; percentages must
be strings. Numeric sizes here once clamped the inspector to 45px. */}
@ -134,7 +145,10 @@ export function SessionView({ sessionId, projectId }: SessionViewProps) {
</ResizablePanel>
{hasInspector ? (
<>
<ResizableHandle className="session-inspector__resize-handle focus-visible:ring-0 focus-visible:ring-offset-0" />
<ResizableHandle
className="session-inspector__resize-handle focus-visible:ring-0 focus-visible:ring-offset-0"
elementRef={inspectorSeparatorRef}
/>
<ResizablePanel
aria-hidden={!isInspectorOpen}
collapsible

View File

@ -1,3 +1,4 @@
import { useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import {
type AttentionZone,
@ -8,7 +9,7 @@ import {
workerSessions,
} from "../types/workspace";
import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery";
import { DashboardSubhead, DashboardTopbar } from "./DashboardTopbar";
import { DashboardSubhead } from "./DashboardSubhead";
import { cn } from "../lib/utils";
type SessionsBoardProps = {
@ -76,7 +77,6 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
const all = workspaceQuery.data ?? [];
const workspaces = projectId ? all.filter((w) => w.id === projectId) : all;
const sessions = workspaces.flatMap((w) => workerSessions(w.sessions));
const projectLabel = projectId ? (workspaces[0]?.name ?? projectId) : "agent-orchestrator";
const byZone = new Map<AttentionZone, WorkspaceSession[]>();
for (const session of sessions) {
@ -84,6 +84,9 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
(byZone.get(zone) ?? byZone.set(zone, []).get(zone)!).push(session);
}
const done = byZone.get("done") ?? [];
// Collapsed by default, like agent-orchestrator's done-bar: finished and
// killed sessions cost one quiet line under the board until expanded.
const [doneExpanded, setDoneExpanded] = useState(false);
const openSession = (session: WorkspaceSession) =>
void navigate({
@ -93,7 +96,6 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
<DashboardTopbar activeTab="coding" projectId={projectId} projectLabel={projectLabel} />
<DashboardSubhead title="Board" subtitle="Live agent sessions flowing from work → review → merge." />
<div className="min-h-0 flex-1 overflow-hidden p-[18px]">
@ -109,24 +111,47 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
</div>
{done.length > 0 && (
<div className="shrink-0 border-t border-border px-[18px] py-2.5">
<div className="mb-1.5 flex items-center gap-2">
<span className="h-[7px] w-[7px] rounded-full bg-passive" />
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Done</span>
<span className="font-mono text-[11px] text-passive">{done.length}</span>
</div>
<div className="flex flex-wrap gap-2">
{done.map((s) => (
<button
key={s.id}
className="rounded-[7px] border border-border bg-surface px-2.5 py-1.5 text-left transition-colors hover:border-border-strong"
onClick={() => openSession(s)}
type="button"
>
<span className="text-[12px] text-muted-foreground">{s.title}</span>
</button>
))}
</div>
<div className="shrink-0 border-t border-border px-[18px]">
{/* agent-orchestrator's done-bar (Dashboard.tsx + globals.css):
a full-width chevron + label + count toggle row. min-h matches
the sidebar footer (7px pad ×2 + 37px Settings button) so this
border-t aligns with the sidebar's footer border. The button is
37px (not the 35.5px its text-[13px] implies) because the
unlayered `button { font: inherit }` in styles.css outranks
Tailwind's layered text utilities, leaving it at 14px/21px. */}
<button
aria-expanded={doneExpanded}
className="group flex min-h-[51px] w-full items-center gap-2 py-2 text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setDoneExpanded((v) => !v)}
type="button"
>
<svg
aria-hidden="true"
className={cn("h-3 w-3 shrink-0 transition-transform duration-150", doneExpanded && "rotate-90")}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="m9 18 6-6-6-6" />
</svg>
<span className="font-mono text-[10.5px] font-medium uppercase tracking-[0.05em]">Done / Terminated</span>
<span className="ml-auto shrink-0 font-mono text-[10px] text-passive">{done.length}</span>
</button>
{doneExpanded && (
<div className="flex flex-wrap gap-2 pb-2.5 pt-1">
{done.map((s) => (
<button
key={s.id}
className="rounded-[7px] border border-border bg-surface px-2.5 py-1.5 text-left transition-colors hover:border-border-strong"
onClick={() => openSession(s)}
type="button"
>
<span className="text-[12px] text-muted-foreground">{s.title}</span>
</button>
))}
</div>
)}
</div>
)}
</div>

View File

@ -0,0 +1,215 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "@tanstack/react-router";
import { GitBranch, LayoutGrid, PanelRightClose, PanelRightOpen, Waypoints } from "lucide-react";
import { useState } from "react";
import {
findProjectOrchestrator,
isOrchestratorSession,
workerDisplayStatus,
type WorkerDisplayStatus,
type WorkspaceSession,
} from "../types/workspace";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { useUiStore } from "../stores/ui-store";
import { cn } from "../lib/utils";
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
const dragStyle = isMac ? ({ WebkitAppRegion: "drag" } as React.CSSProperties) : undefined;
const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined;
// Session status → pill tone, mirroring agent-orchestrator's StatusBadge
// (working=orange & breathing, input=amber, fail=red, ready=green, done=neutral).
// Tones are theme vars so the pill tracks the light/dark status palettes.
const STATUS_PILL: Record<WorkerDisplayStatus, { label: string; tone: string; breathe: boolean }> = {
working: { label: "Working", tone: "var(--orange)", breathe: true },
needs_you: { label: "Needs input", tone: "var(--amber)", breathe: false },
ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false },
mergeable: { label: "Ready", tone: "var(--green)", breathe: false },
done: { label: "Done", tone: "var(--fg-muted)", breathe: false },
};
// The one app topbar (.dashboard-app-header), rendered by the shell layout
// across the full window width — above both the sidebar and the route outlet —
// so the crumb and actions sit at identical offsets on every screen and the
// macOS traffic lights + TitlebarNav cluster live in its left inset
// (.is-under-titlebar-nav pads past them). The
// variant is derived from the route, not props: a sessionId in the URL swaps
// the lead to the session identity (orchestrator crumb + mode badge, or worker
// branch + status pill) and the actions to Kanban/inspector controls;
// otherwise it's the dashboard crumb plus the Orchestrator launcher when a
// project is in scope. Merges the old DashboardTopbar/Topbar pair —
// agent-orchestrator keeps those as two components aligned only by CSS.
export function ShellTopbar() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const params = useParams({ strict: false }) as { projectId?: string; sessionId?: string };
const isInspectorOpen = useUiStore((state) => state.isInspectorOpen);
const toggleInspector = useUiStore((state) => state.toggleInspector);
const [isSpawning, setIsSpawning] = useState(false);
const all = useWorkspaceQuery().data ?? [];
const session = params.sessionId
? all.flatMap((workspace) => workspace.sessions).find((s) => s.id === params.sessionId)
: undefined;
const isSessionRoute = Boolean(params.sessionId);
const isOrchestrator = session ? isOrchestratorSession(session) : false;
// Project in scope: the session's workspace wins over the route param so the
// cross-project /sessions/$sessionId route still resolves a crumb. A
// projectId that no longer resolves (stale route after the project was
// removed, or data still loading) shows an empty crumb — never the raw
// route slug. "agent-orchestrator" is the root-board crumb only.
const projectId = session?.workspaceId ?? params.projectId;
const project = projectId ? all.find((workspace) => workspace.id === projectId) : undefined;
const projectLabel = project?.name ?? session?.workspaceName ?? (projectId ? "" : "agent-orchestrator");
const orchestrator = projectId ? findProjectOrchestrator(all, projectId) : undefined;
const openBoard = () =>
projectId ? void navigate({ to: "/projects/$projectId", params: { projectId } }) : void navigate({ to: "/" });
const openOrchestrator = async () => {
if (!projectId) return;
if (orchestrator) {
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId: orchestrator.id },
});
return;
}
setIsSpawning(true);
try {
const sessionId = await spawnOrchestrator(projectId);
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId },
});
} catch (error) {
console.error("Failed to spawn orchestrator:", error);
} finally {
setIsSpawning(false);
}
};
return (
<header className={cn("dashboard-app-header", isMac && "is-under-titlebar-nav")} style={dragStyle}>
<div className="session-topbar__lead">
{isSessionRoute && isOrchestrator ? (
<div className="topbar-project-pills-group">
<div className="topbar-project-line">
<span className="dashboard-app-header__project">{projectLabel}</span>
<span aria-hidden="true" className="topbar-identity-sep">
·
</span>
<span className="session-detail-mode-badge session-detail-mode-badge--neutral">
<Waypoints className="size-3 shrink-0" aria-hidden="true" />
Orchestrator
</span>
</div>
</div>
) : isSessionRoute ? (
<div className="session-topbar__identity">
<div className="session-topbar__branch">
<GitBranch className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="truncate">{session?.branch || `session/${session?.id ?? ""}`}</span>
</div>
{session ? <SessionStatusPill session={session} /> : null}
</div>
) : (
<div className="topbar-project-line">
<span className="dashboard-app-header__project">{projectLabel}</span>
</div>
)}
</div>
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
{isSessionRoute ? (
<>
<button
aria-label={isOrchestrator ? "Open Kanban" : "Back to board"}
className="dashboard-app-header__primary-btn"
onClick={openBoard}
style={noDragStyle}
type="button"
>
<LayoutGrid className="h-3.5 w-3.5" aria-hidden="true" />
{isOrchestrator ? "Open Kanban" : "Kanban"}
</button>
{/* Inspector collapse (worker sessions only — orchestrators have no rail). */}
{!isOrchestrator && (
<button
aria-label={isInspectorOpen ? "Close inspector panel" : "Open inspector panel"}
aria-pressed={isInspectorOpen}
className="dashboard-app-header__icon-btn"
onClick={toggleInspector}
style={noDragStyle}
title={`${isInspectorOpen ? "Close" : "Open"} inspector · ⌘⇧B`}
type="button"
>
{isInspectorOpen ? (
<PanelRightClose className="h-[15px] w-[15px]" aria-hidden="true" />
) : (
<PanelRightOpen className="h-[15px] w-[15px]" aria-hidden="true" />
)}
</button>
)}
</>
) : projectId ? (
orchestrator ? (
<button
aria-label="Orchestrator"
className="dashboard-app-header__primary-btn"
onClick={() =>
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId: orchestrator.id },
})
}
style={noDragStyle}
type="button"
>
<Waypoints className="h-3.5 w-3.5" aria-hidden="true" />
Orchestrator
</button>
) : (
<button
aria-label="Spawn Orchestrator"
className="dashboard-app-header__primary-btn"
disabled={isSpawning}
onClick={() => void openOrchestrator()}
style={noDragStyle}
type="button"
>
<Waypoints className="h-3.5 w-3.5" aria-hidden="true" />
{isSpawning ? "Spawning…" : "Spawn Orchestrator"}
</button>
)
) : null}
</div>
</header>
);
}
// StatusBadge --pill: tinted bordered pill (inset 25%-tone hairline + 7%-tone
// fill) with a 6px dot that breathes while the agent is working.
function SessionStatusPill({ session }: { session: WorkspaceSession }) {
const { label, tone, breathe } = STATUS_PILL[workerDisplayStatus(session)];
return (
<span
className="inline-flex shrink-0 items-center gap-[7px] whitespace-nowrap rounded-[7px] px-[11px] py-[5px] text-[11.5px] font-semibold leading-none"
style={{
color: tone,
background: `color-mix(in srgb, ${tone} 7%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${tone} 25%, transparent)`,
}}
>
<span
className={cn("h-1.5 w-1.5 rounded-full", breathe && "animate-status-pulse")}
style={{ background: tone }}
/>
{label}
</span>
);
}

View File

@ -1,7 +1,13 @@
import { useNavigate, useParams, useRouterState } from "@tanstack/react-router";
import { ChevronRight, GitPullRequest, Moon, Plus, Search, Settings, Sun, Waypoints } from "lucide-react";
import { useState } from "react";
import { attentionZone, type WorkspaceSession, type WorkspaceSummary, workerSessions } from "../types/workspace";
import {
attentionZone,
sessionIsActive,
type WorkspaceSession,
type WorkspaceSummary,
workerSessions,
} from "../types/workspace";
import { aoBridge } from "../lib/bridge";
import { useEventsConnection } from "../hooks/useEventsConnection";
import { useResizable } from "../hooks/useResizable";
@ -35,12 +41,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { cn } from "../lib/utils";
import { useUiStore } from "../stores/ui-store";
// macOS hiddenInset traffic lights (x:14, y:14) occupy the sidebar's top-left;
// the sidebar gives them a real 40px titlebar strip (draggable; the fixed
// TitlebarNav overlay sits beside the lights), and the collapsed icon rail
// keeps a matching 40px inset. Windows/Linux keep the verbatim 14px padding.
// The macOS hiddenInset traffic lights and the fixed TitlebarNav overlay live
// in the full-width topbar's left inset (_shell renders the bar above the
// sidebar row); the sidebar itself starts below the 56px header, so its border
// never crosses the titlebar strip.
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
const dragStyle = isMac ? ({ WebkitAppRegion: "drag" } as React.CSSProperties) : undefined;
const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined;
type SidebarProps = {
@ -63,7 +68,6 @@ function useSelection() {
activeSessionId: params.sessionId,
goHome: () => void navigate({ to: "/" }),
goPrs: () => void navigate({ to: "/prs" }),
goReview: () => void navigate({ to: "/review" }),
goSettings: (projectId: string) => void navigate({ to: "/projects/$projectId/settings", params: { projectId } }),
goProject: (projectId: string) => void navigate({ to: "/projects/$projectId", params: { projectId } }),
goSession: (projectId: string, sessionId: string) =>
@ -119,13 +123,11 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj
});
return (
<SidebarRoot collapsible="icon" className="border-border">
<SidebarHeader className={cn("gap-0 p-0 px-[7px] group-data-[collapsible=icon]:px-1.5", !isMac && "pt-3.5")}>
{/* Titlebar strip: a draggable 40px inset under the traffic lights and
the fixed TitlebarNav overlay (rendered once by the shell), kept in
both sidebar states. */}
{isMac && <div className="h-10 shrink-0" style={dragStyle} />}
// The container is fixed-positioned by the shadcn primitive; offset it
// below the 56px shell topbar so the bar runs edge-to-edge above it
// (same override as shadcn's header-above-sidebar block).
<SidebarRoot collapsible="icon" className="border-border top-14 h-[calc(100svh-3.5rem)]!">
<SidebarHeader className="gap-0 p-0 px-[7px] pt-3.5 group-data-[collapsible=icon]:px-1.5">
{/* Brand (project-sidebar__brand); in the icon rail it becomes the old
36px board button wrapping the 22px accent mark. */}
<div className="flex shrink-0 items-center gap-2.5 px-2 pb-[18px] group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:pb-2">
@ -241,10 +243,6 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj
<GitPullRequest aria-hidden="true" />
Pull requests
</DropdownMenuItem>
<DropdownMenuItem onSelect={selection.goReview}>
<Settings aria-hidden="true" />
Reviews
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Search aria-hidden="true" />
Search
@ -316,6 +314,9 @@ function ProjectItem({
onNewWorker: () => void;
}) {
const projectActive = selection.activeProjectId === workspace.id && !selection.activeSessionId;
// Live workers only: merged/terminated sessions leave the sidebar and stay
// reachable through the board's Done / Terminated bar (SessionsBoard).
const sessions = workerSessions(workspace.sessions).filter(sessionIsActive);
const onProjectClick = () => {
if (!expanded) {
@ -359,7 +360,7 @@ function ProjectItem({
<span className="hidden group-data-[collapsible=icon]:block">{workspace.name.charAt(0).toUpperCase()}</span>
<span className="min-w-0 flex-1 truncate group-data-[collapsible=icon]:hidden">{workspace.name}</span>
<span className="shrink-0 font-mono text-[11px] text-passive group-hover/menu-item:opacity-0 group-data-[collapsible=icon]:hidden">
{workerSessions(workspace.sessions).length}
{sessions.length}
</span>
</SidebarMenuButton>
{/* project-sidebar__proj-actions — reveal over the count slot on hover */}
@ -369,7 +370,12 @@ function ProjectItem({
showOnHover
aria-label={`New worker in ${workspace.name}`}
onClick={onNewWorker}
className="right-1.5 h-[22px] w-[22px] rounded-[5px] text-passive transition-opacity hover:bg-interactive-active hover:text-foreground peer-data-[size=default]/menu-button:top-1"
// No top override: the base's peer-data-[size=default]:top-1.5
// (6px) centers this 22px action in the 34px row. AO's 50%/
// translateY(-50%) can't be cloned here — the absolute context
// is the whole menu li including the expanded sessions list,
// so a percentage top centers on the group, not the row.
className="right-1.5 h-[22px] w-[22px] rounded-[5px] text-passive transition-opacity hover:bg-interactive-active hover:text-foreground"
>
<Plus className="h-[13px]! w-[13px]!" aria-hidden="true" />
</SidebarMenuAction>
@ -377,10 +383,12 @@ function ProjectItem({
<TooltipContent>New worker in {workspace.name}</TooltipContent>
</Tooltip>
{/* project-sidebar__sessions */}
{expanded && workerSessions(workspace.sessions).length > 0 && (
<SidebarMenuSub className="mx-0 translate-x-0 gap-0 border-0 px-0 pb-2 pl-1 pt-0.5">
{workerSessions(workspace.sessions).map((session) => {
{/* project-sidebar__sessions. Divergence from AO (user decision
2026-06-12): no left indent or tree guide line every sidebar row
(project or worker) spans the same full width. */}
{expanded && sessions.length > 0 && (
<SidebarMenuSub className="mx-0 translate-x-0 gap-0 border-0 px-0 pb-2 pt-0.5">
{sessions.map((session) => {
const active = selection.activeSessionId === session.id;
return (
<SidebarMenuSubItem key={session.id}>

View File

@ -1,5 +1,6 @@
import { useCanGoBack, useRouter, useRouterState } from "@tanstack/react-router";
import { useCanGoBack, useRouter } from "@tanstack/react-router";
import { ArrowLeft, ArrowRight, PanelLeft } from "lucide-react";
import { useEffect, useState } from "react";
import { useUiStore } from "../stores/ui-store";
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
@ -9,18 +10,37 @@ const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperti
// the traffic lights, VS Code-style. Approved divergence from the web
// reference, which has no window chrome (DESIGN.md banner, 2026-06-10).
// Rendered once by the shell as a fixed overlay (.titlebar-nav in styles.css)
// so the buttons occupy the exact same spot whether the sidebar is expanded
// or collapsed; the collapsed-rail topbars pad past it (.is-under-titlebar-nav).
// over the full-width topbar's left inset, so the buttons occupy the exact
// same spot whether the sidebar is expanded or collapsed; the topbar starts
// its content past the cluster (.is-under-titlebar-nav).
// The installed router has no useCanGoForward, and deriving one as
// `__TSR_index < history.length - 1` (the upstream hook's approach) is wrong
// here: window.history.length also counts entries the router never created —
// the WebContents' initial blank entry, pre-router loads — so the tip of the
// stack still reads as "forward available" and the arrow no-ops. Instead,
// track the highest router index reachable on the live stack: a PUSH discards
// the forward entries (the new index is the tip); BACK/FORWARD/GO only move
// within it. After a mid-stack reload the tip resets to the current entry —
// forward greys out rather than dangle on entries we can no longer see.
function useCanGoForward(): boolean {
const router = useRouter();
const [canGoForward, setCanGoForward] = useState(false);
useEffect(() => {
let tip = router.history.location.state.__TSR_index;
return router.history.subscribe(({ location, action }) => {
const index = location.state.__TSR_index;
tip = action.type === "PUSH" ? index : Math.max(tip, index);
setCanGoForward(index < tip);
});
}, [router]);
return canGoForward;
}
export function TitlebarNav() {
const { isSidebarOpen, toggleSidebar } = useUiStore();
const router = useRouter();
const canGoBack = useCanGoBack();
// No useCanGoForward in the installed router; derive it from the history
// index the same way useCanGoBack does (any back/forward/push re-renders
// via the location store, so history.length is read fresh).
const canGoForward = useRouterState({
select: (state) => state.location.state.__TSR_index < router.history.length - 1,
});
const canGoForward = useCanGoForward();
if (!isMac) return null;

View File

@ -1,124 +0,0 @@
import { GitBranch, LayoutGrid, PanelRightClose, PanelRightOpen, Waypoints } from "lucide-react";
import { type WorkbenchView, useUiStore } from "../stores/ui-store";
import type { WorkerDisplayStatus, WorkspaceSession } from "../types/workspace";
import { workerDisplayStatus } from "../types/workspace";
import { cn } from "../lib/utils";
// Session status → pill tone, mirroring agent-orchestrator's StatusBadge
// (working=orange & breathing, input=amber, fail=red, ready=green, done=neutral).
// Tones are theme vars so the pill tracks the light/dark status palettes.
const STATUS_PILL: Record<WorkerDisplayStatus, { label: string; tone: string; breathe: boolean }> = {
working: { label: "Working", tone: "var(--orange)", breathe: true },
needs_you: { label: "Needs input", tone: "var(--amber)", breathe: false },
ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false },
mergeable: { label: "Ready", tone: "var(--green)", breathe: false },
done: { label: "Done", tone: "var(--fg-muted)", breathe: false },
};
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
const dragStyle = isMac ? ({ WebkitAppRegion: "drag" } as React.CSSProperties) : undefined;
const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined;
type TopbarProps = {
view: WorkbenchView;
session?: WorkspaceSession;
/** Project crumb for orchestrator sessions (matches AO topbar-project-line). */
projectLabel?: string;
/** Back-to-board navigation for the Kanban / Open Kanban button. */
onOpenBoard?: () => void;
};
export function Topbar({ view, session, projectLabel, onOpenBoard }: TopbarProps) {
const isSidebarOpen = useUiStore((state) => state.isSidebarOpen);
const isInspectorOpen = useUiStore((state) => state.isInspectorOpen);
const toggleInspector = useUiStore((state) => state.toggleInspector);
return (
<header
className={cn("dashboard-app-header session-topbar", isMac && !isSidebarOpen && "is-under-titlebar-nav")}
style={dragStyle}
>
<div className="session-topbar__lead">
{view === "orchestrator" ? (
<div className="topbar-project-pills-group">
<div className="topbar-project-line">
<span className="dashboard-app-header__project">
{projectLabel ?? session?.workspaceName ?? "Project"}
</span>
<span aria-hidden="true" className="topbar-identity-sep">
·
</span>
<span className="session-detail-mode-badge session-detail-mode-badge--neutral">
<Waypoints className="size-3 shrink-0" aria-hidden="true" />
Orchestrator
</span>
</div>
</div>
) : (
<div className="session-topbar__identity">
<div className="session-topbar__branch">
<GitBranch className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="truncate">{session?.branch || `session/${session?.id ?? ""}`}</span>
</div>
{session ? <SessionStatusPill session={session} /> : null}
</div>
)}
</div>
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
<button
aria-label={view === "orchestrator" ? "Open Kanban" : "Back to board"}
className="dashboard-app-header__primary-btn"
onClick={onOpenBoard}
style={noDragStyle}
type="button"
>
<LayoutGrid className="h-3.5 w-3.5" aria-hidden="true" />
{view === "orchestrator" ? "Open Kanban" : "Kanban"}
</button>
{/* Inspector collapse (worker sessions only — orchestrators have no rail). */}
{view === "session" && (
<button
aria-label={isInspectorOpen ? "Close inspector panel" : "Open inspector panel"}
aria-pressed={isInspectorOpen}
className="dashboard-app-header__icon-btn"
onClick={toggleInspector}
style={noDragStyle}
title={`${isInspectorOpen ? "Close" : "Open"} inspector · ⌘⇧B`}
type="button"
>
{isInspectorOpen ? (
<PanelRightClose className="h-[15px] w-[15px]" aria-hidden="true" />
) : (
<PanelRightOpen className="h-[15px] w-[15px]" aria-hidden="true" />
)}
</button>
)}
</div>
</header>
);
}
// StatusBadge --pill: tinted bordered pill (inset 25%-tone hairline + 7%-tone
// fill) with a 6px dot that breathes while the agent is working.
function SessionStatusPill({ session }: { session: WorkspaceSession }) {
const { label, tone, breathe } = STATUS_PILL[workerDisplayStatus(session)];
return (
<span
className="inline-flex shrink-0 items-center gap-[7px] whitespace-nowrap rounded-[7px] px-[11px] py-[5px] text-[11.5px] font-semibold leading-none"
style={{
color: tone,
background: `color-mix(in srgb, ${tone} 7%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${tone} 25%, transparent)`,
}}
>
<span
className={cn("h-1.5 w-1.5 rounded-full", breathe && "animate-status-pulse")}
style={{ background: tone }}
/>
{label}
</span>
);
}

View File

@ -11,8 +11,6 @@
import { Route as rootRouteImport } from "./routes/__root";
import { Route as ShellRouteImport } from "./routes/_shell";
import { Route as ShellIndexRouteImport } from "./routes/_shell.index";
import { Route as ShellReviewsRouteImport } from "./routes/_shell.reviews";
import { Route as ShellReviewRouteImport } from "./routes/_shell.review";
import { Route as ShellPrsRouteImport } from "./routes/_shell.prs";
import { Route as ShellSessionsSessionIdRouteImport } from "./routes/_shell.sessions.$sessionId";
import { Route as ShellProjectsProjectIdRouteImport } from "./routes/_shell.projects.$projectId";
@ -28,16 +26,6 @@ const ShellIndexRoute = ShellIndexRouteImport.update({
path: "/",
getParentRoute: () => ShellRoute,
} as any);
const ShellReviewsRoute = ShellReviewsRouteImport.update({
id: "/reviews",
path: "/reviews",
getParentRoute: () => ShellRoute,
} as any);
const ShellReviewRoute = ShellReviewRouteImport.update({
id: "/review",
path: "/review",
getParentRoute: () => ShellRoute,
} as any);
const ShellPrsRoute = ShellPrsRouteImport.update({
id: "/prs",
path: "/prs",
@ -67,8 +55,6 @@ const ShellProjectsProjectIdSessionsSessionIdRoute = ShellProjectsProjectIdSessi
export interface FileRoutesByFullPath {
"/": typeof ShellIndexRoute;
"/prs": typeof ShellPrsRoute;
"/review": typeof ShellReviewRoute;
"/reviews": typeof ShellReviewsRoute;
"/projects/$projectId": typeof ShellProjectsProjectIdRoute;
"/sessions/$sessionId": typeof ShellSessionsSessionIdRoute;
"/projects/$projectId/settings": typeof ShellProjectsProjectIdSettingsRoute;
@ -76,8 +62,6 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
"/prs": typeof ShellPrsRoute;
"/review": typeof ShellReviewRoute;
"/reviews": typeof ShellReviewsRoute;
"/": typeof ShellIndexRoute;
"/projects/$projectId": typeof ShellProjectsProjectIdRoute;
"/sessions/$sessionId": typeof ShellSessionsSessionIdRoute;
@ -88,8 +72,6 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/_shell": typeof ShellRouteWithChildren;
"/_shell/prs": typeof ShellPrsRoute;
"/_shell/review": typeof ShellReviewRoute;
"/_shell/reviews": typeof ShellReviewsRoute;
"/_shell/": typeof ShellIndexRoute;
"/_shell/projects/$projectId": typeof ShellProjectsProjectIdRoute;
"/_shell/sessions/$sessionId": typeof ShellSessionsSessionIdRoute;
@ -101,8 +83,6 @@ export interface FileRouteTypes {
fullPaths:
| "/"
| "/prs"
| "/review"
| "/reviews"
| "/projects/$projectId"
| "/sessions/$sessionId"
| "/projects/$projectId/settings"
@ -110,8 +90,6 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo;
to:
| "/prs"
| "/review"
| "/reviews"
| "/"
| "/projects/$projectId"
| "/sessions/$sessionId"
@ -121,8 +99,6 @@ export interface FileRouteTypes {
| "__root__"
| "/_shell"
| "/_shell/prs"
| "/_shell/review"
| "/_shell/reviews"
| "/_shell/"
| "/_shell/projects/$projectId"
| "/_shell/sessions/$sessionId"
@ -150,20 +126,6 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof ShellIndexRouteImport;
parentRoute: typeof ShellRoute;
};
"/_shell/reviews": {
id: "/_shell/reviews";
path: "/reviews";
fullPath: "/reviews";
preLoaderRoute: typeof ShellReviewsRouteImport;
parentRoute: typeof ShellRoute;
};
"/_shell/review": {
id: "/_shell/review";
path: "/review";
fullPath: "/review";
preLoaderRoute: typeof ShellReviewRouteImport;
parentRoute: typeof ShellRoute;
};
"/_shell/prs": {
id: "/_shell/prs";
path: "/prs";
@ -204,8 +166,6 @@ declare module "@tanstack/react-router" {
interface ShellRouteChildren {
ShellPrsRoute: typeof ShellPrsRoute;
ShellReviewRoute: typeof ShellReviewRoute;
ShellReviewsRoute: typeof ShellReviewsRoute;
ShellIndexRoute: typeof ShellIndexRoute;
ShellProjectsProjectIdRoute: typeof ShellProjectsProjectIdRoute;
ShellSessionsSessionIdRoute: typeof ShellSessionsSessionIdRoute;
@ -215,8 +175,6 @@ interface ShellRouteChildren {
const ShellRouteChildren: ShellRouteChildren = {
ShellPrsRoute: ShellPrsRoute,
ShellReviewRoute: ShellReviewRoute,
ShellReviewsRoute: ShellReviewsRoute,
ShellIndexRoute: ShellIndexRoute,
ShellProjectsProjectIdRoute: ShellProjectsProjectIdRoute,
ShellSessionsSessionIdRoute: ShellSessionsSessionIdRoute,

View File

@ -6,6 +6,6 @@ export const Route = createFileRoute("/_shell/projects/$projectId_/sessions/$ses
});
function ProjectSessionRoute() {
const { projectId, sessionId } = Route.useParams();
return <SessionView projectId={projectId} sessionId={sessionId} />;
const { sessionId } = Route.useParams();
return <SessionView sessionId={sessionId} />;
}

View File

@ -1,6 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { ReviewDashboard } from "../components/ReviewDashboard";
export const Route = createFileRoute("/_shell/review")({
component: ReviewDashboard,
});

View File

@ -1,8 +0,0 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
// /reviews is an alias for /review (matches agent-orchestrator).
export const Route = createFileRoute("/_shell/reviews")({
beforeLoad: () => {
throw redirect({ to: "/review" });
},
});

View File

@ -1,6 +1,7 @@
import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import { ShellTopbar } from "../components/ShellTopbar";
import { Sidebar } from "../components/Sidebar";
import { SidebarProvider } from "../components/ui/sidebar";
import { SpawnWorkerModal } from "../components/SpawnWorkerModal";
@ -151,37 +152,48 @@ function ShellLayout() {
return (
<ShellProvider value={{ daemonStatus, openSpawn, createProject, createTask }}>
{/* Controlled by the ui-store so TitlebarNav / Topbar toggles (which call
the store directly) stay in sync. --sidebar-width chains to the
drag-resizable --ao-sidebar-w set on :root by useResizable. */}
<SidebarProvider
className="h-screen min-h-0 bg-background text-foreground"
onOpenChange={(open) => open !== isSidebarOpen && toggleSidebar()}
open={isSidebarOpen}
style={
{ "--sidebar-width": "var(--ao-sidebar-w, 240px)", "--sidebar-width-icon": "48px" } as React.CSSProperties
}
>
<Sidebar
daemonStatus={daemonStatus}
onCreateProject={createProject}
onNewWorker={openSpawn}
workspaceError={workspaceQuery.isError ? errorMessage(workspaceQuery.error) : undefined}
workspaces={workspaces}
/>
<main className="flex min-w-0 flex-1 flex-col">
<Outlet />
</main>
{/* Fixed macOS titlebar cluster beside the traffic lights rendered
once here so the toggle/history buttons never move when the
sidebar collapses or expands. MUST come after the sidebar/topbars
in the DOM: Electron builds the window-drag region in document
order (drag rects add, no-drag rects subtract), so the cluster's
no-drag holes only survive if they're processed after the drag
strips they overlap. Rendered first, real clicks get swallowed by
window-drag even though DOM hit-testing looks correct. */}
<TitlebarNav />
</SidebarProvider>
{/* The topbar spans the full window width above the sidebar row (the
macOS traffic lights + TitlebarNav cluster sit in its left inset),
and the sidebar hangs below it so the sidebar border stops at the
header instead of cutting through the titlebar strip. The bar lives
in the layout, not the screens, so the crumb and actions never shift
when the outlet content swaps. */}
<div className="flex h-screen min-h-0 flex-col bg-background text-foreground">
<ShellTopbar />
{/* Controlled by the ui-store so TitlebarNav / Topbar toggles (which
call the store directly) stay in sync. --sidebar-width chains to
the drag-resizable --ao-sidebar-w set on :root by useResizable. */}
<SidebarProvider
className="min-h-0 flex-1"
onOpenChange={(open) => open !== isSidebarOpen && toggleSidebar()}
open={isSidebarOpen}
style={
{ "--sidebar-width": "var(--ao-sidebar-w, 240px)", "--sidebar-width-icon": "48px" } as React.CSSProperties
}
>
<Sidebar
daemonStatus={daemonStatus}
onCreateProject={createProject}
onNewWorker={openSpawn}
workspaceError={workspaceQuery.isError ? errorMessage(workspaceQuery.error) : undefined}
workspaces={workspaces}
/>
<main className="flex min-w-0 flex-1 flex-col">
<div className="min-h-0 flex-1">
<Outlet />
</div>
</main>
{/* Fixed macOS titlebar cluster beside the traffic lights rendered
once here so the toggle/history buttons never move when the
sidebar collapses or expands. MUST come after the topbar in the
DOM: Electron builds the window-drag region in document order
(drag rects add, no-drag rects subtract), so the cluster's
no-drag holes only survive if they're processed after the drag
strips they overlap. Rendered first, real clicks get swallowed
by window-drag even though DOM hit-testing looks correct. */}
<TitlebarNav />
</SidebarProvider>
</div>
<SpawnWorkerModal
defaultProjectId={spawnProjectId}
onCreateTask={createTask}

View File

@ -1,8 +1,6 @@
import { create } from "zustand";
export type Theme = "light" | "dark";
/** Whether a terminal pane shows the orchestrator or a worker session. */
export type WorkbenchView = "orchestrator" | "session";
/** Worker detail view toggles — Changes (Git rail) is the default. */
export type WorkbenchTab = "changes" | "files" | "terminal";

View File

@ -280,14 +280,21 @@ body.is-resizing-x [data-slot="sidebar-container"] {
transition: none;
}
/* ── Dashboard / session topbar (agent-orchestrator .dashboard-app-header) ─ */
/* Shell topbar (agent-orchestrator .dashboard-app-header)
* One header for every screen (rendered by the shell layout), so one padding:
* AO's session-topbar values. Upstream splits this into a 14px dashboard
* header and a 16/14px session override; with the bar hoisted there is a
* single frame and the crumb never shifts between views. The bar spans the
* full window width with the sidebar pinned below it (approved divergence
* 2026-06-12), so its bottom border runs edge-to-edge and the sidebar border
* stops at the header instead of crossing the traffic-light strip. */
.dashboard-app-header {
display: flex;
height: 56px;
flex-shrink: 0;
align-items: center;
gap: 13px;
padding: 0 20px;
padding: 0 16px 0 14px;
border-bottom: 1px solid var(--border);
background: var(--bg);
z-index: 10;
@ -309,35 +316,6 @@ body.is-resizing-x [data-slot="sidebar-container"] {
gap: 6px;
}
.dashboard-app-header__tabs {
display: inline-flex;
align-items: center;
gap: 2px;
}
.dashboard-app-header__tab {
display: inline-flex;
height: 28px;
align-items: center;
border-radius: 6px;
padding: 0 11px;
font-size: 12.5px;
line-height: 1;
color: var(--fg-passive);
transition:
background 0.12s ease,
color 0.12s ease;
}
.dashboard-app-header__tab:hover {
color: var(--fg);
}
.dashboard-app-header__tab.is-active {
background: var(--interactive-active);
color: var(--fg);
}
.dashboard-app-header__spacer {
flex: 1;
min-width: 0;
@ -410,12 +388,6 @@ body.is-resizing-x [data-slot="sidebar-container"] {
background: color-mix(in srgb, var(--accent-weak) 80%, transparent);
}
.session-topbar.dashboard-app-header {
height: 56px;
gap: 13px;
padding: 0 16px 0 14px;
}
.session-topbar__lead {
display: flex;
min-width: 0;
@ -425,16 +397,19 @@ body.is-resizing-x [data-slot="sidebar-container"] {
/* macOS titlebar cluster (TitlebarNav)
* Fixed beside the traffic lights so the toggle/history buttons occupy the
* exact same spot in both sidebar states. Lights span x=1466 (y center 20);
* 28px buttons in a 40px strip share that center, and the 6.5px icon inset
* puts the first glyph at ~76px the standard 10px gap after the lights. */
* exact same spot in both sidebar states. The 56px strip matches the
* .dashboard-app-header height so the buttons share the topbar's center line
* (y 28) with the project crumb / bell; the lights match via
* trafficLightPosition y:20 in main.ts. Lights span x=1466; the cluster
* starts a 13px group gap after them (the .dashboard-app-header gap), so
* the toggle reads as its own group rather than a fourth light. */
.titlebar-nav {
position: fixed;
top: 0;
left: 70px;
left: 79px;
z-index: 20;
display: flex;
height: 40px;
height: 56px;
align-items: center;
gap: 2px;
}
@ -444,16 +419,11 @@ body.is-resizing-x [data-slot="sidebar-container"] {
height: 28px;
}
/* Collapsed-rail topbars start their content past the fixed cluster:
* cluster right edge 70 + 3×28 + 2×2 = 158px, + the header's 13px gap,
* the 48px icon rail = 123px. Transition mirrors the sidebar's width
* animation (200ms linear) so content tracks the moving sidebar edge. */
.dashboard-app-header {
transition: padding-left 0.2s linear;
}
/* macOS: the full-width header starts its content past the lights + fixed
* cluster: cluster right edge 79 + 3×28 + 2×2 = 167px, + the header's 13px
* group gap = 180px. */
.dashboard-app-header.is-under-titlebar-nav {
padding-left: 123px;
padding-left: 180px;
}
.topbar-project-pills-group {
@ -596,7 +566,7 @@ body.is-resizing-x [data-slot="sidebar-container"] {
left: 50%;
width: 1px;
transform: translateX(-50%);
background: transparent;
background: var(--border-1);
transition: background 0.12s ease;
}

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
attentionZone,
findProjectOrchestrator,
sessionIsActive,
sessionNeedsAttention,
toAgentProvider,
@ -10,6 +11,7 @@ import {
type AttentionZone,
type SessionStatus,
type WorkspaceSession,
type WorkspaceSummary,
} from "./workspace";
function sessionWith(overrides: Partial<WorkspaceSession>): WorkspaceSession {
@ -77,6 +79,37 @@ describe("sessionIsActive", () => {
});
});
describe("findProjectOrchestrator", () => {
function workspaceWith(sessions: WorkspaceSession[]): WorkspaceSummary {
return { id: "skills", name: "skills", path: "/tmp/skills", sessions };
}
it("skips a terminated orchestrator that precedes the live one", () => {
// Regression: the daemon lists sessions by spawn number, so a dead
// orchestrator (zellij session deleted) sorts before its live successor.
// Picking it sent the Orchestrator button to an instant "[process exited]".
const dead = sessionWith({ id: "skills-4", kind: "orchestrator", status: "terminated" });
const live = sessionWith({ id: "skills-5", kind: "orchestrator", status: "needs_input" });
const worker = sessionWith({ id: "skills-6", kind: "worker", status: "working" });
expect(findProjectOrchestrator([workspaceWith([dead, live, worker])], "skills")).toBe(live);
});
it("returns undefined when every orchestrator is terminated", () => {
const dead = sessionWith({ id: "skills-4", kind: "orchestrator", status: "terminated" });
expect(findProjectOrchestrator([workspaceWith([dead])], "skills")).toBeUndefined();
});
it("ignores live workers when looking for an orchestrator", () => {
const worker = sessionWith({ id: "skills-6", kind: "worker", status: "working" });
expect(findProjectOrchestrator([workspaceWith([worker])], "skills")).toBeUndefined();
});
it("returns undefined for an unknown project", () => {
const live = sessionWith({ id: "skills-5", kind: "orchestrator", status: "working" });
expect(findProjectOrchestrator([workspaceWith([live])], "other")).toBeUndefined();
});
});
describe("sessionNeedsAttention", () => {
it.each(["needs_input", "changes_requested", "review_pending", "ci_failed"] as const)("is true for %s", (status) => {
expect(sessionNeedsAttention(sessionWith({ status }))).toBe(true);

View File

@ -123,12 +123,20 @@ export function isOrchestratorSession(session: WorkspaceSession): boolean {
return session.kind === "orchestrator" || session.id.endsWith("-orchestrator");
}
/**
* The project's LIVE orchestrator, if any. Terminated orchestrator rows stay in
* the session list (the daemon returns all sessions, ordered by spawn number),
* so an earlier dead orchestrator must not shadow a live one its zellij
* session is deleted and attaching to it dead-ends in an instant
* "[process exited]". No live orchestrator undefined, so the topbar offers
* Spawn instead of navigating to a dead session.
*/
export function findProjectOrchestrator(
workspaces: WorkspaceSummary[],
projectId: string,
): WorkspaceSession | undefined {
const workspace = workspaces.find((w) => w.id === projectId);
return workspace?.sessions.find(isOrchestratorSession);
return workspace?.sessions.find((session) => isOrchestratorSession(session) && sessionIsActive(session));
}
export function workerSessions(sessions: WorkspaceSession[]): WorkspaceSession[] {