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:
parent
7c97ee79cd
commit
09c16e5bf8
|
|
@ -53,3 +53,6 @@ Thumbs.db
|
|||
# electron-forge / vite build output
|
||||
.vite/
|
||||
dist-electron/
|
||||
|
||||
# playwright artifacts
|
||||
frontend/test-results/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,13 +111,35 @@ 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">
|
||||
<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}
|
||||
|
|
@ -127,6 +151,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ReviewDashboard } from "../components/ReviewDashboard";
|
||||
|
||||
export const Route = createFileRoute("/_shell/review")({
|
||||
component: ReviewDashboard,
|
||||
});
|
||||
|
|
@ -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" });
|
||||
},
|
||||
});
|
||||
|
|
@ -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,11 +152,19 @@ 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. */}
|
||||
{/* 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="h-screen min-h-0 bg-background text-foreground"
|
||||
className="min-h-0 flex-1"
|
||||
onOpenChange={(open) => open !== isSidebarOpen && toggleSidebar()}
|
||||
open={isSidebarOpen}
|
||||
style={
|
||||
|
|
@ -170,18 +179,21 @@ function ShellLayout() {
|
|||
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 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
|
||||
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. */}
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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=14–66 (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=14–66; 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
Loading…
Reference in New Issue