diff --git a/DESIGN.md b/DESIGN.md index 49ff3069..29580b7d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -234,7 +234,8 @@ such detail into a token. New layout spacing must use the scale. remain in the header because the rail is hidden. - Activity bar: a fixed `--activity-width` column (48px; 52px for coarse 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. + Repos (repository icon) followed by Diff (file-diff icon) select one shared panel. + Repos is the initial view. 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, @@ -248,7 +249,8 @@ such detail into a token. New layout spacing must use the scale. 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. + and keeps Repos selected, including the open modal drawer; people switch to + Diff themselves for the file tree. 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. @@ -260,7 +262,12 @@ such detail into a token. New layout spacing must use the scale. - 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 - opens the most recently active one. Share the drawer's per-browser seen state, + opens a “New changes” menu anchored below it, on `--surface-raised` with the + shared menu shadow. Opening the menu never swaps the review. Rows follow + activity order and show repository name (medium, primary text) with age, then + branch with changed-file count, then the linked worktree name or “Primary + checkout” (secondary metadata, single-line truncation). Choosing a row opens + that stream. Share the drawer's per-browser seen state, baseline existing heads on first visit, and clear a head's mark when opened. Keep the same-stream notice available. Unchanged submissions must not create unread prompts. Use existing ghost-button typography, icons and accessible @@ -484,8 +491,8 @@ Do not add motion for decoration. New animation must honor - Keep the real shell, Repos navigation and theme actions available while review data loads. Reserve the shared navigation panel width and split/unified layout with neutral skeletons for repo rows, file rows, the toolbar and diff frames. - Keep Diff disabled until a review is ready; switching to Repos replaces the file - placeholders within the same panel. + Keep Diff disabled until a review is ready; Repos shows its own placeholders + in the same panel, and switching to Diff shows the file placeholders. Narrow screens hide the placeholder file sidebar and follow the narrow layout. - Skeletons use `--border-muted`, existing spacing and radius tokens, and a gentle shared opacity pulse. Disable animation under `prefers-reduced-motion`. Keep @@ -515,7 +522,7 @@ Do not add motion for decoration. New animation must honor - Collapse navigation to the toggle-controlled modal drawer; its width is `min(320px, 88vw)`. Keep file selection, comments, and export reachable there. The repos drawer follows the same modal pattern from its own toggle; opening - it closes the file drawer, and choosing a stream closes it. + it closes the file drawer. Choosing a stream keeps it open. Its tab strip matches `--topbar-height`, including the tablet header height. File/folder rows, filter, summary toggle, and review progress retain desktop control heights and spacing; do not apply the general mobile enlargement here. diff --git a/apps/web/src/ActivityBar.tsx b/apps/web/src/ActivityBar.tsx index 450d6213..b756797a 100644 --- a/apps/web/src/ActivityBar.tsx +++ b/apps/web/src/ActivityBar.tsx @@ -5,8 +5,8 @@ import { useSidebarState } from "./sidebar-context.tsx"; import { useCompactChrome } from "./use-compact-chrome.ts"; const views = [ - { id: "diff", label: "Diff", Icon: FileDiffIcon }, { id: "projects", label: "Repos", Icon: FolderGit2Icon }, + { id: "diff", label: "Diff", Icon: FileDiffIcon }, ] satisfies { id: "projects" | "diff"; label: string; diff --git a/apps/web/src/ProjectsNav.tsx b/apps/web/src/ProjectsNav.tsx index 9be60012..348e6a6e 100644 --- a/apps/web/src/ProjectsNav.tsx +++ b/apps/web/src/ProjectsNav.tsx @@ -3,6 +3,7 @@ import type { ApiContext } from "@diffx/api"; import { ChevronRightIcon, FolderGit2Icon, + FolderTreeIcon, GitBranchIcon, HistoryIcon, SearchIcon, @@ -11,9 +12,20 @@ import { } from "lucide-react"; import { useEffect, useState, type MouseEvent } from "react"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; import { save, saved } from "./preferences.ts"; -import { contextChangeLabel, contextIsPiped } from "./project-picker.ts"; +import { + contextChangeLabel, + contextIsLinkedWorktree, + contextIsPiped, + contextWorktreeName, +} from "./project-picker.ts"; import { contextActivity } from "./context-activity.ts"; import { newerHead, @@ -108,7 +120,7 @@ export function ProjectsNav() { if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return; event.preventDefault(); - openStream(head); + openStream(head, { preserveNavigation: true }); }; return ( <> @@ -314,28 +326,107 @@ function ProjectsEmpty({ ); } -/** Opens the most active unread head without interrupting the open review. */ +/** Lists unread heads; choosing one opens it and clears its mark. */ export function NewChangesButton() { const { unreadHeads, openStream } = useCatalog(); - const latest = unreadHeads[0]; - if (!latest) return null; + const now = useNow(); + if (!unreadHeads.length) return null; const count = unreadHeads.length; + const label = `New changes (${count} ${count === 1 ? "stream" : "streams"})`; return ( - + + {!piped && ( + + {worktree && ( + + + )} + ); } diff --git a/apps/web/src/project-picker.ts b/apps/web/src/project-picker.ts index 9386b5c1..89bd9e40 100644 --- a/apps/web/src/project-picker.ts +++ b/apps/web/src/project-picker.ts @@ -99,9 +99,13 @@ export function contextIsLinkedWorktree(context: ApiContext) { : Boolean(context.worktreeName); } +export function contextWorktreeName(context: ApiContext) { + return context.worktreeName || context.observation?.worktreeName || "Unknown"; +} + export function contextCheckoutLabel(context: ApiContext) { if (contextIsLinkedWorktree(context)) - return `Linked worktree: ${context.worktreeName || context.observation?.worktreeName || "Unknown"}`; + return `Linked worktree: ${contextWorktreeName(context)}`; if (context.observation?.linkedWorktree === false) return "Primary checkout"; return "Checkout type unknown"; } diff --git a/apps/web/src/session-context.tsx b/apps/web/src/session-context.tsx index b6b01581..7f05d340 100644 --- a/apps/web/src/session-context.tsx +++ b/apps/web/src/session-context.tsx @@ -55,8 +55,14 @@ interface Catalog { selectedId: string; /** Pins one snapshot. */ select: (id: string, options?: { restoreFocus?: boolean }) => void; - /** Follows a stream's latest head; piped snapshots stay pinned. */ - openStream: (head: ApiContext, options?: { restoreFocus?: boolean }) => void; + /** + * Follows a stream's latest head; piped snapshots stay pinned. Opens the Diff + * view unless navigation is preserved. + */ + openStream: ( + head: ApiContext, + options?: { restoreFocus?: boolean; preserveNavigation?: boolean }, + ) => void; deleteContext: (id: string) => Promise; pickerOpen: boolean; openPicker: () => void; diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 58fe6197..6c7f08e6 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -441,6 +441,61 @@ kbd { border-radius: 50%; background: var(--accent); } +.new-changes-menu { + width: min(22rem, calc(100vw - 2 * var(--page-gutter))); +} +.new-changes-header { + margin-bottom: var(--space-1); + padding: var(--space-1) var(--space-2) var(--space-2); + border-bottom: 1px solid var(--border-muted); + color: var(--text-secondary); + font-size: var(--text-sm); + font-weight: var(--weight-medium); +} +.new-changes-item { + flex-direction: column; + align-items: stretch; + gap: var(--space-0-5); + padding: var(--space-1-5) var(--space-2); +} +.new-changes-item svg { + width: var(--icon-sm); + height: var(--icon-sm); + color: var(--muted); +} +.new-changes-line { + display: flex; + align-items: center; + gap: var(--space-1-5); + min-width: 0; +} +.new-changes-detail { + color: var(--text-secondary); + font-size: var(--text-xs); +} +.new-changes-repo, +.new-changes-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.new-changes-repo { + color: var(--fg); + font-weight: var(--weight-medium); +} +.new-changes-age, +.new-changes-count { + flex-shrink: 0; + color: var(--muted); + font: var(--text-xs) var(--font-mono); + font-weight: var(--weight-normal); + font-variant-numeric: tabular-nums; +} +.new-changes-item[data-highlighted] :is(.new-changes-repo, svg) { + color: var(--accent); +} .projects-empty { padding: var(--space-2); color: var(--text-secondary); diff --git a/apps/web/src/use-modal-drawer.ts b/apps/web/src/use-modal-drawer.ts index 7e7704e5..ca23833d 100644 --- a/apps/web/src/use-modal-drawer.ts +++ b/apps/web/src/use-modal-drawer.ts @@ -9,13 +9,18 @@ export function useModalDrawer( ) { useEffect(() => { if (!active) return; - const main = document.querySelector("main"); - const header = document.querySelector(".topbar"); - const views = document.querySelector(".activity-bar"); + // The background can remount while the drawer stays open (opening a + // review replaces
), so keep whichever elements are current inert. + const background = () => + document.querySelectorAll("main, .topbar, .activity-bar"); + const makeInert = () => { + for (const element of background()) + if (!element.hasAttribute("inert")) element.setAttribute("inert", ""); + }; const drawer = document.querySelector(drawerSelector); - main?.setAttribute("inert", ""); - header?.setAttribute("inert", ""); - views?.setAttribute("inert", ""); + makeInert(); + const remounts = new MutationObserver(makeInert); + remounts.observe(document.body, { childList: true, subtree: true }); const focusable = () => Array.from( drawer?.querySelectorAll( @@ -40,9 +45,8 @@ export function useModalDrawer( document.addEventListener("keydown", trapFocus); return () => { document.removeEventListener("keydown", trapFocus); - main?.removeAttribute("inert"); - header?.removeAttribute("inert"); - views?.removeAttribute("inert"); + remounts.disconnect(); + for (const element of background()) element.removeAttribute("inert"); document.querySelector(toggleSelector)?.focus(); }; }, [active, drawerSelector, toggleSelector]); diff --git a/apps/web/src/use-sidebar.ts b/apps/web/src/use-sidebar.ts index d80f4e5e..f4359c40 100644 --- a/apps/web/src/use-sidebar.ts +++ b/apps/web/src/use-sidebar.ts @@ -9,7 +9,7 @@ export function useSidebar() { const [mobile, setMobile] = useState( () => matchMedia("(max-width: 1011px)").matches, ); - const [view, setView] = useState("diff"); + const [view, setView] = useState("projects"); const [open, setOpen] = useState(false); const [collapsed, setCollapsed] = useState( () => saved("sidebar-collapsed") === "true", diff --git a/apps/web/test/browser/app.spec.ts b/apps/web/test/browser/app.spec.ts index 0cc7fa7e..81abd0bd 100644 --- a/apps/web/test/browser/app.spec.ts +++ b/apps/web/test/browser/app.spec.ts @@ -15,12 +15,21 @@ async function resolvedRadius( }, token); } +/** Repos opens first; file-tree tests select Diff the way a user would. */ +async function showDiff(page: Page) { + const view = page.getByRole("button", { name: "Diff", exact: true }); + await view.click(); + await expect(view).toHaveAttribute("aria-current", "page"); + await expect(page.locator("#sidebar")).toBeVisible(); +} + test.beforeEach(async ({ request }) => resetFixtureState(request)); test("file summary counts change types across filtering and mobile", async ({ page, }) => { await page.goto("/"); + await showDiff(page); const summary = page.getByRole("region", { name: "Summary", exact: true }); await expect(summary.locator("#summary-files")).toHaveText("12"); for (const label of [ @@ -104,6 +113,7 @@ test("shows server process metrics in the compact status bar", async ({ }), ); await page.goto("/"); + await showDiff(page); await expect(page.locator("#server-cpu")).toHaveText("12.5%"); await expect(page.locator("#server-ram")).toHaveText("50.0 MB"); @@ -121,6 +131,7 @@ test("shows server process metrics in the compact status bar", async ({ test("renders and filters a piped diff", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 4_000 }); await page.goto("/"); + await showDiff(page); await expect(page.getByRole("heading", { name: "Piped" })).toBeVisible(); await expect(page.locator("#connection")).toHaveText("Fixed snapshot"); @@ -258,6 +269,7 @@ test("file navigation scrolls to and retriggers a destination cue", async ({ await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") }); await page.setViewportSize({ width: 1280, height: 420 }); await page.goto("/"); + await showDiff(page); const file = page.locator( '#file-tree [data-path="src/components/Badge.tsx"]', @@ -301,6 +313,7 @@ test("scrolling selects the top file only when the previous diff leaves", async }) => { await page.setViewportSize({ width: 1280, height: 420 }); await page.goto("/"); + await showDiff(page); const first = page.locator('#file-tree [data-path="config/diffx.json"]'); const second = page.locator('#file-tree [data-path="docs/legacy.md"]'); const firstDiff = page @@ -357,6 +370,7 @@ test("scroll selection preserves collapsed diffs and works with filtered files", }) => { await page.setViewportSize({ width: 1280, height: 420 }); await page.goto("/"); + await showDiff(page); await page.getByRole("searchbox", { name: "Filter files" }).fill("src/"); const badge = page.locator( '#file-tree [data-path="src/components/Badge.tsx"]', @@ -590,6 +604,7 @@ test("only the diff collapse button toggles a file and paths copy independently" }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]); await page.goto("/"); + await showDiff(page); const file = page .locator("#viewer diffs-container") .filter({ hasText: "src/components/Badge.tsx" }); @@ -650,6 +665,7 @@ for (const [start, end] of [ }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]); await page.goto("/"); + await showDiff(page); await expect(page.locator("#additions")).toHaveText("+43"); await page.evaluate(() => document.fonts.ready); await page @@ -746,6 +762,7 @@ test("reset reviewed clears file review marks and disables when empty", async ({ }) => { await page.setViewportSize({ width: 1280, height: 4_000 }); await page.goto("/"); + await showDiff(page); const reset = page.getByRole("button", { name: "Reset reviewed" }); await expect(reset).toBeDisabled(); @@ -798,6 +815,7 @@ test("inline comments use a distinct structured surface while sidebar comments r // 1280px of diff chrome beside the expanded projects drawer. await page.setViewportSize({ width: 1536, height: 4_000 }); await page.goto("/"); + await showDiff(page); await page.evaluate(async () => { const catalog: unknown = await (await fetch("/api/v2/contexts")).json(); if ( @@ -901,6 +919,7 @@ test("inline comments use a distinct structured surface while sidebar comments r if (!imported.ok) throw new Error("Could not seed review comments"); }); await page.reload(); + await showDiff(page); const inline = page.locator( '#viewer [data-comment-id="range-comment"] .comment-card', @@ -1316,6 +1335,7 @@ test("display controls expose state and persist preferences", async ({ page, }) => { await page.goto("/"); + await showDiff(page); await expect(page.getByRole("heading", { name: "Piped" })).toBeVisible(); await page.getByRole("tab", { name: /Review/ }).click(); @@ -1376,7 +1396,7 @@ test("header icons and sidebar tab indicator use design-system sizes", async ({ .evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label")), ), - ).toEqual(["Diff", "Repos"]); + ).toEqual(["Repos", "Diff"]); await expect( page.locator(".topbar .header-actions > :last-child"), ).toHaveAttribute("id", "theme"); @@ -1388,6 +1408,14 @@ test("header icons and sidebar tab indicator use design-system sizes", async ({ await expect( page.locator(".topbar #projects-toggle, .topbar #sidebar-toggle"), ).toHaveCount(0); + // Repos is the initial view; Diff opens the file tree on request. + await expect( + page.getByRole("button", { name: "Repos", exact: true }), + ).toHaveAttribute("aria-current", "page"); + await expect(page.locator("#projects-nav")).toBeVisible(); + await expect(page.locator("#sidebar")).toBeHidden(); + await showDiff(page); + await expect(page.locator("#projects-nav")).toBeHidden(); const filter = page.getByRole("searchbox", { name: "Filter files" }); await filter.fill("value"); const before = await brand.boundingBox(); @@ -1555,9 +1583,8 @@ test("shared navigation resizer preserves width across Diff and Repos", async ({ ); }); 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(); + for (const view of ["Repos", "Diff"]) { + if (view === "Diff") await showDiff(page); await expect(resizer).toBeVisible(); const before = Number(await resizer.getAttribute("aria-valuenow")); await expect(panel).toHaveCSS("width", `${before}px`); @@ -1579,14 +1606,14 @@ test("shared navigation resizer preserves width across Diff and Repos", async ({ await expect(panel).toHaveCSS("width", `${before + 20}px`); } const resizedWidth = initialWidth + 40; - await page.getByRole("button", { name: "Diff", exact: true }).click(); + await page.getByRole("button", { name: "Repos", 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 showDiff(page); await expect(panel).toHaveCSS("width", `${resizedWidth}px`); - await page.getByRole("button", { name: "Repos", exact: true }).click(); + await page.getByRole("button", { name: "Diff", exact: true }).click(); await expect(resizer).toBeHidden(); await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole("button", { name: "Show repos" }).click(); @@ -1606,6 +1633,7 @@ test("copy dialog reports clipboard status and restores focus", async ({ }); }); await page.goto("/"); + await showDiff(page); await expect(page.locator("#connection")).toHaveText("Fixed snapshot"); await page.evaluate(async () => { const catalog: unknown = await (await fetch("/api/v2/contexts")).json(); @@ -1692,6 +1720,7 @@ test("copy dialog reports clipboard status and restores focus", async ({ if (!imported.ok) throw new Error("Could not seed review comments"); }); await page.reload(); + await showDiff(page); await page.getByRole("tab", { name: /Review/ }).click(); await page.getByRole("button", { name: "Filter comments: Open" }).click(); @@ -1866,6 +1895,7 @@ test("drawer aligns its header and retains desktop navigation density", async ({ }) => { await page.setViewportSize({ width: 1280, height: 844 }); await page.goto("/"); + await showDiff(page); await expect(page.locator("#file-tree .file-row").first()).toBeVisible(); const density = () => page.evaluate(() => { @@ -2022,6 +2052,7 @@ test("diff file and folder collapse state survives reload within the tab", async }) => { await page.setViewportSize({ width: 1280, height: 4_000 }); await page.goto("/"); + await showDiff(page); await page .getByRole("button", { name: "Collapse src/components/Badge.tsx", @@ -2030,6 +2061,7 @@ test("diff file and folder collapse state survives reload within the tab", async .click(); await page.getByRole("button", { name: "Collapse folder docs" }).click(); await page.reload(); + await showDiff(page); await expect( page.getByRole("button", { name: "Expand src/components/Badge.tsx", diff --git a/apps/web/test/browser/async-recovery.spec.ts b/apps/web/test/browser/async-recovery.spec.ts index dc8215b9..71362a65 100644 --- a/apps/web/test/browser/async-recovery.spec.ts +++ b/apps/web/test/browser/async-recovery.spec.ts @@ -59,12 +59,18 @@ function commentFor( }; } +// Repos is the initial view; file navigation lives in Diff. +async function showDiff(page: Page) { + await page.getByRole("button", { name: "Diff", exact: true }).click(); +} + async function openWorkspace(page: Page) { await page.route("**/api/v2/contexts/*/review-marks?*", (route) => route.fulfill({ json: { marks: [] } }), ); await page.setViewportSize({ width: 1280, height: 844 }); await page.goto("/"); + await showDiff(page); await expect(page.locator("#additions")).toHaveText("+43"); await page.evaluate(() => document.fonts.ready); } @@ -480,6 +486,7 @@ test("reset waits for pending marks and clears their collapse state", async ({ }); // Reload initializes the owner from the server mark, not browser storage. await page.reload(); + await showDiff(page); await expect(page.locator("#review-count")).toHaveText("1 of 12 reviewed"); await page.route("**/api/v2/contexts/*/review-marks/*?*", (route) => { updates.push(route); diff --git a/apps/web/test/browser/design-refresh.spec.ts b/apps/web/test/browser/design-refresh.spec.ts index ee752832..d519d2fb 100644 --- a/apps/web/test/browser/design-refresh.spec.ts +++ b/apps/web/test/browser/design-refresh.spec.ts @@ -3,6 +3,11 @@ import { resetFixtureState } from "./fixture-state.ts"; test.beforeEach(async ({ request }) => resetFixtureState(request)); +/** Repos is the initial view; the file sidebar opens from the Diff view. */ +async function showDiff(page: Page) { + await page.getByRole("button", { name: "Diff", exact: true }).click(); +} + async function seedReviewComments(page: Page) { await page.evaluate(async () => { const catalog: unknown = await (await fetch("/api/v2/contexts")).json(); @@ -139,6 +144,7 @@ test("interactive controls share the semantic control radius", async ({ page, }) => { await page.goto("/"); + await showDiff(page); const viewOptions = page.getByRole("button", { name: "View options" }); const controlRadius = await viewOptions.evaluate( @@ -203,6 +209,7 @@ test("review actions use progressive disclosure, status filters, and confirmed d }) => { await page.goto("/"); await seedReviewComments(page); + await showDiff(page); await expect( page.getByRole("button", { name: "Hide summary" }), @@ -342,6 +349,7 @@ test("bulk comment deletion selects a status and defaults to all", async ({ }) => { await page.goto("/"); await seedReviewComments(page); + await showDiff(page); await page.getByRole("tab", { name: /Review/ }).click(); const panel = page.locator("#comments-panel"); diff --git a/apps/web/test/browser/local-workspace.spec.ts b/apps/web/test/browser/local-workspace.spec.ts index 9b90b137..90b48615 100644 --- a/apps/web/test/browser/local-workspace.spec.ts +++ b/apps/web/test/browser/local-workspace.spec.ts @@ -164,6 +164,11 @@ async function ready(page: Page, value = "all-v1") { ).toBeVisible(); } +/** Repos is the initial view; the file sidebar opens from the Diff view. */ +async function showDiff(page: Page) { + await page.getByRole("button", { name: "Diff", exact: true }).click(); +} + test("comparison uses the captured merge base and follows the selected scope", async ({ page, }) => { @@ -409,6 +414,7 @@ test("draft survives scopes and explicit reload without automatic diff polling", const state = await localWorkspace(page); await page.goto("/"); await ready(page); + await showDiff(page); const inline = page.locator("#viewer .comment-editor textarea"); // Worker rendering can replace the hover utility during initial highlighting. await expect(async () => { @@ -518,6 +524,7 @@ test("comment navigation changes scope and clears filters after previews load", ]; await page.goto("/"); await ready(page); + await showDiff(page); await page.getByRole("searchbox", { name: "Filter files" }).fill("unmatched"); await expect(page.locator("#viewer diffs-container")).toHaveCount(0); await page.getByRole("tab", { name: /Review/ }).click(); @@ -1409,8 +1416,14 @@ test("repos drawer lists current changed streams and announces newer snapshots", await page.goto("/contexts/rate-1"); await ready(page); expect(latestRequests).toContain("true"); - await page.getByRole("button", { name: "Repos", exact: true }).click(); const nav = page.locator("#projects-nav"); + const projectsView = page.getByRole("button", { + name: "Repos", + exact: true, + }); + // Repos is the initial view. + await expect(nav).toBeVisible(); + await expect(projectsView).toHaveAttribute("aria-current", "page"); await expect( nav.getByRole("button", { name: /^(web-app|acme-api)/ }), ).toHaveText([/web-app/, /acme-api/]); @@ -1437,19 +1450,21 @@ test("repos drawer lists current changed streams and announces newer snapshots", 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 projectsView.click(); - await nav.getByRole("link", { name: /^main/ }).click(); + // Choosing a stream replaces the review but keeps Repos selected. + const mainStream = nav.getByRole("link", { name: /^main/ }); + await mainStream.click(); await expect(page).toHaveURL(/\/streams\/stream-main$/); + await expect(nav).toBeVisible(); + await expect(page.locator("#sidebar")).toBeHidden(); + await expect(projectsView).toHaveAttribute("aria-current", "page"); + await expect(mainStream).toHaveAttribute("aria-current", "page"); + await expect(mainStream).toBeFocused(); + const diffView = page.getByRole("button", { name: "Diff", exact: true }); + await diffView.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(); @@ -1487,13 +1502,18 @@ test("repos drawer lists current changed streams and announces newer snapshots", await expect(nav).toBeHidden(); await expect(mobileTrigger).toBeFocused(); await mobileTrigger.click(); - await nav.getByRole("link", { name: /^feat\/rate-limit/ }).click(); + // The mobile drawer stays open on Repos after choosing a stream. + const rateStream = nav.getByRole("link", { name: /^feat\/rate-limit/ }); + await rateStream.click(); await expect(page).toHaveURL(/\/streams\/stream-feat%2Frate-limit$/); + await expect(nav).toBeVisible(); + await expect(page.locator("main")).toHaveAttribute("inert", ""); + await expect(rateStream).toHaveAttribute("aria-current", "page"); + await expect(rateStream).toBeFocused(); + await page.keyboard.press("Escape"); await expect(nav).toBeHidden(); await expect(page.locator("main")).not.toHaveAttribute("inert", ""); - await expect( - page.getByRole("button", { name: "Show file sidebar" }), - ).toBeFocused(); + await expect(mobileTrigger).toBeFocused(); }); test("live stream URLs open the latest head on load and keep it until navigation", async ({ @@ -1574,7 +1594,7 @@ test("live stream URLs open the latest head on load and keep it until navigation }); for (const mode of ["local", "remote"]) { - test(`${mode} global new changes opens the most active unread stream explicitly`, async ({ + test(`${mode} global new changes lists unread streams to open explicitly`, async ({ page, }) => { await page.clock.install(); @@ -1643,25 +1663,25 @@ for (const mode of ["local", "remote"]) { ); await page.goto("/contexts/viewed"); await ready(page); - const global = page.getByRole("button", { - name: /^Open latest new changes/, - }); + const global = page.getByRole("button", { name: /^New changes/ }); await expect(global).toHaveCount(0); - await page.getByRole("button", { name: "Repos", exact: true }).click(); const nav = page.getByRole("navigation", { name: "Repos" }); + await expect(nav).toBeVisible(); await expect(nav.locator(".project-name")).toHaveText(["api", "web"]); const newerApi = snapshot("api-new", "api", 200, 900); - const newerWeb = snapshot("web-new", "web", 400, 10); + const newerWeb = { + ...snapshot("web-new", "web", 400, 10), + branch: "fix-login", + worktreeName: "web-login", + }; contexts.set(newerApi.id, newerApi); contexts.set(newerWeb.id, newerWeb); heads = [newerApi, newerWeb]; await page.evaluate(() => window.dispatchEvent(new Event("test-ingestion")), ); - await expect(global).toHaveAccessibleName( - "Open latest new changes (2 streams)", - ); + await expect(global).toHaveAccessibleName("New changes (2 streams)"); await expect(global).toHaveText("2 new"); await expect(nav.locator(".project-name")).toHaveText(["web", "api"]); await expect(page.locator(".snapshot-notice")).toContainText( @@ -1671,19 +1691,33 @@ for (const mode of ["local", "remote"]) { await page.clock.fastForward(5 * 60_000); expect(collections).toBe(0); - await global.focus(); - await page.keyboard.press("Enter"); - await ready(page); - await expect(page).toHaveURL(/\/streams\/stream-web$/); - await expect(global).toHaveAccessibleName( - "Open latest new changes (1 stream)", + // Opening the list never swaps the review; each entry names its checkout. + await global.click(); + const menu = page.getByRole("menu", { name: "New changes" }); + await expect(menu).toBeVisible(); + await expect(page).toHaveURL(/\/contexts\/viewed$/); + const items = menu.getByRole("menuitem"); + await expect(items).toHaveCount(2); + await expect(items.nth(0)).toHaveAccessibleName( + /^web, fix-login, web-login, /, ); + await expect(items.nth(0)).toContainText("web"); + await expect(items.nth(0)).toContainText("fix-login"); + await expect(items.nth(0)).toContainText("web-login"); + await expect(items.nth(1)).toHaveAccessibleName(/^api, main, /); + + // Choosing the less active entry opens exactly that stream and clears it. + await items.nth(1).click(); + await ready(page); + await expect(page).toHaveURL(/\/streams\/stream-api$/); + await expect(menu).toBeHidden(); + await expect(global).toHaveAccessibleName("New changes (1 stream)"); await expect(page.locator(".snapshot-notice")).toBeHidden(); await expect( page.getByRole("button", { name: "Diff", exact: true }), ).toBeFocused(); - // Compact chrome preserves the action and restores focus to the closed drawer's toggle. + // Compact chrome keeps the list keyboard reachable and restores focus to the closed drawer's toggle. await page.setViewportSize({ width: 390, height: 844 }); await page.emulateMedia({ colorScheme: "dark" }); await expect(global).toBeVisible(); @@ -1693,8 +1727,16 @@ for (const mode of ["local", "remote"]) { ); await global.focus(); await page.keyboard.press("Enter"); + await expect(menu).toBeVisible(); + const menuBounds = await menu.boundingBox(); + expect( + menuBounds && menuBounds.x >= 0 && menuBounds.x + menuBounds.width <= 390, + ).toBe(true); + await expect(items).toHaveCount(1); + await expect(items.first()).toBeFocused(); + await page.keyboard.press("Enter"); await ready(page); - await expect(page).toHaveURL(/\/streams\/stream-api$/); + await expect(page).toHaveURL(/\/streams\/stream-web$/); await expect(global).toHaveCount(0); await expect( page.getByRole("button", { name: "Show file sidebar" }), @@ -1782,7 +1824,10 @@ 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: "Repos", exact: true }).click(); + // Repos is the initial view and shows its own placeholders while loading. + await expect( + page.getByRole("button", { name: "Repos", exact: true }), + ).toHaveAttribute("aria-current", "page"); const projects = page.getByRole("navigation", { name: "Repos" }); await expect(projects).toContainText("Collecting repos…"); await expect(projects.locator(".projects-skeleton")).toBeVisible(); diff --git a/docs/development.md b/docs/development.md index 7c975d6e..0ac31925 100644 --- a/docs/development.md +++ b/docs/development.md @@ -261,8 +261,9 @@ files (per stream and scope) and closed tree folders (per stream) persist in snapshots key by context. One draft persists 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 +beside the activity rail, with Repos first and selected initially; selecting a +stream in Repos loads its diff and keeps Repos open. 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 @@ -292,7 +293,8 @@ available and show Retry when no stored review can open. An explicit sync never replaces the open review: it reloads the heads, so the newer-head notice, drawer dots and global unread action announce new snapshots, and feedback reports synced, updated and failed checkouts. The global -action opens the most recently active unopened changed head. The drawer and +action opens a menu of unopened changed heads in activity order, naming each +head's repository, branch and worktree; choosing one opens that head. The drawer and header share one per-browser seen-state owner; first visits baseline existing heads and unchanged submissions do not create unread prompts. Activity ordering uses catalog `lastChangedAt`, falling back to captured collection time, with