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 # electron-forge / vite build output
.vite/ .vite/
dist-electron/ 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 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 by a `PanelRight` icon button in the session topbar and ⌘⇧B; open state + split
width persist. The AO reference keeps the rail always visible. 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 ## 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", title: "Agent Orchestrator",
backgroundColor: "#0f1014", backgroundColor: "#0f1014",
titleBarStyle: "hiddenInset", 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: { webPreferences: {
preload: preloadPath(), preload: preloadPath(),
contextIsolation: true, 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 type { components } from "../../api/schema";
import { apiClient, apiErrorMessage } from "../lib/api-client"; import { apiClient, apiErrorMessage } from "../lib/api-client";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { DashboardSubhead, DashboardTopbar } from "./DashboardTopbar"; import { DashboardSubhead } from "./DashboardSubhead";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Label } from "./ui/label"; import { Label } from "./ui/label";
@ -51,7 +51,6 @@ export function ProjectSettingsForm({ projectId }: { projectId: string }) {
return ( return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground"> <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} /> <DashboardSubhead title="Settings" subtitle={query.data.path} />
<div className="min-h-0 flex-1 overflow-y-auto p-[18px]"> <div className="min-h-0 flex-1 overflow-y-auto p-[18px]">
<SettingsBody <SettingsBody

View File

@ -4,7 +4,7 @@ import { useState } from "react";
import { apiClient, apiErrorMessage } from "../lib/api-client"; import { apiClient, apiErrorMessage } from "../lib/api-client";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import type { WorkspaceSession } from "../types/workspace"; import type { WorkspaceSession } from "../types/workspace";
import { DashboardSubhead, DashboardTopbar } from "./DashboardTopbar"; import { DashboardSubhead } from "./DashboardSubhead";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
@ -46,7 +46,6 @@ export function PullRequestsPage() {
return ( return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground"> <div className="flex h-full min-h-0 flex-col bg-background text-foreground">
<DashboardTopbar />
<DashboardSubhead <DashboardSubhead
title="Pull requests" title="Pull requests"
subtitle="Open PRs across every agent session, ready to resolve and merge." 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>() }; return { workspaces, panels: new Map<string, PanelEntry>() };
}); });
// The terminal, inspector body, and topbar pull in xterm/router/SSE machinery // The terminal and inspector body pull in xterm/SSE machinery irrelevant to
// irrelevant to the split under test. // the split under test. (The topbar is shell-owned — see ShellTopbar.)
vi.mock("./CenterPane", () => ({ CenterPane: () => <div /> })); vi.mock("./CenterPane", () => ({ CenterPane: () => <div /> }));
vi.mock("./SessionInspector", () => ({ SessionInspector: () => <div /> })); vi.mock("./SessionInspector", () => ({ SessionInspector: () => <div /> }));
vi.mock("./Topbar", () => ({ Topbar: () => <header /> }));
vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() }));
vi.mock("../lib/shell-context", () => ({ vi.mock("../lib/shell-context", () => ({
useShell: () => ({ daemonStatus: { state: "ready" } }), useShell: () => ({ daemonStatus: { state: "ready" } }),
})); }));
@ -60,7 +58,17 @@ vi.mock("../hooks/useWorkspaceQuery", () => ({
// fake imperative handle per panel instead. // fake imperative handle per panel instead.
vi.mock("./ui/resizable", () => ({ vi.mock("./ui/resizable", () => ({
ResizablePanelGroup: ({ children }: { children?: ReactNode }) => <div>{children}</div>, 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: ({ ResizablePanel: ({
children, children,
id, id,
@ -177,6 +185,8 @@ describe("SessionView", () => {
it("syncs drag resizes back into the store and persists the split", () => { it("syncs drag resizes back into the store and persists the split", () => {
render(<SessionView sessionId="sess-1" />); render(<SessionView sessionId="sess-1" />);
const entry = panels.get("inspector")!; 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. // Dragging past minSize collapses the panel → store follows.
act(() => entry.onResize?.({ asPercentage: 0, inPixels: 0 })); act(() => entry.onResize?.({ asPercentage: 0, inPixels: 0 }));
@ -188,12 +198,56 @@ describe("SessionView", () => {
expect(window.localStorage.getItem("ao.inspector.split")).toBe("31.5"); 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", () => { it("restores the persisted split width", () => {
window.localStorage.setItem("ao.inspector.split", "40"); window.localStorage.setItem("ao.inspector.split", "40");
render(<SessionView sessionId="sess-1" />); render(<SessionView sessionId="sess-1" />);
expect(panelSizes("inspector")[0]).toBe("40%"); 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", () => { it("renders no inspector panel or handle for orchestrator sessions", () => {
render(<SessionView sessionId="sess-orch" />); render(<SessionView sessionId="sess-orch" />);

View File

@ -1,9 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef } from "react";
import { useNavigate } from "@tanstack/react-router";
import type { PanelImperativeHandle, PanelSize } from "react-resizable-panels"; import type { PanelImperativeHandle, PanelSize } from "react-resizable-panels";
import { CenterPane } from "./CenterPane"; import { CenterPane } from "./CenterPane";
import { SessionInspector } from "./SessionInspector"; import { SessionInspector } from "./SessionInspector";
import { Topbar } from "./Topbar";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable";
import { useUiStore } from "../stores/ui-store"; import { useUiStore } from "../stores/ui-store";
import { useShell } from "../lib/shell-context"; import { useShell } from "../lib/shell-context";
@ -23,24 +21,22 @@ function initialSplitPercent(): number {
type SessionViewProps = { type SessionViewProps = {
sessionId: string; 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 // The session detail screen: the persistent terminal + git rail, under the
// both the project-scoped and cross-project session routes. The terminal lives // shell-owned ShellTopbar. Rendered by both the project-scoped and
// here (not in the shell) — switching sessions only changes route params, so // cross-project session routes. The terminal lives here (not in the shell) —
// TanStack Router keeps this component mounted and the terminal re-points its // switching sessions only changes route params, so TanStack Router keeps this
// mux without remounting (useTerminalSession). Leaving for the board unmounts // component mounted and the terminal re-points its mux without remounting
// it; the server's output ring replays on return. // (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 // 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 // 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 // flex-grow transition in styles.css. Content keeps a stable min-width inside
// the clipped panel so nothing reflows mid-animation; split width persists. // the clipped panel so nothing reflows mid-animation; split width persists.
export function SessionView({ sessionId, projectId }: SessionViewProps) { export function SessionView({ sessionId }: SessionViewProps) {
const navigate = useNavigate();
const workspaceQuery = useWorkspaceQuery(); const workspaceQuery = useWorkspaceQuery();
const workspaces = workspaceQuery.data ?? []; const workspaces = workspaceQuery.data ?? [];
const { theme } = useUiStore(); const { theme } = useUiStore();
@ -48,24 +44,32 @@ export function SessionView({ sessionId, projectId }: SessionViewProps) {
const toggleInspector = useUiStore((state) => state.toggleInspector); const toggleInspector = useUiStore((state) => state.toggleInspector);
const { daemonStatus } = useShell(); const { daemonStatus } = useShell();
const inspectorRef = useRef<PanelImperativeHandle | null>(null); 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 session = workspaces.flatMap((workspace) => workspace.sessions).find((s) => s.id === sessionId);
const isOrchestrator = session ? isOrchestratorSession(session) : false; 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. // Orchestrator sessions are terminal-only; only worker sessions have the rail.
const hasInspector = !isOrchestrator; const hasInspector = !isOrchestrator;
// Frozen at mount: rrp re-registers the panel (a layout effect keyed on // Computed when the inspector panel mounts and frozen while it stays
// defaultSize, among others) whenever this prop's identity changes, and the // mounted: rrp re-registers the panel (a layout effect keyed on defaultSize,
// imperative collapse()/expand() below can race that re-registration within // among others) whenever this prop's identity changes, and the imperative
// the same commit — rrp then throws "Panel constraints not found for Panel // 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 // inspector", which unwinds the whole route to the router's CatchBoundary
// (the toggle button looks dead and the session view is torn down). // (the toggle button looks dead and the session view is torn down).
// defaultSize only matters at first mount; afterwards the imperative API // Re-derived per panel mount (not once per SessionView mount — navigating
// owns the size, so it must never track live open/closed state. // orchestrator → worker keeps this component mounted while the panel
const [inspectorDefaultSize] = useState(() => (isInspectorOpen ? `${initialSplitPercent()}%` : "0%")); // 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(() => { useEffect(() => {
if (!hasInspector) return; if (!hasInspector) return;
@ -79,8 +83,14 @@ export function SessionView({ sessionId, projectId }: SessionViewProps) {
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [hasInspector, toggleInspector]); }, [hasInspector, toggleInspector]);
// Drive the collapsible panel from the store so the Topbar button, ⌘⇧B, and // Drive the collapsible panel from the store so the topbar button, ⌘⇧B, and
// drag-to-collapse all stay in sync. // 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(() => { useEffect(() => {
const panel = inspectorRef.current; const panel = inspectorRef.current;
if (!panel) return; if (!panel) return;
@ -92,11 +102,22 @@ export function SessionView({ sessionId, projectId }: SessionViewProps) {
} else { } else {
panel.collapse(); panel.collapse();
} }
}, [hasInspector, isInspectorOpen]); }, [isInspectorOpen]);
// Persist drags and mirror collapse state (dragging past minSize collapses) // Persist drags and mirror collapse state (dragging past minSize collapses)
// back into the store. Read the store imperatively to avoid a stale closure. // 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) => { const handleInspectorResize = (size: PanelSize) => {
if (inspectorSeparatorRef.current?.getAttribute("data-separator") !== "active") return;
const open = useUiStore.getState().isInspectorOpen; const open = useUiStore.getState().isInspectorOpen;
if (size.asPercentage > 0) { if (size.asPercentage > 0) {
window.localStorage?.setItem(inspectorSplitStorageKey, String(size.asPercentage)); window.localStorage?.setItem(inspectorSplitStorageKey, String(size.asPercentage));
@ -116,16 +137,6 @@ export function SessionView({ sessionId, projectId }: SessionViewProps) {
return ( return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground"> <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"> <ResizablePanelGroup className="session-split min-h-0 flex-1" id="session-workspace" orientation="horizontal">
{/* react-resizable-panels v4: bare numbers are PIXELS; percentages must {/* react-resizable-panels v4: bare numbers are PIXELS; percentages must
be strings. Numeric sizes here once clamped the inspector to 45px. */} be strings. Numeric sizes here once clamped the inspector to 45px. */}
@ -134,7 +145,10 @@ export function SessionView({ sessionId, projectId }: SessionViewProps) {
</ResizablePanel> </ResizablePanel>
{hasInspector ? ( {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 <ResizablePanel
aria-hidden={!isInspectorOpen} aria-hidden={!isInspectorOpen}
collapsible collapsible

View File

@ -1,3 +1,4 @@
import { useState } from "react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { import {
type AttentionZone, type AttentionZone,
@ -8,7 +9,7 @@ import {
workerSessions, workerSessions,
} from "../types/workspace"; } from "../types/workspace";
import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery"; import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery";
import { DashboardSubhead, DashboardTopbar } from "./DashboardTopbar"; import { DashboardSubhead } from "./DashboardSubhead";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
type SessionsBoardProps = { type SessionsBoardProps = {
@ -76,7 +77,6 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
const all = workspaceQuery.data ?? []; const all = workspaceQuery.data ?? [];
const workspaces = projectId ? all.filter((w) => w.id === projectId) : all; const workspaces = projectId ? all.filter((w) => w.id === projectId) : all;
const sessions = workspaces.flatMap((w) => workerSessions(w.sessions)); const sessions = workspaces.flatMap((w) => workerSessions(w.sessions));
const projectLabel = projectId ? (workspaces[0]?.name ?? projectId) : "agent-orchestrator";
const byZone = new Map<AttentionZone, WorkspaceSession[]>(); const byZone = new Map<AttentionZone, WorkspaceSession[]>();
for (const session of sessions) { for (const session of sessions) {
@ -84,6 +84,9 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
(byZone.get(zone) ?? byZone.set(zone, []).get(zone)!).push(session); (byZone.get(zone) ?? byZone.set(zone, []).get(zone)!).push(session);
} }
const done = byZone.get("done") ?? []; 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) => const openSession = (session: WorkspaceSession) =>
void navigate({ void navigate({
@ -93,7 +96,6 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
return ( return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground"> <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." /> <DashboardSubhead title="Board" subtitle="Live agent sessions flowing from work → review → merge." />
<div className="min-h-0 flex-1 overflow-hidden p-[18px]"> <div className="min-h-0 flex-1 overflow-hidden p-[18px]">
@ -109,24 +111,47 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
</div> </div>
{done.length > 0 && ( {done.length > 0 && (
<div className="shrink-0 border-t border-border px-[18px] py-2.5"> <div className="shrink-0 border-t border-border px-[18px]">
<div className="mb-1.5 flex items-center gap-2"> {/* agent-orchestrator's done-bar (Dashboard.tsx + globals.css):
<span className="h-[7px] w-[7px] rounded-full bg-passive" /> a full-width chevron + label + count toggle row. min-h matches
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Done</span> the sidebar footer (7px pad ×2 + 37px Settings button) so this
<span className="font-mono text-[11px] text-passive">{done.length}</span> border-t aligns with the sidebar's footer border. The button is
</div> 37px (not the 35.5px its text-[13px] implies) because the
<div className="flex flex-wrap gap-2"> unlayered `button { font: inherit }` in styles.css outranks
{done.map((s) => ( Tailwind's layered text utilities, leaving it at 14px/21px. */}
<button <button
key={s.id} aria-expanded={doneExpanded}
className="rounded-[7px] border border-border bg-surface px-2.5 py-1.5 text-left transition-colors hover:border-border-strong" className="group flex min-h-[51px] w-full items-center gap-2 py-2 text-muted-foreground transition-colors hover:text-foreground"
onClick={() => openSession(s)} onClick={() => setDoneExpanded((v) => !v)}
type="button" type="button"
> >
<span className="text-[12px] text-muted-foreground">{s.title}</span> <svg
</button> aria-hidden="true"
))} className={cn("h-3 w-3 shrink-0 transition-transform duration-150", doneExpanded && "rotate-90")}
</div> 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>
)} )}
</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 { useNavigate, useParams, useRouterState } from "@tanstack/react-router";
import { ChevronRight, GitPullRequest, Moon, Plus, Search, Settings, Sun, Waypoints } from "lucide-react"; import { ChevronRight, GitPullRequest, Moon, Plus, Search, Settings, Sun, Waypoints } from "lucide-react";
import { useState } from "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 { aoBridge } from "../lib/bridge";
import { useEventsConnection } from "../hooks/useEventsConnection"; import { useEventsConnection } from "../hooks/useEventsConnection";
import { useResizable } from "../hooks/useResizable"; import { useResizable } from "../hooks/useResizable";
@ -35,12 +41,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useUiStore } from "../stores/ui-store"; import { useUiStore } from "../stores/ui-store";
// macOS hiddenInset traffic lights (x:14, y:14) occupy the sidebar's top-left; // The macOS hiddenInset traffic lights and the fixed TitlebarNav overlay live
// the sidebar gives them a real 40px titlebar strip (draggable; the fixed // in the full-width topbar's left inset (_shell renders the bar above the
// TitlebarNav overlay sits beside the lights), and the collapsed icon rail // sidebar row); the sidebar itself starts below the 56px header, so its border
// keeps a matching 40px inset. Windows/Linux keep the verbatim 14px padding. // never crosses the titlebar strip.
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent); 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; const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined;
type SidebarProps = { type SidebarProps = {
@ -63,7 +68,6 @@ function useSelection() {
activeSessionId: params.sessionId, activeSessionId: params.sessionId,
goHome: () => void navigate({ to: "/" }), goHome: () => void navigate({ to: "/" }),
goPrs: () => void navigate({ to: "/prs" }), goPrs: () => void navigate({ to: "/prs" }),
goReview: () => void navigate({ to: "/review" }),
goSettings: (projectId: string) => void navigate({ to: "/projects/$projectId/settings", params: { projectId } }), goSettings: (projectId: string) => void navigate({ to: "/projects/$projectId/settings", params: { projectId } }),
goProject: (projectId: string) => void navigate({ to: "/projects/$projectId", params: { projectId } }), goProject: (projectId: string) => void navigate({ to: "/projects/$projectId", params: { projectId } }),
goSession: (projectId: string, sessionId: string) => goSession: (projectId: string, sessionId: string) =>
@ -119,13 +123,11 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj
}); });
return ( return (
<SidebarRoot collapsible="icon" className="border-border"> // The container is fixed-positioned by the shadcn primitive; offset it
<SidebarHeader className={cn("gap-0 p-0 px-[7px] group-data-[collapsible=icon]:px-1.5", !isMac && "pt-3.5")}> // below the 56px shell topbar so the bar runs edge-to-edge above it
{/* Titlebar strip: a draggable 40px inset under the traffic lights and // (same override as shadcn's header-above-sidebar block).
the fixed TitlebarNav overlay (rendered once by the shell), kept in <SidebarRoot collapsible="icon" className="border-border top-14 h-[calc(100svh-3.5rem)]!">
both sidebar states. */} <SidebarHeader className="gap-0 p-0 px-[7px] pt-3.5 group-data-[collapsible=icon]:px-1.5">
{isMac && <div className="h-10 shrink-0" style={dragStyle} />}
{/* Brand (project-sidebar__brand); in the icon rail it becomes the old {/* Brand (project-sidebar__brand); in the icon rail it becomes the old
36px board button wrapping the 22px accent mark. */} 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"> <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" /> <GitPullRequest aria-hidden="true" />
Pull requests Pull requests
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={selection.goReview}>
<Settings aria-hidden="true" />
Reviews
</DropdownMenuItem>
<DropdownMenuItem disabled> <DropdownMenuItem disabled>
<Search aria-hidden="true" /> <Search aria-hidden="true" />
Search Search
@ -316,6 +314,9 @@ function ProjectItem({
onNewWorker: () => void; onNewWorker: () => void;
}) { }) {
const projectActive = selection.activeProjectId === workspace.id && !selection.activeSessionId; 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 = () => { const onProjectClick = () => {
if (!expanded) { if (!expanded) {
@ -359,7 +360,7 @@ function ProjectItem({
<span className="hidden group-data-[collapsible=icon]:block">{workspace.name.charAt(0).toUpperCase()}</span> <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="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"> <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> </span>
</SidebarMenuButton> </SidebarMenuButton>
{/* project-sidebar__proj-actions — reveal over the count slot on hover */} {/* project-sidebar__proj-actions — reveal over the count slot on hover */}
@ -369,7 +370,12 @@ function ProjectItem({
showOnHover showOnHover
aria-label={`New worker in ${workspace.name}`} aria-label={`New worker in ${workspace.name}`}
onClick={onNewWorker} 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" /> <Plus className="h-[13px]! w-[13px]!" aria-hidden="true" />
</SidebarMenuAction> </SidebarMenuAction>
@ -377,10 +383,12 @@ function ProjectItem({
<TooltipContent>New worker in {workspace.name}</TooltipContent> <TooltipContent>New worker in {workspace.name}</TooltipContent>
</Tooltip> </Tooltip>
{/* project-sidebar__sessions */} {/* project-sidebar__sessions. Divergence from AO (user decision
{expanded && workerSessions(workspace.sessions).length > 0 && ( 2026-06-12): no left indent or tree guide line every sidebar row
<SidebarMenuSub className="mx-0 translate-x-0 gap-0 border-0 px-0 pb-2 pl-1 pt-0.5"> (project or worker) spans the same full width. */}
{workerSessions(workspace.sessions).map((session) => { {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; const active = selection.activeSessionId === session.id;
return ( return (
<SidebarMenuSubItem key={session.id}> <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 { ArrowLeft, ArrowRight, PanelLeft } from "lucide-react";
import { useEffect, useState } from "react";
import { useUiStore } from "../stores/ui-store"; import { useUiStore } from "../stores/ui-store";
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent); 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 // the traffic lights, VS Code-style. Approved divergence from the web
// reference, which has no window chrome (DESIGN.md banner, 2026-06-10). // 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) // 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 // over the full-width topbar's left inset, so the buttons occupy the exact
// or collapsed; the collapsed-rail topbars pad past it (.is-under-titlebar-nav). // 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() { export function TitlebarNav() {
const { isSidebarOpen, toggleSidebar } = useUiStore(); const { isSidebarOpen, toggleSidebar } = useUiStore();
const router = useRouter(); const router = useRouter();
const canGoBack = useCanGoBack(); const canGoBack = useCanGoBack();
// No useCanGoForward in the installed router; derive it from the history const canGoForward = useCanGoForward();
// 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,
});
if (!isMac) return null; 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 rootRouteImport } from "./routes/__root";
import { Route as ShellRouteImport } from "./routes/_shell"; import { Route as ShellRouteImport } from "./routes/_shell";
import { Route as ShellIndexRouteImport } from "./routes/_shell.index"; 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 ShellPrsRouteImport } from "./routes/_shell.prs";
import { Route as ShellSessionsSessionIdRouteImport } from "./routes/_shell.sessions.$sessionId"; import { Route as ShellSessionsSessionIdRouteImport } from "./routes/_shell.sessions.$sessionId";
import { Route as ShellProjectsProjectIdRouteImport } from "./routes/_shell.projects.$projectId"; import { Route as ShellProjectsProjectIdRouteImport } from "./routes/_shell.projects.$projectId";
@ -28,16 +26,6 @@ const ShellIndexRoute = ShellIndexRouteImport.update({
path: "/", path: "/",
getParentRoute: () => ShellRoute, getParentRoute: () => ShellRoute,
} as any); } 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({ const ShellPrsRoute = ShellPrsRouteImport.update({
id: "/prs", id: "/prs",
path: "/prs", path: "/prs",
@ -67,8 +55,6 @@ const ShellProjectsProjectIdSessionsSessionIdRoute = ShellProjectsProjectIdSessi
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
"/": typeof ShellIndexRoute; "/": typeof ShellIndexRoute;
"/prs": typeof ShellPrsRoute; "/prs": typeof ShellPrsRoute;
"/review": typeof ShellReviewRoute;
"/reviews": typeof ShellReviewsRoute;
"/projects/$projectId": typeof ShellProjectsProjectIdRoute; "/projects/$projectId": typeof ShellProjectsProjectIdRoute;
"/sessions/$sessionId": typeof ShellSessionsSessionIdRoute; "/sessions/$sessionId": typeof ShellSessionsSessionIdRoute;
"/projects/$projectId/settings": typeof ShellProjectsProjectIdSettingsRoute; "/projects/$projectId/settings": typeof ShellProjectsProjectIdSettingsRoute;
@ -76,8 +62,6 @@ export interface FileRoutesByFullPath {
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
"/prs": typeof ShellPrsRoute; "/prs": typeof ShellPrsRoute;
"/review": typeof ShellReviewRoute;
"/reviews": typeof ShellReviewsRoute;
"/": typeof ShellIndexRoute; "/": typeof ShellIndexRoute;
"/projects/$projectId": typeof ShellProjectsProjectIdRoute; "/projects/$projectId": typeof ShellProjectsProjectIdRoute;
"/sessions/$sessionId": typeof ShellSessionsSessionIdRoute; "/sessions/$sessionId": typeof ShellSessionsSessionIdRoute;
@ -88,8 +72,6 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport; __root__: typeof rootRouteImport;
"/_shell": typeof ShellRouteWithChildren; "/_shell": typeof ShellRouteWithChildren;
"/_shell/prs": typeof ShellPrsRoute; "/_shell/prs": typeof ShellPrsRoute;
"/_shell/review": typeof ShellReviewRoute;
"/_shell/reviews": typeof ShellReviewsRoute;
"/_shell/": typeof ShellIndexRoute; "/_shell/": typeof ShellIndexRoute;
"/_shell/projects/$projectId": typeof ShellProjectsProjectIdRoute; "/_shell/projects/$projectId": typeof ShellProjectsProjectIdRoute;
"/_shell/sessions/$sessionId": typeof ShellSessionsSessionIdRoute; "/_shell/sessions/$sessionId": typeof ShellSessionsSessionIdRoute;
@ -101,8 +83,6 @@ export interface FileRouteTypes {
fullPaths: fullPaths:
| "/" | "/"
| "/prs" | "/prs"
| "/review"
| "/reviews"
| "/projects/$projectId" | "/projects/$projectId"
| "/sessions/$sessionId" | "/sessions/$sessionId"
| "/projects/$projectId/settings" | "/projects/$projectId/settings"
@ -110,8 +90,6 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo; fileRoutesByTo: FileRoutesByTo;
to: to:
| "/prs" | "/prs"
| "/review"
| "/reviews"
| "/" | "/"
| "/projects/$projectId" | "/projects/$projectId"
| "/sessions/$sessionId" | "/sessions/$sessionId"
@ -121,8 +99,6 @@ export interface FileRouteTypes {
| "__root__" | "__root__"
| "/_shell" | "/_shell"
| "/_shell/prs" | "/_shell/prs"
| "/_shell/review"
| "/_shell/reviews"
| "/_shell/" | "/_shell/"
| "/_shell/projects/$projectId" | "/_shell/projects/$projectId"
| "/_shell/sessions/$sessionId" | "/_shell/sessions/$sessionId"
@ -150,20 +126,6 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof ShellIndexRouteImport; preLoaderRoute: typeof ShellIndexRouteImport;
parentRoute: typeof ShellRoute; 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": { "/_shell/prs": {
id: "/_shell/prs"; id: "/_shell/prs";
path: "/prs"; path: "/prs";
@ -204,8 +166,6 @@ declare module "@tanstack/react-router" {
interface ShellRouteChildren { interface ShellRouteChildren {
ShellPrsRoute: typeof ShellPrsRoute; ShellPrsRoute: typeof ShellPrsRoute;
ShellReviewRoute: typeof ShellReviewRoute;
ShellReviewsRoute: typeof ShellReviewsRoute;
ShellIndexRoute: typeof ShellIndexRoute; ShellIndexRoute: typeof ShellIndexRoute;
ShellProjectsProjectIdRoute: typeof ShellProjectsProjectIdRoute; ShellProjectsProjectIdRoute: typeof ShellProjectsProjectIdRoute;
ShellSessionsSessionIdRoute: typeof ShellSessionsSessionIdRoute; ShellSessionsSessionIdRoute: typeof ShellSessionsSessionIdRoute;
@ -215,8 +175,6 @@ interface ShellRouteChildren {
const ShellRouteChildren: ShellRouteChildren = { const ShellRouteChildren: ShellRouteChildren = {
ShellPrsRoute: ShellPrsRoute, ShellPrsRoute: ShellPrsRoute,
ShellReviewRoute: ShellReviewRoute,
ShellReviewsRoute: ShellReviewsRoute,
ShellIndexRoute: ShellIndexRoute, ShellIndexRoute: ShellIndexRoute,
ShellProjectsProjectIdRoute: ShellProjectsProjectIdRoute, ShellProjectsProjectIdRoute: ShellProjectsProjectIdRoute,
ShellSessionsSessionIdRoute: ShellSessionsSessionIdRoute, ShellSessionsSessionIdRoute: ShellSessionsSessionIdRoute,

View File

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

View File

@ -1,8 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
export type Theme = "light" | "dark"; 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. */ /** Worker detail view toggles — Changes (Git rail) is the default. */
export type WorkbenchTab = "changes" | "files" | "terminal"; export type WorkbenchTab = "changes" | "files" | "terminal";

View File

@ -280,14 +280,21 @@ body.is-resizing-x [data-slot="sidebar-container"] {
transition: none; 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 { .dashboard-app-header {
display: flex; display: flex;
height: 56px; height: 56px;
flex-shrink: 0; flex-shrink: 0;
align-items: center; align-items: center;
gap: 13px; gap: 13px;
padding: 0 20px; padding: 0 16px 0 14px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg); background: var(--bg);
z-index: 10; z-index: 10;
@ -309,35 +316,6 @@ body.is-resizing-x [data-slot="sidebar-container"] {
gap: 6px; 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 { .dashboard-app-header__spacer {
flex: 1; flex: 1;
min-width: 0; 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); 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 { .session-topbar__lead {
display: flex; display: flex;
min-width: 0; min-width: 0;
@ -425,16 +397,19 @@ body.is-resizing-x [data-slot="sidebar-container"] {
/* macOS titlebar cluster (TitlebarNav) /* macOS titlebar cluster (TitlebarNav)
* Fixed beside the traffic lights so the toggle/history buttons occupy the * 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); * exact same spot in both sidebar states. The 56px strip matches the
* 28px buttons in a 40px strip share that center, and the 6.5px icon inset * .dashboard-app-header height so the buttons share the topbar's center line
* puts the first glyph at ~76px the standard 10px gap after the lights. */ * (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 { .titlebar-nav {
position: fixed; position: fixed;
top: 0; top: 0;
left: 70px; left: 79px;
z-index: 20; z-index: 20;
display: flex; display: flex;
height: 40px; height: 56px;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
} }
@ -444,16 +419,11 @@ body.is-resizing-x [data-slot="sidebar-container"] {
height: 28px; height: 28px;
} }
/* Collapsed-rail topbars start their content past the fixed cluster: /* macOS: the full-width header starts its content past the lights + fixed
* cluster right edge 70 + 3×28 + 2×2 = 158px, + the header's 13px gap, * cluster: cluster right edge 79 + 3×28 + 2×2 = 167px, + the header's 13px
* the 48px icon rail = 123px. Transition mirrors the sidebar's width * group gap = 180px. */
* animation (200ms linear) so content tracks the moving sidebar edge. */
.dashboard-app-header {
transition: padding-left 0.2s linear;
}
.dashboard-app-header.is-under-titlebar-nav { .dashboard-app-header.is-under-titlebar-nav {
padding-left: 123px; padding-left: 180px;
} }
.topbar-project-pills-group { .topbar-project-pills-group {
@ -596,7 +566,7 @@ body.is-resizing-x [data-slot="sidebar-container"] {
left: 50%; left: 50%;
width: 1px; width: 1px;
transform: translateX(-50%); transform: translateX(-50%);
background: transparent; background: var(--border-1);
transition: background 0.12s ease; transition: background 0.12s ease;
} }

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
attentionZone, attentionZone,
findProjectOrchestrator,
sessionIsActive, sessionIsActive,
sessionNeedsAttention, sessionNeedsAttention,
toAgentProvider, toAgentProvider,
@ -10,6 +11,7 @@ import {
type AttentionZone, type AttentionZone,
type SessionStatus, type SessionStatus,
type WorkspaceSession, type WorkspaceSession,
type WorkspaceSummary,
} from "./workspace"; } from "./workspace";
function sessionWith(overrides: Partial<WorkspaceSession>): WorkspaceSession { 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", () => { describe("sessionNeedsAttention", () => {
it.each(["needs_input", "changes_requested", "review_pending", "ci_failed"] as const)("is true for %s", (status) => { it.each(["needs_input", "changes_requested", "review_pending", "ci_failed"] as const)("is true for %s", (status) => {
expect(sessionNeedsAttention(sessionWith({ status }))).toBe(true); 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"); 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( export function findProjectOrchestrator(
workspaces: WorkspaceSummary[], workspaces: WorkspaceSummary[],
projectId: string, projectId: string,
): WorkspaceSession | undefined { ): WorkspaceSession | undefined {
const workspace = workspaces.find((w) => w.id === projectId); 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[] { export function workerSessions(sessions: WorkspaceSession[]): WorkspaceSession[] {