diff --git a/DESIGN.md b/DESIGN.md index 69dd8f8e..00c96746 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -181,8 +181,10 @@ component internals; layout remains aligned to the 4px rhythm. | Large conceptual separation | 32 or 48, rarely needed in review chrome | Use `--page-gutter` for notices and footer: 24 desktop, 16 below -1012px, 12 below 768px. Header, toolbar, and file frames share `--diff-gutter` -(12px). Their trailing actions account for the viewport's native scrollbar inset. +1012px, 12 below 768px. Toolbar and file frames share `--diff-gutter` +(12px), accounting for the viewport's native scrollbar inset. Header leading +content uses the same gutter; its rightmost Theme action anchors directly to +the shell's right edge, independently of diff scrolling. Use explicit margins instead of relying on browser-default paragraph spacing. Custom values are acceptable only for documented optical or renderer geometry: @@ -198,61 +200,63 @@ 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. + Repos 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. -- Header: `--topbar-height` (40 desktop, 44 narrow or coarse pointer). Keep +- Header: `--topbar-height` (36 desktop, 44 narrow or coarse pointer). Keep desktop controls at 32px, header icons at 16px, the divider at 16px, and group - spacing at 8px so the header recedes into the editor chrome. Toolbar and sidebar tabs: `--toolbar-height` - (44) as the desktop baseline. The narrow toolbar matches the 44px page header; + spacing at 6px so the header recedes into the editor chrome. Toolbar and sidebar + tabs share the compact 36px desktop rhythm (`--control-small` plus two + `--space-1` insets). The narrow toolbar matches the 44px page header; its icon buttons match the header's 44px touch targets and 16px icons. Scope controls fill the row without adding border/padding height; abbreviate the visible “All changes” label to “All”, retaining its full accessible name. - Header and display-toolbar icon actions share borderless ghost buttons, 16px icons, and 1.7px strokes, including menu and dialog triggers. Hover and expanded states use the same neutral surface feedback. On mobile, both rows - use 8px action gaps and the same trailing inset so icon columns align. + use 8px action gaps; the toolbar retains its diff gutter while Theme reaches + the header's right edge. - Header uses `--shell-canvas`, a quiet brand, and the review identity as its primary context. Keep the secondary scope heading and muted monospace path inline; hide the path at 768–1011px and both below 768px. Group ghost Refresh and - delete actions at the trailing edge; share `--hover` feedback with the trigger. + sync actions at the trailing edge; share `--hover` feedback with the trigger. + Theme is the rightmost header action in every layout, with the same ghost + styling, 32px desktop hit area and 16px icon. Keep its outer edge flush with + the shell; never apply the diff scrollbar inset to header actions. Snapshot deletion is absent + from the header. 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 text uses cap-height trimming with token padding to center UI and + monospace labels without clipping ascenders or descenders. +- Header lead: brand and divider identify the app. Desktop navigation controls + live in the activity rail. Below 768px, Repos 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. + Diff (file-diff icon) followed by Repos (repository 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. + Account actions sit at the bottom. Below 768px account actions move + into the header before the rightmost theme control. Below 1012px navigation panels become modal drawers. +- Repos panel: shared navigation width 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 “Repos” 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; both views share one user-controlled width. - 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 @@ -263,7 +267,8 @@ such detail into a token. New layout spacing must use the scale. labels; preserve the header's review identity and compact-layout controls. - Status bar: `--statusbar-height` (28), with shortcuts shed before server metrics. - The diff display toolbar uses `--panel`, matching the header and status bar. -- Desktop sidebar: `--sidebar-width` defaults to 330px. `use-sidebar.ts` owns +- Desktop navigation panel: `--sidebar-width` defaults to 330px. `Navigation.tsx` owns + the shared frame and resize handle for Diff and Repos; `use-sidebar.ts` owns the user's pixel width, bounded to 200–520px and available viewport space. Do not add competing CSS defaults or override a saved width at tablet sizes. - Sidebar interiors share a 16px inset. Tree rows use 8px base padding plus @@ -278,8 +283,8 @@ such detail into a token. New layout spacing must use the scale. - Keep overlays anchored to their owning region. Sidebar/resizer layering uses `--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 + layout width. Drag distance changes the shared navigation panel width, regardless of + 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 @@ -415,7 +420,7 @@ such detail into a token. New layout spacing must use the scale. branch, and repository icon. Give the repository name priority when long context labels truncate. Piped reviews use a terminal icon, “Piped”, and collection time; omit repository, branch, worktree, and directory identity. - Switching lives in the projects drawer; never add a dropdown chevron. + Switching lives in the repos drawer; never add a dropdown chevron. Cmd/Ctrl+K and the drawer's browse action open the searchable “Switch review” dialog. Position it at `--topbar-height` plus `--space-6` on desktop (64px default), `--space-4` on @@ -487,7 +492,7 @@ Do not add motion for decoration. New animation must honor Keep the linked-worktree icon visible when its text label is hidden. - Collapse navigation to the toggle-controlled modal drawer; its width is `min(320px, 88vw)`. Keep file selection, comments, and export reachable there. - The projects drawer follows the same modal pattern from its own toggle; opening + The repos drawer follows the same modal pattern from its own toggle; opening it closes the file drawer, and choosing a stream closes it. Its tab strip matches `--topbar-height`, including the tablet header height. File/folder rows, filter, summary toggle, and review progress retain desktop diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 35911e4d..5cfe880d 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -24,7 +24,7 @@ A local review workspace that combines a browser-based Git diff with comments th - Reviews can cover all, staged, or unstaged changes. The file tree, reviewed-file marks, and comments help track progress. - Comments can be copied as agent-ready XML. The same review comments are available to compatible coding agents through MCP and, in supported browsers, WebMCP. - The CLI can also display a piped Git patch as a fixed review. -- A remote diffx server can collect from many agent sessions across many repositories. The Projects drawer keeps that work navigable: each repository lists only its current changed branches, newest first, and new arrivals are marked without replacing the review in progress. +- A remote diffx server can collect from many agent sessions across many repositories. The Repos drawer keeps that work navigable: each repository lists only its current changed branches, newest first, and new arrivals are marked without replacing the review in progress. - Review data is stored by the local diffx server, as documented in the README. ## Capabilities and Constraints diff --git a/apps/web/src/ActivityBar.tsx b/apps/web/src/ActivityBar.tsx index 619c0476..450d6213 100644 --- a/apps/web/src/ActivityBar.tsx +++ b/apps/web/src/ActivityBar.tsx @@ -1,23 +1,22 @@ -import { FileDiffIcon, MonitorIcon, MoonIcon, SunIcon } from "lucide-react"; +import { FileDiffIcon, FolderGit2Icon } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; 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: "diff", label: "Diff", Icon: FileDiffIcon }, + { id: "projects", label: "Repos", Icon: FolderGit2Icon }, +] 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 196ae114..54f8992d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,9 +1,9 @@ -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 { Navigation } from "./Navigation.tsx"; import { NewerSnapshotNotice } from "./ProjectsNav.tsx"; import { CopyDialog } from "./review.tsx"; import { capabilityEnabled, useSession } from "./session-context.tsx"; @@ -17,11 +17,9 @@ export function App() {
-
- + -
- +
@@ -35,7 +33,7 @@ export function App() { function SidebarBackdrop() { const sidebar = useSidebarState(); - return sidebar.mobile && sidebar.open ? ( + return sidebar.mobile && sidebar.open && sidebar.view === "diff" ? ( + {compact && } + + {compact && ( + + )}

- {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 3ac56afa..525f3528 100644 --- a/apps/web/src/ProjectsNav.tsx +++ b/apps/web/src/ProjectsNav.tsx @@ -4,16 +4,12 @@ import { FolderGit2Icon, GitBranchIcon, HistoryIcon, - ListIcon, - PanelLeftCloseIcon, - PanelLeftOpenIcon, SearchIcon, SquareTerminalIcon, XIcon, } from "lucide-react"; import { useEffect, useState, type MouseEvent } from "react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { save, saved } from "./preferences.ts"; import { contextChangeLabel, contextIsPiped } from "./project-picker.ts"; @@ -22,42 +18,45 @@ import { newerHead, projectGroups, relativeAge, - searchProjectGroups, streamKey, streamMeta, streamTitle, type ProjectGroup, } from "./project-nav.ts"; -import { useProjectsNavState } from "./projects-nav-context.tsx"; import { streamPath, useCatalog, useSession } from "./session-context.tsx"; import { useSidebarState } from "./sidebar-context.tsx"; const shortcut = navigator.platform.includes("Mac") ? "⌘K" : "Ctrl K"; -/** Topbar start: projects toggle above the activity bar, brand and divider. */ +/** Compact entry point; desktop view controls live in the activity rail. */ export function ProjectsToggle() { - const projects = useProjectsNavState(); - const action = projects.expanded ? "Hide projects" : "Show projects"; - const Icon = projects.expanded ? PanelLeftCloseIcon : PanelLeftOpenIcon; + const sidebar = useSidebarState(); + const expanded = sidebar.view === "projects" && sidebar.expanded; + const action = expanded ? "Hide repos" : "Show repos"; + return ( +
+ +
+ ); +} + +export function HeaderBrand() { return ( <> -
- -
- diffx + diffx @@ -87,21 +86,14 @@ function readCollapsedGroups() { } export function ProjectsNav() { - const projects = useProjectsNavState(); const sidebar = useSidebarState(); const { heads, current, openStream, openPicker, seen, collecting } = useCatalog(); const [closed, setClosed] = useState(readCollapsedGroups); - const [query, setQuery] = useState(""); const now = useNow(); - const searching = query.trim() !== ""; - const allGroups = projectGroups(heads.contexts, seen); - const groups = searchProjectGroups(allGroups, query); + const groups = projectGroups(heads.contexts, seen); const currentStream = current ? streamKey(current) : ""; - const { closeMobile: closeFiles } = sidebar; - useEffect(() => { - if (projects.open) closeFiles(); - }, [projects.open, closeFiles]); + const expanded = sidebar.view === "projects" && sidebar.expanded; useEffect(() => { save("projects-closed-groups", JSON.stringify([...closed])); }, [closed]); @@ -115,74 +107,58 @@ export function ProjectsNav() { if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return; event.preventDefault(); - openStream(head, { restoreFocus: false }); - projects.closeMobile(); + openStream(head); }; return ( <> - {projects.mobile && projects.open && ( + {sidebar.mobile && sidebar.open && sidebar.view === "projects" && ( -
-
-

- {searching - ? `${groups.reduce((sum, group) => sum + group.streams.length, 0)} matching streams` - : ""} -

{groups.length ? (
    @@ -190,7 +166,7 @@ export function ProjectsNav() { toggleGroup(group.id)} currentStream={currentStream} now={now} @@ -198,10 +174,6 @@ export function ProjectsNav() { /> ))}
- ) : searching && allGroups.length ? ( -
-

No matching projects

-
) : ( )}
- + ); } @@ -330,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/main.tsx b/apps/web/src/main.tsx index 6b18dc16..ec745c48 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -3,7 +3,6 @@ import { StrictMode, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App.tsx"; import { AppearanceProvider } from "./appearance-context.tsx"; -import { ProjectsNavProvider } from "./projects-nav-context.tsx"; import { SidebarProvider } from "./sidebar-context.tsx"; import { AppProvider } from "./app-state.tsx"; import { highlighterOptions, poolOptions } from "./diff-workers.ts"; @@ -46,18 +45,16 @@ function ApplicationRoutes() { if (path === "/settings" && state.bootstrap.mode === "remote") return ; return ( - - - - - - - - - + + + + + + + ); } diff --git a/apps/web/src/project-nav.ts b/apps/web/src/project-nav.ts index 900b8a60..0b8770eb 100644 --- a/apps/web/src/project-nav.ts +++ b/apps/web/src/project-nav.ts @@ -3,9 +3,7 @@ import { compareContextActivity, contextActivity } from "./context-activity.ts"; import { contextHasChanges, contextIsPiped, - contextMatches, contextTimestamp, - searchWords, } from "./project-picker.ts"; // The projects drawer shows only current work: each stream's head with changes. @@ -91,20 +89,6 @@ export function projectGroups(heads: ApiContext[], seen: SeenStreams | null) { }); } -/** Keeps streams whose metadata matches every search word, preserving order. */ -export function searchProjectGroups(groups: ProjectGroup[], query: string) { - const words = searchWords(query); - if (!words.length) return groups; - return groups - .map((group) => ({ - ...group, - streams: group.streams.filter((stream) => - contextMatches(stream.context, words).every((score) => score > 0), - ), - })) - .filter((group) => group.streams.length); -} - function streamHost(context: ApiContext) { return context.observation?.hostname ?? context.observation?.sourceId ?? ""; } diff --git a/apps/web/src/projects-nav-context.tsx b/apps/web/src/projects-nav-context.tsx deleted file mode 100644 index 14953349..00000000 --- a/apps/web/src/projects-nav-context.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useState, - type ReactNode, -} from "react"; -import { save, saved } from "./preferences.ts"; -import { useModalDrawer } from "./use-modal-drawer.ts"; - -// Desktop keeps a persisted collapsible column; below 1012px the drawer is modal. -function useProjectsNav() { - const [mobile, setMobile] = useState( - () => matchMedia("(max-width: 1011px)").matches, - ); - const [open, setOpen] = useState(false); - const [collapsed, setCollapsed] = useState( - () => saved("projects-collapsed") === "true", - ); - useEffect(() => { - const media = matchMedia("(max-width: 1011px)"); - const update = () => { - setMobile(media.matches); - setOpen(false); - }; - media.addEventListener("change", update); - const onEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); - }; - document.addEventListener("keydown", onEscape); - return () => { - media.removeEventListener("change", update); - document.removeEventListener("keydown", onEscape); - }; - }, []); - useModalDrawer(mobile && open, "#projects-nav", "#projects-toggle"); - useEffect(() => { - save("projects-collapsed", String(collapsed)); - }, [collapsed]); - const toggle = useCallback(() => { - if (mobile) setOpen((value) => !value); - else setCollapsed((value) => !value); - }, [mobile]); - const closeMobile = useCallback(() => setOpen(false), []); - return { - mobile, - open, - expanded: mobile ? open : !collapsed, - toggle, - closeMobile, - }; -} - -const ProjectsNavContext = createContext | null>(null); - -export function ProjectsNavProvider({ children }: { children: ReactNode }) { - const projects = useProjectsNav(); - return {children}; -} - -export function useProjectsNavState() { - const projects = useContext(ProjectsNavContext); - if (!projects) throw new Error("Page components require ProjectsNavProvider"); - return projects; -} 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 43697186..9f0aa403 100644 --- a/apps/web/src/session-context.tsx +++ b/apps/web/src/session-context.tsx @@ -14,19 +14,22 @@ 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"; import { unseenChangedHeads, type SeenStreams } from "./project-nav.ts"; import { useSeenStreams } from "./use-seen-streams.ts"; +import { useSidebarState } from "./sidebar-context.tsx"; import { useCompactChrome } from "./use-compact-chrome.ts"; import { useContextCatalog, @@ -149,25 +152,39 @@ export function SessionProvider({ children }: { children: ReactNode }) { const statusRef = useRef(null); const restorePickerFocus = useRef(false); const compact = useCompactChrome(); + const { openDiff } = useSidebarState(); const openPicker = useCallback(() => setPickerOpen(true), []); const show = useCallback( ( next: Route, path: string, id: string, - options?: { restoreFocus?: boolean; replace?: boolean }, + options?: { + restoreFocus?: boolean; + replace?: boolean; + preserveNavigation?: boolean; + }, ) => { + if (!options?.preserveNavigation) openDiff(); if (options?.replace || path === window.location.pathname) window.history.replaceState(null, "", path); else window.history.pushState(null, "", path); setRoute(next); setMissing(false); - if (id === selectedRef.current) return; + if (id === selectedRef.current) { + if (options?.restoreFocus !== false) + requestAnimationFrame(() => + document + .querySelector("#diff-view, #sidebar-toggle") + ?.focus(), + ); + return; + } restorePickerFocus.current = options?.restoreFocus ?? true; setSelectedId(id); setState({ status: "loading" }); }, - [], + [openDiff], ); const select = useCallback( (id: string, options?: { restoreFocus?: boolean }) => @@ -177,7 +194,11 @@ export function SessionProvider({ children }: { children: ReactNode }) { const openStream = useCallback( ( head: ApiContext, - options?: { restoreFocus?: boolean; replace?: boolean }, + options?: { + restoreFocus?: boolean; + replace?: boolean; + preserveNavigation?: boolean; + }, ) => show( head.streamId @@ -299,7 +320,12 @@ export function SessionProvider({ children }: { children: ReactNode }) { route.kind === "live" ? contexts.find((context) => context.streamId === route.stream) : latestChangedContext(contexts); - if (head) openStream(head, { replace: true, restoreFocus: false }); + if (head) + openStream(head, { + replace: true, + restoreFocus: false, + preserveNavigation: true, + }); else setUncollected(route); }); }, [collectOnLoad, route, collectLatest, openStream]); @@ -310,7 +336,12 @@ export function SessionProvider({ children }: { children: ReactNode }) { route.kind === "live" ? heads.contexts.find((context) => context.streamId === route.stream) : latestChangedContext(heads.contexts); - if (head) openStream(head, { replace: true, restoreFocus: false }); + if (head) + openStream(head, { + replace: true, + restoreFocus: false, + preserveNavigation: true, + }); else if (route.kind === "live") setMissing(true); }, [ resolving, @@ -409,29 +440,30 @@ export function SessionProvider({ children }: { children: ReactNode }) { ); const target = selected?.getClientRects().length ? selected - : document.querySelector("#projects-toggle"); + : document.querySelector("#diff-view, #sidebar-toggle"); target?.focus(); } }, [state, empty]); return (

-
{ready ? ( {children} ) : ( <>
- + {compact && } +
- {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 70309f3f..084cc263 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -99,12 +99,26 @@ 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 0; -} -/* Centers the projects toggle over the activity bar so it never moves. */ + 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 { display: flex; justify-content: center; @@ -219,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 { @@ -273,20 +297,21 @@ 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; background: var(--panel); - border-radius: var(--radius-lg); - outline: 1px solid var(--border); + border-left: 1px solid var(--border-muted); + border-radius: 0 var(--radius-lg) var(--radius-lg) 0; } +.sidebar[hidden], .projects-nav[hidden] { display: none; } .projects-nav-header { - height: var(--topbar-height); + height: calc(var(--control-small) + 2 * var(--space-1)); flex-shrink: 0; display: flex; align-items: center; @@ -298,9 +323,6 @@ kbd { .projects-nav-header button svg { stroke-width: 1.7; } -.projects-search.search-box { - margin-top: var(--space-3); -} .projects-heading { flex: 1; min-width: 0; @@ -314,7 +336,7 @@ kbd { flex: 1; min-height: 0; overflow: auto; - padding: 0 var(--space-2) var(--space-4); + padding: var(--space-2) var(--space-2) var(--space-4); } .projects-list, .stream-list { @@ -456,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; @@ -998,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; @@ -1020,6 +1045,9 @@ h2 { .sidebar .mobile-only { display: inline-flex; } + .projects-nav-header { + height: var(--topbar-height); + } .projects-nav .mobile-only { display: inline-flex; } @@ -1028,6 +1056,8 @@ h2 { z-index: var(--layer-sidebar); inset: var(--shell-gutter) auto var(--shell-gutter) var(--shell-gutter); width: min(320px, 88vw); + border-radius: var(--radius-lg); + outline: 1px solid var(--border); box-shadow: var(--shadow-overlay); } } @@ -1056,7 +1086,6 @@ h2 { } .topbar-lead { width: auto; - padding-left: var(--diff-gutter); } .topbar .header-divider, .topbar .brand, 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/src/use-sidebar.ts b/apps/web/src/use-sidebar.ts index fdcd4a8d..d80f4e5e 100644 --- a/apps/web/src/use-sidebar.ts +++ b/apps/web/src/use-sidebar.ts @@ -2,11 +2,14 @@ import { useCallback, useEffect, useState } from "react"; import { save, saved } from "./preferences.ts"; import { useModalDrawer } from "./use-modal-drawer.ts"; -// React owns both the desktop preferences and the temporary mobile overlay. +export type WorkspaceView = "projects" | "diff"; + +// One owner for workspace views, desktop preferences and mobile overlays. export function useSidebar() { const [mobile, setMobile] = useState( () => matchMedia("(max-width: 1011px)").matches, ); + const [view, setView] = useState("diff"); const [open, setOpen] = useState(false); const [collapsed, setCollapsed] = useState( () => saved("sidebar-collapsed") === "true", @@ -31,7 +34,13 @@ export function useSidebar() { document.removeEventListener("keydown", onEscape); }; }, []); - useModalDrawer(mobile && open, "#sidebar", "#sidebar-toggle"); + useModalDrawer( + mobile && open, + view === "projects" ? "#projects-nav" : "#sidebar", + view === "projects" + ? "#projects-toggle, #projects-view" + : "#sidebar-toggle, #diff-view", + ); useEffect(() => { save("sidebar-collapsed", String(collapsed)); }, [collapsed]); @@ -46,22 +55,31 @@ export function useSidebar() { [], ); const show = useCallback(() => { + setView("diff"); if (mobile) setOpen(true); else setCollapsed(false); }, [mobile]); + const openDiff = useCallback(() => { + setView("diff"); + setOpen(false); + setCollapsed(false); + }, []); const closeMobile = useCallback(() => setOpen(false), []); return { mobile, + view, open, collapsed, width, show, closeMobile, expanded: mobile ? open : !collapsed, - toggle: () => { - if (mobile) setOpen((value) => !value); - else setCollapsed((value) => !value); + selectView: (next: WorkspaceView) => { + setView(next); + if (mobile) setOpen(view === next ? !open : true); + else setCollapsed(view === next ? !collapsed : false); }, + openDiff, setWidth: (next: number) => setWidthState( Math.max(200, Math.min(520, window.innerWidth - 320, next)), diff --git a/apps/web/test/browser/app.spec.ts b/apps/web/test/browser/app.spec.ts index 129ed29a..0cc7fa7e 100644 --- a/apps/web/test/browser/app.spec.ts +++ b/apps/web/test/browser/app.spec.ts @@ -309,7 +309,7 @@ test("scrolling selects the top file only when the previous diff leaves", async await expect(firstDiff.locator("[data-diffs-header]")).toBeVisible(); await first.click(); const boundary = await firstDiff.evaluate((element) => { - const scroller = document.querySelector(".diff-code-view"); + const scroller = document.querySelector(".diff-code-view"); if (!scroller) throw new Error("Missing diff scroller"); return ( element.getBoundingClientRect().bottom - @@ -425,14 +425,17 @@ test("file frames preserve measured gutters, gaps, and virtual geometry", async ) .toEqual(Array.from({ length: 11 }, () => spacing)); const gutters = await containers.first().evaluate((element) => { - const scroller = document.querySelector(".diff-code-view"); + const scroller = document.querySelector(".diff-code-view"); if (!scroller) throw new Error("Missing diff scroller"); const file = element.getBoundingClientRect(); const viewport = scroller.getBoundingClientRect(); return { top: file.top - viewport.top, left: file.left - viewport.left, - right: viewport.left + scroller.clientWidth - file.right, + right: + viewport.right - + (scroller.offsetWidth - scroller.clientWidth) - + file.right, }; }); expect(gutters).toEqual({ top: spacing, left: spacing, right: spacing }); @@ -1362,34 +1365,55 @@ test("header icons and sidebar tab indicator use design-system sizes", async ({ page, }) => { await page.goto("/"); - // The projects toggle and brand keep their header order; opening the drawer - // shifts them right with the header instead of moving them into the drawer. - const toggle = page.locator("#projects-toggle"); const brand = page.locator(".topbar .brand"); - const view = page.locator(".activity-bar [aria-current='page']"); - // The loading shell's header is replaced once the review is ready. - await expect(page.locator("#sidebar-toggle")).toBeVisible(); + 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(); - const lead = async () => { - const [toggleBox, brandBox, viewBox] = await Promise.all([ - toggle.boundingBox(), - brand.boundingBox(), - view.boundingBox(), - ]); - if (!toggleBox || !brandBox || !viewBox) throw new Error("Missing lead"); - return { toggle: toggleBox, brand: brandBox, view: viewBox }; - }; - const open = await lead(); - await page.getByRole("button", { name: "Hide projects" }).click(); + await expect( + page.locator(".topbar #projects-toggle, .topbar #sidebar-toggle"), + ).toHaveCount(0); + const filter = page.getByRole("searchbox", { name: "Filter files" }); + await filter.fill("value"); + const before = await brand.boundingBox(); + 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(); + expect(after).toEqual(before); + const projects = await page.locator("#projects-nav").boundingBox(); + const workspace = await page.locator(".workspace").boundingBox(); + if (!projects || !workspace) throw new Error("Missing workspace panels"); + expect(projects.y).toBe(workspace.y); + expect(projects.height).toBe(workspace.height); + await view.click(); + await expect(page.locator("#sidebar")).toBeVisible(); await expect(page.locator("#projects-nav")).toBeHidden(); - const closed = await lead(); - expect(closed.brand.x - closed.toggle.x).toBe(open.brand.x - open.toggle.x); - expect(closed.toggle.y).toBe(open.toggle.y); - for (const { toggle, view } of [open, closed]) - expect( - Math.abs(toggle.x + toggle.width / 2 - (view.x + view.width / 2)), - ).toBeLessThanOrEqual(0.5); + await expect(filter).toHaveValue("value"); + await expect(view).toHaveAttribute("aria-expanded", "true"); + await view.click(); + await expect(page.locator("#sidebar")).toBeHidden(); + await expect(view).toHaveAttribute("aria-expanded", "false"); + await view.click(); + const files = await page.locator("#sidebar").boundingBox(); + if (!files) throw new Error("Missing file panel"); + expect(files.y).toBe(workspace.y); + expect(files.height).toBe(workspace.height); await expect(page.locator('img[src="/logo.png"]')).toHaveCount(0); const styles = await page.evaluate(() => { @@ -1412,7 +1436,6 @@ test("header icons and sidebar tab indicator use design-system sizes", async ({ const accent = getComputedStyle(colorProbe).color; colorProbe.remove(); return { - sidebar: size("#sidebar-toggle svg"), identity: size(".context-identity > svg"), viewOptions: size("#view-options svg"), refresh: size("#refresh svg"), @@ -1439,14 +1462,13 @@ test("header icons and sidebar tab indicator use design-system sizes", async ({ }); expect(styles).toMatchObject({ - sidebar: "16px", 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); @@ -1454,41 +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 projectsOpen of [true, false]) { - if (!projectsOpen) - await page.getByRole("button", { name: "Hide projects" }).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 ({ @@ -1789,7 +1892,10 @@ test("drawer aligns its header and retains desktop navigation density", async ({ const desktop = await density(); for (const width of [800, 390]) { await page.setViewportSize({ width, height: 844 }); - const toggle = page.getByRole("button", { name: "Show file sidebar" }); + const toggle = page.getByRole("button", { + name: width >= 768 ? "Diff" : "Show file sidebar", + exact: true, + }); await toggle.click(); await expect(page.locator("#sidebar")).toBeVisible(); await expect.poll(density).toEqual(desktop); @@ -1802,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); @@ -1812,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 c75c54ef..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.locator('#projects-nav [aria-current="page"]'), - ).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 = { @@ -1379,7 +1170,7 @@ test("observation picker drills into repositories and searches independent sourc await search.press("Enter"); await expect(page).toHaveURL(/\/contexts\/second$/); await expect( - page.locator('#projects-nav [aria-current="page"]'), + page.getByRole("button", { name: "Diff", exact: true }), ).toBeFocused(); await expect(page.locator(".context-identity-worktree")).toHaveAccessibleName( "Linked worktree: main-checkout", @@ -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,8 @@ test("projects drawer lists current changed streams and announces newer snapshot await page.goto("/contexts/rate-1"); await ready(page); expect(latestRequests).toContain("true"); - const nav = page.getByRole("navigation", { name: "Projects" }); + 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)/ }), ).toHaveText([/web-app/, /acme-api/]); @@ -1645,32 +1437,63 @@ 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: "Repos", exact: true }).click(); await nav.getByRole("link", { name: /^main/ }).click(); await expect(page).toHaveURL(/\/streams\/stream-main$/); - const search = nav.getByRole("searchbox", { name: "Search projects" }); - await search.fill("acme rate-limit"); - await expect(nav.locator(".stream-row")).toHaveCount(1); - await expect(nav.locator(".stream-row")).toContainText("feat/rate-limit"); - await search.fill("missing"); - await expect(nav).toContainText("No matching projects"); - await search.press("Escape"); - await expect(search).toHaveValue(""); - await expect(nav.locator(".stream-row")).toHaveCount(2); - await nav.getByRole("button", { name: "Browse all reviews" }).click(); + await expect(nav).toBeHidden(); + await expect(page.locator("#sidebar")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Diff", exact: true }), + ).toBeFocused(); + const projectsView = page.getByRole("button", { + name: "Repos", + exact: true, + }); + await projectsView.click(); + await expect(nav.getByRole("searchbox")).toHaveCount(0); + await nav.getByRole("button", { name: "Search reviews" }).click(); const picker = page.getByRole("dialog", { name: "Switch review" }); await expect(picker).toBeVisible(); + await picker + .getByRole("combobox", { name: "Search reviews" }) + .fill("acme rate-limit"); + await expect(picker.getByRole("option")).toHaveCount(1); + await expect(picker.getByRole("option")).toContainText("acme-api"); await page.keyboard.press("Escape"); await expect(picker).toBeHidden(); - await page.getByRole("button", { name: "Hide projects" }).click(); + await expect( + nav.getByRole("button", { name: "Search reviews" }), + ).toBeFocused(); + await projectsView.click(); await expect(nav).toBeHidden(); + await expect(projectsView).toHaveAttribute("aria-expanded", "false"); await page.reload(); await ready(page); - await expect(page.getByRole("navigation", { name: "Projects" })).toBeHidden(); - await page.getByRole("button", { name: "Show projects" }).click(); + await expect(page.locator("#sidebar")).toBeHidden(); + 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 repos" }); + await mobileTrigger.click(); + await expect(nav).toBeVisible(); + await expect(nav).toHaveAttribute("role", "dialog"); + await expect(nav).toHaveAttribute("aria-modal", "true"); + await expect(page.locator("main")).toHaveAttribute("inert", ""); + await expect(page.locator("#sidebar")).toBeHidden(); + await page.keyboard.press("Escape"); + await expect(nav).toBeHidden(); + await expect(mobileTrigger).toBeFocused(); + await mobileTrigger.click(); + await nav.getByRole("link", { name: /^feat\/rate-limit/ }).click(); + await expect(page).toHaveURL(/\/streams\/stream-feat%2Frate-limit$/); + await expect(nav).toBeHidden(); + await expect(page.locator("main")).not.toHaveAttribute("inert", ""); await expect( - page.getByRole("navigation", { name: "Projects" }), - ).toBeVisible(); + page.getByRole("button", { name: "Show file sidebar" }), + ).toBeFocused(); }); test("live stream URLs open the latest head on load and keep it until navigation", async ({ @@ -1824,7 +1647,8 @@ for (const mode of ["local", "remote"]) { name: /^Open latest new changes/, }); await expect(global).toHaveCount(0); - 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); @@ -1855,7 +1679,9 @@ for (const mode of ["local", "remote"]) { "Open latest new changes (1 stream)", ); await expect(page.locator(".snapshot-notice")).toBeHidden(); - await expect(nav.locator('[aria-current="page"]')).toBeFocused(); + await expect( + page.getByRole("button", { name: "Diff", exact: true }), + ).toBeFocused(); // Compact chrome preserves the action and restores focus to the closed drawer's toggle. await page.setViewportSize({ width: 390, height: 844 }); @@ -1871,7 +1697,7 @@ for (const mode of ["local", "remote"]) { await expect(page).toHaveURL(/\/streams\/stream-api$/); await expect(global).toHaveCount(0); await expect( - page.getByRole("button", { name: "Show projects" }), + page.getByRole("button", { name: "Show file sidebar" }), ).toBeFocused(); // A new submission of the same head may reorder projects, but adds no unread content. heads = [ @@ -1883,6 +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: "Repos", exact: true }).click(); await expect(nav.locator(".project-name")).toHaveText(["api", "web"]); await expect(global).toHaveCount(0); expect(collections).toBe(0); @@ -1934,8 +1761,15 @@ test("local first launch waits for collection with an empty catalog", async ({ const status = page.locator("main.session-status"); await expect(status).toContainText("Collecting latest changes"); await expect(status).toHaveAttribute("aria-busy", "true"); - const projects = page.getByRole("navigation", { name: "Projects" }); - await expect(projects).toContainText("Collecting projects…"); + 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: "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/apps/web/test/project-nav.test.ts b/apps/web/test/project-nav.test.ts index 556fdf72..0a26ef1d 100644 --- a/apps/web/test/project-nav.test.ts +++ b/apps/web/test/project-nav.test.ts @@ -7,7 +7,6 @@ import { projectGroups, readSeen, relativeAge, - searchProjectGroups, streamMeta, unseenChangedHeads, } from "../src/project-nav.ts"; @@ -87,30 +86,6 @@ test("projects list current changed streams by recent activity, piped last", () assert.equal(groups[1]?.lastActivity, 20); }); -test("project search keeps matching streams in order and drops empty projects", () => { - const groups = projectGroups( - [ - head("auth", "api", 30, { branch: "feature/auth" }), - head("main", "api", 20, { branch: "main" }), - head("web", "web", 10, { branch: "feature/auth-ui" }), - ], - null, - ); - const ids = (query: string) => - searchProjectGroups(groups, query).map((group) => [ - group.name, - group.streams.map((stream) => stream.context.id), - ]); - assert.equal(searchProjectGroups(groups, " "), groups); - assert.deepEqual(ids("auth"), [ - ["api", ["auth"]], - ["web", ["web"]], - ]); - assert.deepEqual(ids("api main"), [["api", ["main"]]]); - assert.deepEqual(ids("WEB"), [["web", ["web"]]]); - assert.deepEqual(ids("missing"), []); -}); - test("activity orders streams and repositories independently of submission time", () => { const active = head("active", "api", 10, { lastChangedAt: 300 }); const submitted = head("submitted", "web", 500, { lastChangedAt: 100 }); diff --git a/docs/development.md b/docs/development.md index 6b015a02..928c90c4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -259,10 +259,14 @@ 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 drawer, whose search filters -loaded stream heads, and the popup picker (Cmd/Ctrl+K or the drawer's “Browse -all reviews” action); the header shows the open review's identity without a -picker. The selected context scopes every request. The session owner exposes +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, 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 `/streams/{streamId}` URL, the header's “Sync all checkouts” icon and Alt+R call `POST /api/v2/collections`. Load-time sync opens the stream's synced head, or the @@ -312,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 797e8c07..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,8 +120,12 @@ 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. -The drawer's search filters those loaded heads by the picker's metadata terms; -the header only names the open review. An explicit local sync, like an arrival, +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. 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. Activity is a producer estimate, separate from collection freshness and unread @@ -145,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 drawer's “Browse all 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