diff --git a/.gitignore b/.gitignore index d0b25c0..0a8225e 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ Thumbs.db # electron-forge / vite build output .vite/ dist-electron/ + +# playwright artifacts +frontend/test-results/ diff --git a/DESIGN.md b/DESIGN.md index 8987585..4778112 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/frontend/e2e/history-nav.spec.ts b/frontend/e2e/history-nav.spec.ts new file mode 100644 index 0000000..536d7d4 --- /dev/null +++ b/frontend/e2e/history-nav.spec.ts @@ -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/); +}); diff --git a/frontend/e2e/inspector-toggle.spec.ts b/frontend/e2e/inspector-toggle.spec.ts new file mode 100644 index 0000000..ff6460b --- /dev/null +++ b/frontend/e2e/inspector-toggle.spec.ts @@ -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(); +}); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 2d0d541..f0fe3de 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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, diff --git a/frontend/src/renderer/components/DashboardSubhead.tsx b/frontend/src/renderer/components/DashboardSubhead.tsx new file mode 100644 index 0000000..d732922 --- /dev/null +++ b/frontend/src/renderer/components/DashboardSubhead.tsx @@ -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 ( +
+

{title}

+ {typeof count === "number" && {count}} + {subtitle} +
+ ); +} diff --git a/frontend/src/renderer/components/DashboardTopbar.tsx b/frontend/src/renderer/components/DashboardTopbar.tsx deleted file mode 100644 index 1b631e8..0000000 --- a/frontend/src/renderer/components/DashboardTopbar.tsx +++ /dev/null @@ -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 ( -
-
-
- {projectLabel} - -
-
-
-
- {projectId ? ( - orchestrator ? ( - - ) : ( - - ) - ) : null} -
-
- ); -} - -// 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 ( -
-

{title}

- {typeof count === "number" && {count}} - {subtitle} -
- ); -} diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index 4a95827..43e2dc2 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -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 (
-
- = { - 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(""); - - 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 ( -
- - -
- - -
- -
- {reviews.isError ? ( -

Could not load reviews.

- ) : runs.length === 0 ? ( -

- No review runs yet. Pick a worker and Run review. -

- ) : ( -
- {runs.map((run) => ( - s.id === run.sessionId)?.title} - /> - ))} -
- )} -
-
- ); -} - -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 ( - - -
- {run.id} - {sessionTitle ?? run.sessionId} - - {run.status} - - {run.status !== "sent" && ( - - )} -
- {run.findings.length === 0 ? ( -

no findings

- ) : ( -
    - {run.findings.map((f) => ( -
  • - - {f.severity} - - - - {f.path}:{f.line} - {" "} - {f.body} - -
  • - ))} -
- )} -
-
- ); -} diff --git a/frontend/src/renderer/components/SessionView.test.tsx b/frontend/src/renderer/components/SessionView.test.tsx index 58e632c..0e4fc5f 100644 --- a/frontend/src/renderer/components/SessionView.test.tsx +++ b/frontend/src/renderer/components/SessionView.test.tsx @@ -42,12 +42,10 @@ const { workspaces, panels } = vi.hoisted(() => { return { workspaces, panels: new Map() }; }); -// 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: () =>
})); vi.mock("./SessionInspector", () => ({ SessionInspector: () =>
})); -vi.mock("./Topbar", () => ({ Topbar: () =>
})); -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 }) =>
{children}
, - ResizableHandle: () =>
, + ResizableHandle: ({ elementRef }: { elementRef?: Ref }) => ( +
{ + 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(); 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(); + 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(); 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(); + 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(); + + 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(); diff --git a/frontend/src/renderer/components/SessionView.tsx b/frontend/src/renderer/components/SessionView.tsx index f3c0a54..8f19ad3 100644 --- a/frontend/src/renderer/components/SessionView.tsx +++ b/frontend/src/renderer/components/SessionView.tsx @@ -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(null); + const inspectorSeparatorRef = useRef(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(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 (
- - workspace - ? void navigate({ to: "/projects/$projectId", params: { projectId: workspace.id } }) - : void navigate({ to: "/" }) - } - projectLabel={workspace?.name} - session={session} - view={isOrchestrator ? "orchestrator" : "session"} - /> {/* 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) { {hasInspector ? ( <> - + 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(); 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 (
-
@@ -109,24 +111,47 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
{done.length > 0 && ( -
-
- - Done - {done.length} -
-
- {done.map((s) => ( - - ))} -
+
+ {/* 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. */} + + {doneExpanded && ( +
+ {done.map((s) => ( + + ))} +
+ )}
)}
diff --git a/frontend/src/renderer/components/ShellTopbar.tsx b/frontend/src/renderer/components/ShellTopbar.tsx new file mode 100644 index 0000000..c1e378e --- /dev/null +++ b/frontend/src/renderer/components/ShellTopbar.tsx @@ -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 = { + 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 ( +
+
+ {isSessionRoute && isOrchestrator ? ( +
+
+ {projectLabel} + + + +
+
+ ) : isSessionRoute ? ( +
+
+
+ {session ? : null} +
+ ) : ( +
+ {projectLabel} +
+ )} +
+ +
+ +
+ {isSessionRoute ? ( + <> + + {/* Inspector collapse (worker sessions only — orchestrators have no rail). */} + {!isOrchestrator && ( + + )} + + ) : projectId ? ( + orchestrator ? ( + + ) : ( + + ) + ) : null} +
+
+ ); +} + +// 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 ( + + + {label} + + ); +} diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index 80137a0..45c645f 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -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 ( - - - {/* 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 &&
} - + // 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). + + {/* Brand (project-sidebar__brand); in the icon rail it becomes the old 36px board button wrapping the 22px accent mark. */}
@@ -241,10 +243,6 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj