From 472b106cd496976a048d6bc434f10529af06703d Mon Sep 17 00:00:00 2001 From: Dinesh Pandiyan Date: Mon, 19 Oct 2026 16:59:53 +1100 Subject: [PATCH 1/2] feat: share Projects and Diff navigation panel --- DESIGN.md | 64 +++++----- apps/web/src/ActivityBar.tsx | 43 ++++++- apps/web/src/App.tsx | 5 +- apps/web/src/FileExplorerNav.tsx | 17 ++- apps/web/src/Header.tsx | 37 +++--- apps/web/src/ProjectsNav.tsx | 114 +++++++----------- apps/web/src/main.tsx | 23 ++-- apps/web/src/project-nav.ts | 16 --- apps/web/src/projects-nav-context.tsx | 68 ----------- apps/web/src/session-context.tsx | 53 ++++++-- apps/web/src/style.css | 23 ++-- apps/web/src/use-sidebar.ts | 28 ++++- apps/web/test/browser/app.spec.ts | 79 +++++++----- apps/web/test/browser/local-workspace.spec.ts | 76 +++++++++--- apps/web/test/project-nav.test.ts | 25 ---- docs/development.md | 11 +- docs/system.md | 9 +- 17 files changed, 355 insertions(+), 336 deletions(-) delete mode 100644 apps/web/src/projects-nav-context.tsx diff --git a/DESIGN.md b/DESIGN.md index 69dd8f8e..259dc20d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -198,7 +198,7 @@ such detail into a token. New layout spacing must use the scale. islands, `--radius-lg` outer corners, and a 1px neutral outline without elevation. The activity rail and file sidebar form one continuous navigation island; notices, toolbar, viewer, and status bar form the review island. - The optional Projects column remains independently placed and rounded. + Projects and Diff share the navigation panel beside the activity rail. Keep these frames outside Pierre's measured file geometry. At compact widths, remove the empty navigation island and retain the inset review surface; modal drawers use the same inset and corners without clipping focus or menus. @@ -219,40 +219,34 @@ such detail into a token. New layout spacing must use the scale. delete actions at the trailing edge; share `--hover` feedback with the trigger. A local checkout process replaces Refresh with an icon-only “Sync all checkouts” action (folder-sync icon) that never swaps the open review. -- Header lead: the “Show/Hide projects” toggle (panel-left open/close icon), - brand and divider always start the header, in that order. The toggle sits in - an `--activity-width` box so it centers over the activity bar's icons. The - drawer pushes the header right; the lead never moves into the drawer or - reorders. Below 768px the lead uses the normal header gutter. +- Header lead: brand and divider identify the app. Desktop navigation controls + live in the activity rail. Below 768px, Projects and file-sidebar controls + remain in the header because the rail is hidden. - Activity bar: a fixed `--activity-width` column (48px; 52px for coarse - pointers) between the projects drawer and file sidebar, spanning the header's - bottom edge to the shell's bottom inset on the navigation island's `--panel`. - Keep the rail and file tree in one rounded island, separated by the file - sidebar's 1px `--border-muted` leading edge. Views - (Diff now; Files and Git later) are `--activity-item` square ghost icon - buttons with 20px icons, `--radius-md`, hover `--hover`, and the current view - in accent text on `--accent-bg` with `aria-current="page"`. Theme and account - actions sit at its bottom, as in an editor's activity bar. Below 768px the bar - hides and those actions return to the header's trailing edge. -- Projects drawer: the outermost full-height column, `--projects-width` (16rem) - on `--panel` with the shell's rounded outline. Its header matches `--topbar-height` and - holds the “Projects” panel title (UI label size, medium, secondary text), a - ghost “Browse all reviews” icon action that opens the review picker, and a - close action in the modal drawer only. Below it: a “Search projects” field - styled like the file filter that filters current streams in place (Escape - clears it; matching repositories expand), then repositories ordered by their - most active visible stream. Streams sort by estimated code activity, with - captured collection time as the fallback and stable identity ties. Repository - and stream ages describe this activity; snapshot history retains its chronology. - Repository rows match tree-row height with a chevron, repository icon, medium - name and muted monospace age. Stream rows indent under them: branch icon, name, - trailing monospace changed-file count, and a muted metadata line (worktree, - harness, host only when a repository has several, age). Show only current - heads with changes. The current stream uses `--accent-bg`, accent text and - `aria-current="page"`. Unopened arrivals show a small accent dot labeled “New - changes”; a collapsed repository carries its streams' dot. The file - sidebar toggle uses a list-tree icon so the two panel controls stay distinct. - Collapse state persists per browser. + pointers), spanning the workbench below the header to the shell's bottom inset. + Projects (repository icon) and Diff (file-diff icon) select one shared panel. + Clicking the active view toggles its panel; clicking another view opens it. + Keep the rail and selected panel in one rounded island, separated by a 1px + `--border-muted` edge. Views use `--activity-item` square ghost buttons, + 20px icons and `--radius-md`; selection uses accent text on `--accent-bg` + with `aria-current="page"`, while `aria-expanded` describes panel visibility. + Theme and account actions sit at the bottom. Below 768px those actions move + into the header. Below 1012px navigation panels become modal drawers. +- Projects panel: `--projects-width` (16rem) on `--panel`, sharing the file tree + and diff's top and bottom edges below the header. Its header matches + the compact sidebar tabs and display toolbar and holds the “Projects” title (UI label size, medium, + secondary text), a ghost magnifier labeled “Search reviews” that opens the + existing picker, and a close action in the modal drawer only. Search is + available through that popup or Cmd/Ctrl+K. Selecting a stream loads its diff + and switches the shared panel to Diff; mobile closes the drawer. + Repositories sort by their most active visible stream. Streams sort by + estimated code activity, with captured collection time as fallback and stable + identity ties. Rows retain compact tree density, truncation, ages and metadata. + Show only current heads with changes. The current stream uses `--accent-bg`, + accent text and `aria-current="page"`. Unopened arrivals show a small accent + dot labeled “New changes”; collapsed repositories carry their streams' dots. + Repository collapse state persists per browser. Switching views preserves + the open diff and review state; file-panel width remains user controlled. - A newer head for the viewed stream shows a one-line `--accent-bg` notice below the header with an outline-muted “View latest” action. Never swap the open review. - A compact header action counts unopened changed heads across projects and @@ -279,7 +273,7 @@ such detail into a token. New layout spacing must use the scale. `--layer-sidebar` / `--layer-resizer`; modal dialogs use the native top layer. The resize handle overlaps the navigation island's trailing edge and adds no layout width. Drag distance changes only the file sidebar width, regardless of - the Projects column or shell insets. The shell gutter separates navigation from + view switches or shell insets. The shell gutter separates navigation from the review island; the measured 12px diff gutter remains inside that island. ### Pierre boundary diff --git a/apps/web/src/ActivityBar.tsx b/apps/web/src/ActivityBar.tsx index 619c0476..8b952434 100644 --- a/apps/web/src/ActivityBar.tsx +++ b/apps/web/src/ActivityBar.tsx @@ -1,4 +1,10 @@ -import { FileDiffIcon, MonitorIcon, MoonIcon, SunIcon } from "lucide-react"; +import { + FileDiffIcon, + FolderGit2Icon, + MonitorIcon, + MoonIcon, + SunIcon, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -10,14 +16,22 @@ import { import { AccountButton } from "./account.tsx"; import { useAppearance } from "./appearance-context.tsx"; import { readThemePreference, type ThemePreference } from "./theme.ts"; +import { useSidebarState } from "./sidebar-context.tsx"; import { useCompactChrome } from "./use-compact-chrome.ts"; -// Workspace views; file and Git views join the diff view here. -const views = [{ id: "diff", label: "Diff", Icon: FileDiffIcon }]; +const views = [ + { id: "projects", label: "Projects", Icon: FolderGit2Icon }, + { id: "diff", label: "Diff", Icon: FileDiffIcon }, +] satisfies { + id: "projects" | "diff"; + label: string; + Icon: typeof FileDiffIcon; +}[]; /** Fixed view switcher left of the file sidebar, hidden in compact chrome. */ -export function ActivityBar() { +export function ActivityBar({ diffReady = true }: { diffReady?: boolean }) { const compact = useCompactChrome(); + const sidebar = useSidebarState(); if (compact) return null; return ( ); } - -/** Theme and account actions: activity bar bottom, or header when compact. */ -export function ShellActions({ side }: { side: "right" | "bottom" }) { - return ( - <> - - - - ); -} - -function themeLabel(theme: ThemePreference) { - if (theme === "light") return "Light"; - if (theme === "dark") return "Dark"; - return "System"; -} - -function ThemeMenu({ side }: { side: "right" | "bottom" }) { - const { themePreference, setThemePreference } = useAppearance(); - return ( - - - } - > - {themePreference === "system" ? ( - - - - setThemePreference(readThemePreference(value)) - } - > - - - - - - - - - - ); -} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0d3cb375..54f8992d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,10 +1,10 @@ -import { ActivityBar } from "./ActivityBar.tsx"; import { useReviewState } from "./app-state.tsx"; import { OutdatedCommentsNotice } from "./CommentsPanel.tsx"; import { DiffWorkspace } from "./DiffWorkspace.tsx"; -import { FileExplorerNav, SidebarResizer } from "./FileExplorerNav.tsx"; +import { FileExplorerNav } from "./FileExplorerNav.tsx"; import { Header } from "./Header.tsx"; -import { NewerSnapshotNotice, ProjectsNav } from "./ProjectsNav.tsx"; +import { Navigation } from "./Navigation.tsx"; +import { NewerSnapshotNotice } from "./ProjectsNav.tsx"; import { CopyDialog } from "./review.tsx"; import { capabilityEnabled, useSession } from "./session-context.tsx"; import { useSidebarState } from "./sidebar-context.tsx"; @@ -17,12 +17,9 @@ export function App() {
-
- - + -
- +
diff --git a/apps/web/src/FileExplorerNav.tsx b/apps/web/src/FileExplorerNav.tsx index 160bf2b4..039bfd35 100644 --- a/apps/web/src/FileExplorerNav.tsx +++ b/apps/web/src/FileExplorerNav.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from "react"; +import { useMemo } from "react"; import { XIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -233,47 +233,3 @@ export function FileExplorerNav() { ); } - -export function SidebarResizer() { - const sidebar = useSidebarState(); - const drag = useRef({ x: 0, width: sidebar.width }); - return ( - { - if (event.button !== 0) return; - event.preventDefault(); - drag.current = { x: event.clientX, width: sidebar.width }; - event.currentTarget.setPointerCapture(event.pointerId); - document.body.classList.add("resizing-sidebar"); - }} - onPointerMove={(event) => { - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - sidebar.setWidth(drag.current.width + event.clientX - drag.current.x); - } - }} - onPointerUp={(event) => { - if (event.currentTarget.hasPointerCapture(event.pointerId)) - event.currentTarget.releasePointerCapture(event.pointerId); - }} - onLostPointerCapture={() => - document.body.classList.remove("resizing-sidebar") - } - onKeyDown={(event) => { - if (event.key === "ArrowLeft" || event.key === "ArrowRight") { - event.preventDefault(); - sidebar.setWidth( - sidebar.width + (event.key === "ArrowRight" ? 20 : -20), - ); - } - }} - /> - ); -} diff --git a/apps/web/src/Header.tsx b/apps/web/src/Header.tsx index 1ebe15f9..dfdc5bf3 100644 --- a/apps/web/src/Header.tsx +++ b/apps/web/src/Header.tsx @@ -1,7 +1,7 @@ import { FolderSyncIcon, ListTreeIcon, RefreshCwIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { ShellActions } from "./ActivityBar.tsx"; -import { DeleteContextButton } from "./DeleteContextButton.tsx"; +import { AccountButton } from "./account.tsx"; +import { ThemeButton } from "./ThemeButton.tsx"; import { HeaderBrand, NewChangesButton, @@ -66,7 +66,6 @@ export function Header() {
- {collect ? ( )} - {compact && } + {compact && } +
); diff --git a/apps/web/src/Navigation.tsx b/apps/web/src/Navigation.tsx new file mode 100644 index 00000000..addf821b --- /dev/null +++ b/apps/web/src/Navigation.tsx @@ -0,0 +1,65 @@ +import { useRef, type ReactNode } from "react"; +import { ActivityBar } from "./ActivityBar.tsx"; +import { ProjectsNav } from "./ProjectsNav.tsx"; +import { useSidebarState } from "./sidebar-context.tsx"; + +/** The shared navigation frame owns panel width and its resize boundary. */ +export function Navigation({ children }: { children?: ReactNode }) { + const sidebar = useSidebarState(); + const diffReady = Boolean(children); + const expanded = sidebar.expanded && (sidebar.view !== "diff" || diffReady); + return ( +
+ + +
+ ); +} + +function NavigationResizer({ hidden }: { hidden: boolean }) { + const sidebar = useSidebarState(); + const drag = useRef({ x: 0, width: sidebar.width }); + return ( + { + if (event.button !== 0) return; + event.preventDefault(); + drag.current = { x: event.clientX, width: sidebar.width }; + event.currentTarget.setPointerCapture(event.pointerId); + document.body.classList.add("resizing-sidebar"); + }} + onPointerMove={(event) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + sidebar.setWidth(drag.current.width + event.clientX - drag.current.x); + } + }} + onPointerUp={(event) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) + event.currentTarget.releasePointerCapture(event.pointerId); + }} + onLostPointerCapture={() => + document.body.classList.remove("resizing-sidebar") + } + onKeyDown={(event) => { + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + sidebar.setWidth( + sidebar.width + (event.key === "ArrowRight" ? 20 : -20), + ); + } + }} + /> + ); +} diff --git a/apps/web/src/ProjectsNav.tsx b/apps/web/src/ProjectsNav.tsx index 61ae34c2..525f3528 100644 --- a/apps/web/src/ProjectsNav.tsx +++ b/apps/web/src/ProjectsNav.tsx @@ -32,7 +32,7 @@ const shortcut = navigator.platform.includes("Mac") ? "⌘K" : "Ctrl K"; export function ProjectsToggle() { const sidebar = useSidebarState(); const expanded = sidebar.view === "projects" && sidebar.expanded; - const action = expanded ? "Hide projects" : "Show projects"; + const action = expanded ? "Hide repos" : "Show repos"; return (
- + ); } @@ -302,10 +302,10 @@ function ProjectsEmpty({ >

{collecting - ? "Collecting projects…" + ? "Collecting repos…" : loaded ? "No current changes" - : "Loading projects…"} + : "Loading repos…"}

{loaded && !collecting && (

diff --git a/apps/web/src/ThemeButton.tsx b/apps/web/src/ThemeButton.tsx new file mode 100644 index 00000000..6d252939 --- /dev/null +++ b/apps/web/src/ThemeButton.tsx @@ -0,0 +1,63 @@ +import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useAppearance } from "./appearance-context.tsx"; +import { readThemePreference, type ThemePreference } from "./theme.ts"; + +function themeLabel(theme: ThemePreference) { + if (theme === "light") return "Light"; + if (theme === "dark") return "Dark"; + return "System"; +} + +export function ThemeButton() { + const { themePreference, setThemePreference } = useAppearance(); + return ( + + + } + > + {themePreference === "system" ? ( + + + + setThemePreference(readThemePreference(value)) + } + > + + + + + + + + + + ); +} diff --git a/apps/web/src/review.css b/apps/web/src/review.css index 4ba2e94d..6c688e6a 100644 --- a/apps/web/src/review.css +++ b/apps/web/src/review.css @@ -237,15 +237,15 @@ } #sidebar-resizer { /* Overlap the pane divider without shifting the viewer's alignment. */ - margin: 0 -2px 0 -3px; + margin: 0; width: 5px; height: auto; align-self: stretch; border: 0; - flex: 0 0 5px; cursor: col-resize; z-index: var(--layer-resizer); - position: relative; + position: absolute; + inset: 0 -2px 0 auto; background: transparent; touch-action: none; } diff --git a/apps/web/src/session-context.tsx b/apps/web/src/session-context.tsx index 8cd54b47..9f0aa403 100644 --- a/apps/web/src/session-context.tsx +++ b/apps/web/src/session-context.tsx @@ -14,15 +14,16 @@ import { useRef, useState, } from "react"; -import { ActivityBar, ShellActions } from "./ActivityBar.tsx"; +import { AccountButton } from "./account.tsx"; +import { ThemeButton } from "./ThemeButton.tsx"; import { useIdentity, navigate } from "./identity-context.tsx"; import { Button } from "@/components/ui/button"; import { ContextIdentity } from "./ContextIdentity.tsx"; import { ProjectPicker } from "./ProjectPicker.tsx"; +import { Navigation } from "./Navigation.tsx"; import { HeaderBrand, NewChangesButton, - ProjectsNav, ProjectsToggle, } from "./ProjectsNav.tsx"; import { latestChangedContext } from "./context-activity.ts"; @@ -457,14 +458,12 @@ export function SessionProvider({ children }: { children: ReactNode }) {

- {compact && } + {compact && } +
-
- - -
+
{statusError || (missing - ? "This stream's snapshots expired or were deleted. Pick current work from Projects." + ? "This stream's snapshots expired or were deleted. Pick current work from Repos." : empty ? remote ? "Create a collector token in settings, then run diffx sync to publish your changes." diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 14a90b1d..084cc263 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -99,11 +99,24 @@ kbd { .topbar { height: var(--topbar-height); display: flex; - gap: var(--space-2); + gap: var(--space-1-5); align-items: center; background: var(--shell-canvas); - padding: 0 calc(var(--diff-gutter) + var(--diff-viewport-inset, 0px)) 0 - var(--diff-gutter); + padding: 0 0 0 var(--diff-gutter); +} +/* Center mixed UI/mono text by cap height; padding preserves clipped descenders. */ +.topbar + :is( + .brand > span, + .context-identity-name, + .context-identity-branch, + .context-identity-separator, + .context-identity-worktree-name, + .header-heading h1:not(.sr-only), + #repo-path + ) { + text-box: trim-both cap alphabetic; + padding-block: var(--space-2); } /* Compact entry point when the activity rail is hidden. */ .topbar-lead { @@ -220,6 +233,16 @@ kbd { .navigation-island { display: flex; min-height: 0; + position: relative; +} +.navigation-panel { + display: flex; + flex: 0 0 var(--sidebar-width); + width: var(--sidebar-width); + min-height: 0; +} +.navigation-panel[hidden] { + display: none; } .workspace, .workbench > main { @@ -274,8 +297,8 @@ kbd { color: var(--accent); } .projects-nav { - flex: 0 0 var(--projects-width); - width: var(--projects-width); + flex: 1; + width: 100%; min-height: 0; display: flex; flex-direction: column; @@ -455,8 +478,8 @@ kbd { white-space: nowrap; } .sidebar { - flex: 0 0 var(--sidebar-width); - width: var(--sidebar-width); + flex: 1; + width: 100%; min-height: 0; overflow: auto; display: flex; @@ -997,6 +1020,9 @@ h2 { background: var(--backdrop); } @media (max-width: 1011px) { + .navigation-panel:not([hidden]) { + display: contents; + } .mobile-review-progress { display: inline; flex-shrink: 0; diff --git a/apps/web/src/tokens.css b/apps/web/src/tokens.css index e2d0ce0d..0e14043e 100644 --- a/apps/web/src/tokens.css +++ b/apps/web/src/tokens.css @@ -63,7 +63,7 @@ --control-small: 1.75rem; --control-large: 2.5rem; --touch-target: 2.75rem; - --topbar-height: 2.5rem; + --topbar-height: calc(var(--control-small) + 2 * var(--space-1)); --toolbar-height: 2.75rem; --statusbar-height: calc( var(--text-xs) * var(--leading-ui) + 2 * var(--space-2) + 1px @@ -72,7 +72,6 @@ --diff-gutter: var(--space-3); --shell-gutter: var(--space-1-5); --sidebar-width: 330px; - --projects-width: 16rem; --activity-item: var(--control-large); --activity-width: calc(var(--activity-item) + 2 * var(--space-1)); --reading-width: 42.5rem; diff --git a/apps/web/test/browser/app.spec.ts b/apps/web/test/browser/app.spec.ts index 6b962fef..0cc7fa7e 100644 --- a/apps/web/test/browser/app.spec.ts +++ b/apps/web/test/browser/app.spec.ts @@ -1369,6 +1369,21 @@ test("header icons and sidebar tab indicator use design-system sizes", async ({ const view = page.getByRole("button", { name: "Diff", exact: true }); await expect(page.locator("#file-count")).not.toHaveText("0"); await expect(brand).toHaveText("diffx"); + await expect(page.locator(".activity-views button")).toHaveCount(2); + expect( + await page + .locator(".activity-views button") + .evaluateAll((buttons) => + buttons.map((button) => button.getAttribute("aria-label")), + ), + ).toEqual(["Diff", "Repos"]); + await expect( + page.locator(".topbar .header-actions > :last-child"), + ).toHaveAttribute("id", "theme"); + await expect(page.locator(".activity-bar #theme")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Delete snapshot", exact: true }), + ).toHaveCount(0); await expect(view).toBeVisible(); await expect( page.locator(".topbar #projects-toggle, .topbar #sidebar-toggle"), @@ -1376,7 +1391,7 @@ test("header icons and sidebar tab indicator use design-system sizes", async ({ const filter = page.getByRole("searchbox", { name: "Filter files" }); await filter.fill("value"); const before = await brand.boundingBox(); - await page.getByRole("button", { name: "Projects", exact: true }).click(); + await page.getByRole("button", { name: "Repos", exact: true }).click(); await expect(page.locator("#projects-nav")).toBeVisible(); await expect(page.locator("#sidebar")).toBeHidden(); const after = await brand.boundingBox(); @@ -1448,12 +1463,12 @@ test("header icons and sidebar tab indicator use design-system sizes", async ({ expect(styles).toMatchObject({ refresh: "16px", - theme: "20px", + theme: "16px", view: "20px", tabBorderWidth: "1px", indicatorOpacity: "1", dividerHeight: 16, - headerHeight: 40, + headerHeight: 36, }); expect(styles.identity).toBe(styles.viewOptions); expect(styles.chromeStrokes.every((stroke) => stroke === "1.7px")).toBe(true); @@ -1461,44 +1476,122 @@ test("header icons and sidebar tab indicator use design-system sizes", async ({ expect(styles.indicatorColor).toBe(styles.accent); }); -test("sidebar resizer preserves drag distance across shell offsets", async ({ +test("header text stays centered and Theme reaches the shell edge", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.locator("#file-count")).not.toHaveText("0"); + for (const width of [1627, 800, 390]) { + await page.setViewportSize({ width, height: 900 }); + // A native diff scrollbar must not pull shell actions inward. + await page + .locator("html") + .evaluate((element) => + element.style.setProperty("--diff-viewport-inset", "17px"), + ); + const geometry = await page.locator(".topbar").evaluate((header) => { + const box = header.getBoundingClientRect(); + const theme = header.querySelector("#theme"); + if (!theme) throw new Error("Missing Theme action"); + const themeBox = theme.getBoundingClientRect(); + const labels = Array.from( + header.querySelectorAll( + ".brand > span, .context-identity-name, .context-identity-branch, .context-identity-separator, .context-identity-worktree-name, #repo-path", + ), + ) + .filter((element) => element.getBoundingClientRect().width > 0) + .map((element) => { + const label = element.getBoundingClientRect(); + const range = document.createRange(); + range.selectNodeContents(element); + const text = range.getBoundingClientRect(); + return { + boxOffset: Math.abs( + label.y + label.height / 2 - (box.y + box.height / 2), + ), + textOffset: Math.abs( + text.y + text.height / 2 - (box.y + box.height / 2), + ), + topClearance: text.top - label.top, + bottomClearance: label.bottom - text.bottom, + }; + }); + return { + rightInset: box.right - themeBox.right, + iconOffset: Math.abs( + themeBox.y + themeBox.height / 2 - (box.y + box.height / 2), + ), + labels, + }; + }); + expect(geometry.rightInset).toBe(0); + expect(geometry.iconOffset).toBe(0); + expect(geometry.labels.length).toBeGreaterThan(0); + for (const label of geometry.labels) { + expect(label.boxOffset).toBeLessThanOrEqual(0.5); + expect(label.textOffset).toBeLessThanOrEqual(1); + expect(label.topClearance).toBeGreaterThanOrEqual(0); + expect(label.bottomClearance).toBeGreaterThanOrEqual(0); + } + } +}); + +test("shared navigation resizer preserves width across Diff and Repos", async ({ page, }) => { await page.goto("/"); const resizer = page.getByRole("separator", { - name: "Resize file sidebar", + name: "Resize navigation panel", }); + const panel = page.locator(".navigation-panel"); await expect(resizer).toBeVisible(); const paneGap = () => - page.locator("main").evaluate((element) => { - const sidebar = document.querySelector("#sidebar"); - if (!sidebar) throw new Error("Missing sidebar"); + page.locator(".workspace").evaluate((element) => { + const navigation = document.querySelector(".navigation-island"); + if (!navigation) throw new Error("Missing shared navigation"); return ( element.getBoundingClientRect().left - - sidebar.getBoundingClientRect().right + navigation.getBoundingClientRect().right ); }); - expect(await paneGap()).toBe(6); - for (const switchViews of [false, true]) { - if (switchViews) { - await page.getByRole("button", { name: "Projects", exact: true }).click(); - await expect(resizer).toBeHidden(); - await page.getByRole("button", { name: "Diff", exact: true }).click(); - } - const box = await resizer.boundingBox(); - expect(box?.height ?? 0).toBeGreaterThan(100); + const initialWidth = Number(await resizer.getAttribute("aria-valuenow")); + for (const view of ["Diff", "Repos"]) { + if (view === "Repos") + await page.getByRole("button", { name: view, exact: true }).click(); + await expect(resizer).toBeVisible(); const before = Number(await resizer.getAttribute("aria-valuenow")); - if (!box) throw new Error("Missing sidebar resizer bounds"); + await expect(panel).toHaveCSS("width", `${before}px`); + const box = await resizer.boundingBox(); + if (!box) throw new Error("Missing navigation resizer bounds"); + const island = await page.locator(".navigation-island").boundingBox(); + if (!island) throw new Error("Missing navigation bounds"); + expect(box.height).toBe(island.height); + expect(box.x + 3).toBe(island.x + island.width); await page.mouse.move(box.x + box.width / 2, box.y + 20); await page.mouse.down(); await page.mouse.move(box.x + box.width / 2 + 40, box.y + 20); await page.mouse.up(); await expect(resizer).toHaveAttribute("aria-valuenow", String(before + 40)); + await expect(panel).toHaveCSS("width", `${before + 40}px`); expect(await paneGap()).toBe(6); await resizer.focus(); await page.keyboard.press("ArrowLeft"); - await expect(resizer).toHaveAttribute("aria-valuenow", String(before + 20)); + await expect(panel).toHaveCSS("width", `${before + 20}px`); } + const resizedWidth = initialWidth + 40; + await page.getByRole("button", { name: "Diff", exact: true }).click(); + await expect(panel).toHaveCSS("width", `${resizedWidth}px`); + await page.reload(); + await expect(resizer).toHaveAttribute("aria-valuenow", String(resizedWidth)); + await expect(panel).toHaveCSS("width", `${resizedWidth}px`); + await page.getByRole("button", { name: "Repos", exact: true }).click(); + await expect(panel).toHaveCSS("width", `${resizedWidth}px`); + await page.getByRole("button", { name: "Repos", exact: true }).click(); + await expect(resizer).toBeHidden(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("button", { name: "Show repos" }).click(); + await expect(page.getByRole("dialog", { name: "Repos" })).toBeVisible(); + await expect(resizer).toBeHidden(); }); test("copy dialog reports clipboard status and restores focus", async ({ @@ -1815,9 +1908,6 @@ test("drawer aligns its header and retains desktop navigation density", async ({ const toolbar = await page.locator(".toolbar").boundingBox(); const theme = await page.locator("#theme").boundingBox(); const options = await page.locator("#view-options").boundingBox(); - const remove = await page - .getByRole("button", { name: "Delete snapshot", exact: true }) - .boundingBox(); const collapse = await page.locator("#toggle-all-files").boundingBox(); expect(header.height).toBe(44); expect(toolbar?.height).toBe(header.height); @@ -1825,8 +1915,8 @@ test("drawer aligns its header and retains desktop navigation density", async ({ expect(theme?.height).toBe(44); expect(options?.width).toBe(theme?.width); expect(options?.height).toBe(theme?.height); - expect(options?.x).toBe(remove?.x); - expect(collapse?.x).toBe(theme?.x); + expect(theme && theme.x + theme.width).toBe(header.x + header.width); + expect(collapse?.width).toBe(theme?.width); } await page .locator("#sidebar") diff --git a/apps/web/test/browser/local-workspace.spec.ts b/apps/web/test/browser/local-workspace.spec.ts index bea61e64..4ec2384b 100644 --- a/apps/web/test/browser/local-workspace.spec.ts +++ b/apps/web/test/browser/local-workspace.spec.ts @@ -366,183 +366,6 @@ test("snapshot stays selected after newer captures and branch transitions", asyn await expect(page).toHaveURL(/\/contexts\/clean-other-branch\?watch=/); }); -async function deletableWorkspace( - page: Page, - snapshot = false, - single = false, -) { - const state = await localWorkspace(page); - if (snapshot) state.context.source = "stdin"; - const contexts = [ - state.context, - { ...state.context, id: "other", name: "Other review" }, - ]; - if (single) contexts.splice(1); - const deletion = { - requests: new Array(), - fail: false, - hold: false, - pending: new Array(), - }; - await page.route("**/api/v2/contexts?*", (route) => - route.fulfill({ json: { contexts, nextCursor: null } }), - ); - await page.route("**/api/v2/contexts/*", (route) => { - const id = new URL(route.request().url()).pathname.split("/").at(-1); - if (route.request().method() === "DELETE") { - if (!id) throw new Error("Missing deletion ID"); - deletion.requests.push(id); - if (deletion.fail) - return route.fulfill({ - status: 500, - json: { detail: "Storage unavailable. Try again." }, - }); - const index = contexts.findIndex((context) => context.id === id); - if (index >= 0) contexts.splice(index, 1); - if (deletion.hold) { - deletion.pending.push(route); - return; - } - return route.fulfill({ status: 204 }); - } - const context = contexts.find((context) => context.id === id); - return context - ? route.fulfill({ json: context }) - : route.fulfill({ status: 404, json: { detail: "Context not found" } }); - }); - await page.goto("/"); - await ready(page); - return { ...deletion, contexts, state, deletion }; -} - -test("snapshot deletion confirms its scope and cancel restores focus without deleting", async ({ - page, -}) => { - const state = await deletableWorkspace(page); - const trigger = page.getByRole("button", { - name: "Delete snapshot", - exact: true, - }); - await trigger.click(); - const dialog = page.getByRole("dialog", { - name: "Delete snapshot?", - exact: true, - }); - await expect(dialog).toContainText("this snapshot"); - await expect(dialog).toContainText("comments and reviewed-file marks"); - await expect(dialog).toContainText("Local review"); - await expect( - dialog.getByRole("button", { name: "Cancel", exact: true }), - ).toBeFocused(); - await page.keyboard.press("Escape"); - await expect(dialog).toBeHidden(); - await expect(trigger).toBeFocused(); - expect(state.requests).toHaveLength(0); - await expect(page).toHaveURL(/\/contexts\/local-review$/); -}); - -test("snapshot deletion navigates to another review and removes the snapshot from the picker", async ({ - page, -}) => { - const state = await deletableWorkspace(page, true); - await page - .getByRole("button", { name: "Delete snapshot", exact: true }) - .click(); - const dialog = page.getByRole("dialog", { - name: "Delete snapshot?", - exact: true, - }); - await dialog - .getByRole("button", { name: "Delete snapshot", exact: true }) - .click(); - await expect(page).toHaveURL(/\/contexts\/other$/); - await ready(page); - await expect(dialog).toBeHidden(); - expect(state.requests).toEqual(["local-review"]); - await expect( - page.getByRole("button", { name: "Diff", exact: true }), - ).toBeFocused(); - await openReviewPicker(page); - await expect(page.getByRole("option")).toHaveCount(1); - await expect(page.getByRole("option")).toContainText("Piped"); - await page.getByRole("option").click(); - await expect(page.getByRole("option")).toHaveCount(1); - await expect(page.getByRole("option")).toHaveAttribute( - "title", - /Context: other/, - ); -}); - -test("deletion failure stays retryable and pending deletion blocks dismissal and duplicate writes", async ({ - page, -}) => { - const { deletion } = await deletableWorkspace(page); - deletion.fail = true; - await page - .getByRole("button", { name: "Delete snapshot", exact: true }) - .click(); - const dialog = page.getByRole("dialog", { - name: "Delete snapshot?", - exact: true, - }); - await dialog - .getByRole("button", { name: "Delete snapshot", exact: true }) - .click(); - await expect(dialog.getByRole("alert")).toHaveText( - "Storage unavailable. Try again.", - ); - await expect(page).toHaveURL(/\/contexts\/local-review$/); - deletion.fail = false; - deletion.hold = true; - await dialog - .getByRole("button", { name: "Delete snapshot", exact: true }) - .click(); - await expect( - dialog.getByRole("button", { name: "Deleting…", exact: true }), - ).toBeDisabled(); - await expect( - dialog.getByRole("button", { name: "Cancel", exact: true }), - ).toBeDisabled(); - await page.keyboard.press("Escape"); - await expect(dialog).toBeVisible(); - expect(deletion.requests).toEqual(["local-review", "local-review"]); - const pending = deletion.pending.shift(); - if (!pending) throw new Error("Missing deletion request"); - await pending.fulfill({ status: 204 }); - await expect(page).toHaveURL(/\/contexts\/other$/); -}); - -test("deleting the last context shows a focused empty state on mobile", async ({ - page, -}) => { - await deletableWorkspace(page, false, true); - await page.setViewportSize({ width: 360, height: 640 }); - await page - .getByRole("button", { name: "Delete snapshot", exact: true }) - .click(); - const dialog = page.getByRole("dialog", { - name: "Delete snapshot?", - exact: true, - }); - const bounds = await dialog.boundingBox(); - if (!bounds) throw new Error("Missing deletion dialog bounds"); - expect(bounds.x).toBeGreaterThanOrEqual(0); - expect(bounds.x + bounds.width).toBeLessThanOrEqual(360); - expect(bounds.y + bounds.height).toBeLessThanOrEqual(640); - await dialog - .getByRole("button", { name: "Delete snapshot", exact: true }) - .click(); - await expect(page).toHaveURL(/\/$/); - await expect( - page.getByRole("heading", { name: "No review contexts yet" }), - ).toBeVisible(); - await expect(page.locator("main.session-status")).toBeFocused(); - await page.reload(); - await expect( - page.getByRole("heading", { name: "No review contexts yet" }), - ).toBeVisible(); -}); - test("rapid scopes ignore old diff responses and reviewed-file failures", async ({ page, }) => { @@ -1189,38 +1012,6 @@ test("empty contexts remain accessible through the picker and direct URLs", asyn await expect(page).toHaveURL(/\/contexts\/local-review$/); }); -test("deleting the last diff returns to landing when empty contexts remain", async ({ - page, -}) => { - const state = await deletableWorkspace(page, false, true); - state.contexts.push({ - ...state.state.context, - id: "empty", - changedFileCount: 0, - }); - await page.evaluate(() => - document.dispatchEvent(new Event("visibilitychange")), - ); - await openReviewPicker(page); - const picker = page.getByRole("dialog", { name: "Switch review" }); - await picker.getByRole("button", { name: "Filters", exact: true }).click(); - await picker.getByRole("checkbox", { name: "All", exact: true }).check(); - await expect(picker.getByRole("option")).toContainText("2 snapshots"); - await page.keyboard.press("Escape"); - await page - .getByRole("button", { name: "Delete snapshot", exact: true }) - .click(); - await page - .getByRole("dialog", { name: "Delete snapshot?", exact: true }) - .getByRole("button", { name: "Delete snapshot", exact: true }) - .click(); - await expect(page).toHaveURL(/\/$/); - await expect( - page.getByRole("heading", { name: "No changes to review" }), - ).toBeVisible(); - await expect(page.locator("main.session-status")).toBeFocused(); -}); - test("unavailable context keeps the switcher usable", async ({ page }) => { const state = await localWorkspace(page); const unavailable: ApiContext = { @@ -1559,7 +1350,7 @@ test("picker combines filters before grouping and preserves them across navigati await expect(unavailable).toHaveAttribute("aria-selected", "false"); }); -test("projects drawer lists current changed streams and announces newer snapshots", async ({ +test("repos drawer lists current changed streams and announces newer snapshots", async ({ page, }) => { await page.addInitScript(() => { @@ -1618,7 +1409,7 @@ test("projects drawer lists current changed streams and announces newer snapshot await page.goto("/contexts/rate-1"); await ready(page); expect(latestRequests).toContain("true"); - await page.getByRole("button", { name: "Projects", exact: true }).click(); + await page.getByRole("button", { name: "Repos", exact: true }).click(); const nav = page.locator("#projects-nav"); await expect( nav.getByRole("button", { name: /^(web-app|acme-api)/ }), @@ -1646,7 +1437,7 @@ test("projects drawer lists current changed streams and announces newer snapshot await notice.getByRole("button", { name: "View latest" }).click(); await expect(page).toHaveURL(/\/streams\/stream-feat%2Frate-limit$/); await expect(notice).toBeHidden(); - await page.getByRole("button", { name: "Projects", exact: true }).click(); + await page.getByRole("button", { name: "Repos", exact: true }).click(); await nav.getByRole("link", { name: /^main/ }).click(); await expect(page).toHaveURL(/\/streams\/stream-main$/); @@ -1656,7 +1447,7 @@ test("projects drawer lists current changed streams and announces newer snapshot page.getByRole("button", { name: "Diff", exact: true }), ).toBeFocused(); const projectsView = page.getByRole("button", { - name: "Projects", + name: "Repos", exact: true, }); await projectsView.click(); @@ -1680,14 +1471,12 @@ test("projects drawer lists current changed streams and announces newer snapshot await page.reload(); await ready(page); await expect(page.locator("#sidebar")).toBeHidden(); - await expect(page.getByRole("navigation", { name: "Projects" })).toBeHidden(); - await page.getByRole("button", { name: "Projects", exact: true }).click(); - await expect( - page.getByRole("navigation", { name: "Projects" }), - ).toBeVisible(); + await expect(page.getByRole("navigation", { name: "Repos" })).toBeHidden(); + await page.getByRole("button", { name: "Repos", exact: true }).click(); + await expect(page.getByRole("navigation", { name: "Repos" })).toBeVisible(); await page.setViewportSize({ width: 390, height: 844 }); - const mobileTrigger = page.getByRole("button", { name: "Show projects" }); + const mobileTrigger = page.getByRole("button", { name: "Show repos" }); await mobileTrigger.click(); await expect(nav).toBeVisible(); await expect(nav).toHaveAttribute("role", "dialog"); @@ -1858,8 +1647,8 @@ for (const mode of ["local", "remote"]) { name: /^Open latest new changes/, }); await expect(global).toHaveCount(0); - await page.getByRole("button", { name: "Projects", exact: true }).click(); - const nav = page.getByRole("navigation", { name: "Projects" }); + await page.getByRole("button", { name: "Repos", exact: true }).click(); + const nav = page.getByRole("navigation", { name: "Repos" }); await expect(nav.locator(".project-name")).toHaveText(["api", "web"]); const newerApi = snapshot("api-new", "api", 200, 900); @@ -1920,7 +1709,7 @@ for (const mode of ["local", "remote"]) { ); await page.setViewportSize({ width: 1440, height: 900 }); await page.emulateMedia({ colorScheme: "light" }); - await page.getByRole("button", { name: "Projects", exact: true }).click(); + await page.getByRole("button", { name: "Repos", exact: true }).click(); await expect(nav.locator(".project-name")).toHaveText(["api", "web"]); await expect(global).toHaveCount(0); expect(collections).toBe(0); @@ -1975,9 +1764,12 @@ test("local first launch waits for collection with an empty catalog", async ({ const diffView = page.getByRole("button", { name: "Diff", exact: true }); await expect(diffView).toBeDisabled(); await expect(diffView).not.toHaveAttribute("aria-controls"); - await page.getByRole("button", { name: "Projects", exact: true }).click(); - const projects = page.getByRole("navigation", { name: "Projects" }); - await expect(projects).toContainText("Collecting projects…"); + await page.getByRole("button", { name: "Repos", exact: true }).click(); + const projects = page.getByRole("navigation", { name: "Repos" }); + await expect(projects).toContainText("Collecting repos…"); + await expect( + page.getByRole("separator", { name: "Resize navigation panel" }), + ).toBeVisible(); await expect(projects).not.toContainText("No current changes"); await expect(projects).not.toContainText("Agent hooks and diffx sync"); await expect( diff --git a/apps/web/test/browser/native-scrollbars.spec.ts b/apps/web/test/browser/native-scrollbars.spec.ts index 72eb78a7..d82d9426 100644 --- a/apps/web/test/browser/native-scrollbars.spec.ts +++ b/apps/web/test/browser/native-scrollbars.spec.ts @@ -80,17 +80,22 @@ test("diff tracks fill frames and headers align with their gutters", async ({ await expect .poll(() => files.first().evaluate((element) => { - // The trailing header action; theme moves to the activity bar on desktop. + // Theme anchors to the shell; diff actions retain their measured gutter. const theme = Array.from( document.querySelectorAll(".header-actions > *"), ) .filter((action) => action.getClientRects().length > 0) .at(-1); const toggle = document.querySelector("#toggle-all-files"); - if (!theme || !toggle) throw new Error("Missing shell actions"); + const header = document.querySelector(".topbar"); + if (!theme || !toggle || !header) + throw new Error("Missing shell actions"); const edge = element.getBoundingClientRect().right; return Math.max( - Math.abs(theme.getBoundingClientRect().right - edge), + Math.abs( + theme.getBoundingClientRect().right - + header.getBoundingClientRect().right, + ), Math.abs(toggle.getBoundingClientRect().right - edge), ); }), diff --git a/docs/development.md b/docs/development.md index d7a05161..928c90c4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -259,11 +259,12 @@ navigation, draft, review, reviewed files, and collapse state. Collapsed diff files (per stream and scope) and closed tree folders (per stream) persist in `sessionStorage`, so reloads and newer snapshots keep them within a tab; piped snapshots key by context. One draft persists -across scopes. Context selection uses the Projects view and the popup picker (Cmd/Ctrl+K -or the Projects “Search reviews” magnifier). Projects and Diff share the panel +across scopes. Context selection uses the Repos view and the popup picker (Cmd/Ctrl+K +or the Repos “Search reviews” magnifier). Repos and Diff share the panel beside the activity rail; selecting a stream loads its diff and switches to the file tree. Clicking the active view toggles the panel. `use-sidebar.ts` owns -view selection, visibility, width and mobile drawer focus. Compact header +view selection, visibility, shared width and mobile drawer focus. +`Navigation.tsx` owns the common frame and resize handle for both views. Compact header controls remain available when the rail is hidden; the header shows the open review's identity. The selected context scopes every request. The session owner exposes local checkout sync when bootstrap reports `collect`; loading `/` or a live @@ -315,8 +316,9 @@ repository grouping. Unavailable entries are disabled and skipped during keyboar navigation. Filter selections persist while navigating and reopening the picker. Switching disposes the previous workspace's request ownership and resets transient state. -The header's delete action removes the selected snapshot, all -its stored diff scopes, comments, and reviewed-file marks after confirmation. +The compact header keeps Theme at its trailing edge and exposes no snapshot +delete action. Snapshot deletion through the API removes its stored diff scopes, +comments, and reviewed-file marks. Deletion preserves stream freshness history; older snapshots remain stale. It leaves Git files untouched, and a new collection can create a review again. The footer shows the selected scope's actual baseline. Its Diff comparison popover diff --git a/docs/system.md b/docs/system.md index cf7e4e32..b8406f50 100644 --- a/docs/system.md +++ b/docs/system.md @@ -97,7 +97,7 @@ freshness follows collection time and acceptance sequence. ### Browse and review -Navigation is project-driven. The Projects drawer queries +Navigation is project-driven. The Repos drawer queries `/api/v2/contexts?latest=true`, which omits stale snapshots, and groups stream heads by repository. A stream is one owner, source, repository, checkout, branch and comparison policy; every context carries its opaque `streamId` and the @@ -110,7 +110,7 @@ existing heads as seen; opening a head clears its mark. Ingestion events reload the heads; when the viewed context's stream has a newer head, a notice offers it without replacing the open review. URLs are live or pinned: `/streams/{streamId}` opens the stream's latest head when loaded or navigated to, while -`/contexts/{contextId}` pins one snapshot. Projects rows and “View latest” open +`/contexts/{contextId}` pins one snapshot. Repos rows and “View latest” open live URLs; the picker opens pinned ones; piped snapshots have no stream and stay pinned. A live URL resolves once, so later arrivals raise the notice instead of swapping the open review. With local collection enabled, loading `/` or a live @@ -120,10 +120,11 @@ for `/`, the most recently active changed one; a failed sync opens the stored head and reports the error. Otherwise `/` opens the most recently active changed head's live URL. Startup selection and the local default MCP context use the same activity ordering. -Projects and Diff share one navigation panel beside the activity rail, aligned +Repos and Diff share one navigation panel beside the activity rail, aligned with the diff below the header. Selecting a stream loads its diff and opens the -Diff file tree; clicking the active rail icon collapses its panel. Compact -layouts keep drawer controls in the header. The Projects magnifier opens the +Diff file tree; clicking the active rail icon collapses its panel. Diff appears +first, then Repos; both views share one saved width and parent resize handle. Compact +layouts keep drawer controls in the header. The Repos magnifier opens the review picker; the header names the open review. An explicit local sync, like an arrival, raises the notice instead of replacing the open review. @@ -148,7 +149,7 @@ head selection. Unchanged submissions can update ordering without producing an unread-content indicator. Piped history and the full catalog retain their existing collection/submission chronology. -The review picker, opened by Cmd/Ctrl+K or the Projects “Search reviews” +The review picker, opened by Cmd/Ctrl+K or the Repos “Search reviews” action, loads the full catalog only while open. It selects a repository, then an observation identified by worktree, branch, source, run and time. Distinct content from the same worktree remains independently selectable; unchanged