diff --git a/DESIGN.md b/DESIGN.md
index 015304cc..c0d30965 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -280,23 +280,35 @@ such detail into a token. New layout spacing must use the scale.
monospace labels without clipping ascenders or descenders.
- Header lead: brand and divider identify the app. The file-sidebar toggle
leads the review toolbar. Below 1012px a “Show reviews” toggle in the header
- opens the review column as a drawer. Account actions are the header's
- trailing action in every layout.
+ opens the review column as a drawer. “Search reviews” (magnifier, Cmd/Ctrl+K)
+ leads the header's trailing actions in every layout and opens the review
+ picker. Account actions are the header's trailing action in every layout.
+- Review navigation is a shell policy (`shell-policy.ts`), resolved once from
+ the server bootstrap: checkout and remote processes switch between reviews;
+ a capture (piped stdin or a fixture) serves one fixed diff. A capture shows
+ no review column, rail, drawer toggle, search, Cmd/Ctrl+K, header
+ comboboxes or sync; its header keeps the brand, file-sidebar toggle and the
+ static Piped identity, and the island holds only the file sidebar (hidden,
+ still mounted, while collapsed). Piped wording, icons and grouping appear
+ only there; elsewhere a stored stdin snapshot reads like any review.
+ Components read the policy, never the deployment mode.
- Review column: the leading pane of the navigation island, titled “Reviews”,
on `--panel`, sharing the file tree and diff's top and bottom edges below the
header. Switching streams changes everything to its right; the column stays.
- It has three states: list, rail, and the inbox overlay. List and rail persist
- per browser; there is no auto-collapse.
+ It has two states, list and rail, persisted per browser; there is no
+ auto-collapse.
Its header matches the compact sidebar tabs and display toolbar and holds the
title (UI label size, medium, secondary text) and ghost actions: “Review list
- view” (Group by repo / Recent first, persisted, default by repo), “Search
- reviews” (opens the picker), “Open inbox”, and “Collapse reviews” on desktop
- or “Close reviews” in the drawer. Selecting a stream loads its diff and keeps
- the column and file sidebar open.
+ view” (Group by repo / Recent first, persisted, default by repo) and
+ “Collapse reviews” on desktop or “Close reviews” in the drawer. Selecting a
+ stream loads its diff and keeps the column and file sidebar open.
+ A repository row is a `disclosure-row`: its chevron alone collapses or
+ expands the group; its name (“Open latest in {repo}”) opens the repository's
+ most active stream and expands the group.
Repositories sort by their most active visible stream. Streams sort by
estimated code activity, with captured collection time as fallback and stable
identity ties. Recent first is one flat activity-ordered list labeling each
- row with its repository; Piped stays last. Rows retain compact tree density,
+ row with its repository. 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
@@ -306,35 +318,19 @@ such detail into a token. New layout spacing must use the scale.
to `--topbar-height` (36px; 44px for coarse pointers). Items match header icon
buttons (32px desktop, 44px touch) with the header's inset; coarse pointers add
a 4px block inset so flush items clear the island corners. “Expand reviews”
- and “Open inbox” lead a separator, then one `rail` button per repository
- showing a two-letter monogram (terminal icon for Piped), an accent dot for
- unopened streams, and `aria-current="true"` with accent text on `--selected`
- for the open review's repository. A mark expands the list scrolled to and
- focused on that repository.
-- Inbox: “Open inbox” extends the column over the review island as an overlay,
- not a route. The review stays mounted, inert and unscrolled underneath.
- Filter text, Repository / Session / Worktree / Host facets (OR within a facet,
- AND across), and Unread only; facets show only with values and persist per
- browser; text does not. Rows show repository / branch, new mark, count and
- age, with worktree, session and host below in muted monospace. Content is
- bounded by `--reading-width`; a footer counts shown reviews. Escape or “Close
- inbox” restores focus to the opener; choosing a row opens it and focuses the
- column's current stream.
+ leads a separator, then one `rail` button per repository showing a
+ two-letter monogram, an accent dot for unopened streams, and
+ `aria-current="true"` with accent text on `--selected` for the open review's
+ repository. A mark (“Open latest in {repo}”) opens that repository's most
+ active stream; the rail stays collapsed.
- 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 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 review column'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
- labels; preserve the header's review identity and compact-layout controls.
+- Unopened arrivals are marked only by the column's and rail's accent dots and
+ the header combobox rows; there is no header counter. Share the per-browser
+ seen state, baseline existing heads on first visit, and clear a head's mark
+ when opened. Unchanged submissions must not create unread marks.
- Status bars: `--statusbar-height` (28: a compact control plus 2px insets,
- border included) for the sidebar, review, inbox and loading footers alike,
+ border included) for the sidebar, review and loading footers alike,
with shortcuts shed before server status.
Server status is an icon-only compact ghost trigger at the trailing edge:
success check when ready, reduced-motion-safe spinner while connecting, and
@@ -452,6 +448,8 @@ with a semantic variant instead of overriding it.
| Banner, empty, error | `notice`, `empty-state`, `error-state` |
| Destructive confirmation | `confirm-dialog`; other dialogs `ui/dialog` |
| Menus, popovers, tooltips | `ui/dropdown-menu`, `ui/popover`, `ui/tooltip` |
+| Menu with type-to-filter rows | `ui/combobox` (shares the `ui/dropdown-menu` surface and rows) |
+| Group row: toggle plus its own action | `disclosure-row` (chevron toggles; label acts) |
| Keyboard hint | `shortcut` (formats per platform), `ui/kbd` |
| Loading placeholder | `ui/skeleton` (shapes mirror real geometry) |
| Divider, drawer backdrop | `ui/separator` (`chrome` for the header), `scrim` |
@@ -542,22 +540,28 @@ at `/design.html` (see `docs/development.md`).
### Overlays
- The header identifies the open review after the brand divider at control
- height as two ghost menu triggers, repo ▾ / branch ▾: a semibold repository
- name with repository icon, then a quieter branch. Give the repository name
- priority when long context labels truncate; hide the chevrons below 768px.
- The repository menu opens a repository's most active stream; the branch menu
- lists the open repository's streams with new marks, counts and metadata.
- Both focus the column's current stream after switching. Worktree stays static.
- Piped reviews use static text: a terminal icon, “Piped”, and collection time;
+ height as two ghost `ui/combobox` triggers, repo ▾ / branch ▾: a semibold
+ repository name with repository icon, then a quieter branch. Give the
+ repository name priority when long context labels truncate; hide the
+ chevrons below 768px. Each opens a `--surface-raised` popup matching
+ `ui/dropdown-menu` with a focused filter field on top (“Filter
+ repositories”; “Filter branches, worktrees, sessions…”), then the matching
+ rows; the first match is highlighted so typing and Enter switch, and an
+ empty filter result says so. The repository combobox opens a repository's
+ most active stream; the branch combobox lists the open repository's streams
+ with new marks, counts and metadata, matching every word against branch,
+ worktree, host and sessions. Both focus the column's current stream after
+ switching. Worktree stays static.
+ A capture uses static text: a terminal icon, “Piped”, and collection time;
omit repository, branch, worktree, and directory identity.
- Cmd/Ctrl+K and the column's search action open the searchable “Switch review”
- dialog. Position it at
+ Cmd/Ctrl+K and the header's “Search reviews” action open the searchable
+ “Switch review” dialog. Position it at
`--topbar-height` plus `--space-6` on desktop (64px default), `--space-4` on
mobile (16px default). Bound picker width to 35rem and viewport gutters. The
picker owns focus and stays available during context errors. Choosing a
review moves focus to the column's current stream.
- Search leads the picker; keep “Switch review” as its screen-reader title.
- Label global search “Search reviews” and Piped history search “Search Piped”.
+ Label search “Search reviews”.
Use the shared `SearchField` (32px) with close at its right edge and the
focus ring around its entire boundary. The dialog is `DialogContent`'s
`command` variant.
@@ -565,12 +569,8 @@ at `/design.html` (see `docs/development.md`).
within each group; search preserves that order, using match strength to break
recency ties. Compact rows (about 48px) put repository name and branch on
one line, with a muted monospace path below; truncate long text to single lines.
- Mark linked paths with “Worktree”. Group retained imports under a separate
- “Piped” entry after repositories, with a quiet separator and snapshot count.
- Its history shows collection timestamps newest first, including legacy captures;
- changed-file counts remain secondary. Show “Piped · Newest first” below search
- and omit repository filters from this history. Repository freshness, host,
- branch, worktree, and status filters never hide Piped imports.
+ Mark linked paths with “Worktree”. Stored stdin snapshots follow the same
+ grouping and filters as any review.
Distinguish the current review with a check and accessible “Current review”
text; retain explicit “Unavailable” feedback. Keep the footer to a short result
count and keyboard hints.
@@ -652,7 +652,7 @@ Do not add motion for decoration. New animation must honor
`min(320px, 88vw)`. Keep file selection, comments, and export reachable there.
The review column becomes a drawer with the same modal pattern from its own
header toggle; opening it closes the file drawer. Choosing a stream closes it
- and shows the diff. The inbox fills the review island.
+ and shows the diff.
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/PRODUCT.md b/apps/web/PRODUCT.md
index 9ec227ce..63bcc0be 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 Reviews column keeps that work navigable as people hop between agents: each repository lists only its current changed branches, newest first, the column collapses to repository marks, the inbox filters every stream by repository, session, worktree or host, 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 Reviews column keeps that work navigable as people hop between agents: each repository lists only its current changed branches, newest first, the column collapses to repository marks that open each repository's latest work, the header repository and branch comboboxes filter as you type, and new arrivals are marked without replacing the review in progress. A piped patch shows only that diff.
- Review data is stored by the local diffx server, as documented in `../../docs-website/src/pages/api.md`.
## Capabilities and Constraints
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 880573b6..82f74c85 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -9,7 +9,6 @@ import { NewerSnapshotNotice } from "./ProjectsNav.tsx";
import { CopyDialog } from "./review.tsx";
import { capabilityEnabled, useSession } from "./session-context.tsx";
import { useSidebarState } from "./sidebar-context.tsx";
-import { StreamInbox } from "./StreamInbox.tsx";
import { WebMCPTools } from "./WebMCPTools.tsx";
export function App() {
@@ -26,7 +25,6 @@ export function App() {
-
diff --git a/apps/web/src/ContextIdentity.tsx b/apps/web/src/ContextIdentity.tsx
index 0a9d4a7f..edd5e09e 100644
--- a/apps/web/src/ContextIdentity.tsx
+++ b/apps/web/src/ContextIdentity.tsx
@@ -4,43 +4,48 @@ import { ContextIcon } from "@/components/context-icon";
import { CountBadge } from "@/components/count-badge";
import { Button } from "@/components/ui/button";
import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuHeader,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
+ Combobox,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxItem,
+ ComboboxList,
+ ComboboxSearch,
+ ComboboxTrigger,
+} from "@/components/ui/combobox";
import {
contextChangeLabel,
contextCheckoutLabel,
contextDetail,
contextDiagnostics,
contextIsLinkedWorktree,
- contextIsPiped,
contextTimestampLabel,
} from "./project-picker.ts";
import {
+ latestStream,
projectGroups,
projectKey,
relativeAge,
streamKey,
+ streamMatches,
streamMeta,
streamTitle,
type ProjectGroup,
+ type ProjectStream,
} from "./project-nav.ts";
import { NewMark, useNow } from "./ProjectsNav.tsx";
import { useCatalog } from "./session-context.tsx";
+import { useShellPolicy } from "./shell-policy.ts";
/**
- * The open review's repository, branch and worktree. Repository and branch
- * open menus of current streams, beside the review column.
+ * The open review's repository, branch and worktree. With review navigation,
+ * repository and branch are comboboxes that filter and switch current streams;
+ * a capture shows its fixed piped identity.
*/
export function ContextIdentity({ current }: { current: ApiContext }) {
+ const { reviewNavigation } = useShellPolicy();
return (
- {contextIsPiped(current) ? (
+ {!reviewNavigation ? (
Piped
@@ -74,13 +79,23 @@ function ContextSwitcher({ current }: { current: ApiContext }) {
const now = useNow();
const groups = projectGroups(heads.contexts, seen);
const repository = groups.find((group) => group.id === projectKey(current));
- const open = (head: ApiContext | undefined) => {
- if (head) openStream(head);
- };
+ const stream = repository?.streams.find(
+ (candidate) => candidate.id === streamKey(current),
+ );
return (
<>
-
- item.id === value.id}
+ itemToStringLabel={(group) => group.name}
+ onValueChange={(group) => {
+ const latest = group && latestStream(group);
+ if (latest) openStream(latest.context);
+ }}
+ >
+
}
>
-
+
{current.name}
-
-
-
- Repositories
-
-
-
- open(groups.find((group) => group.id === id)?.streams[0]?.context)
- }
- >
- {groups.map((group) => (
+
+
+
+ No matching repositories
+
+ {(group: ProjectGroup) => (
- ))}
-
-
-
+ )}
+
+
+
/
{repository ? (
-
- item.id === value.id}
+ itemToStringLabel={(item) => streamTitle(item.context)}
+ filter={streamMatches}
+ onValueChange={(item) => {
+ if (item) openStream(item.context);
+ }}
+ >
+
-
-
-
- {repository.name}
-
-
-
- open(
- repository.streams.find((stream) => stream.id === id)
- ?.context,
- )
- }
- >
- {repository.streams.map(({ id, context, unseen }) => (
-
-
-
-
- {streamTitle(context)}
-
- {unseen && }
- {typeof context.changedFileCount === "number" && (
-
- )}
-
-
- {streamMeta(context, repository.multipleHosts, now)}
-
-
- ))}
-
-
-
+
+
+
+ No matching branches
+
+ {(item: ProjectStream) => (
+
+ )}
+
+
+
) : (
@@ -201,15 +191,45 @@ function ContextSwitcher({ current }: { current: ApiContext }) {
);
}
+function BranchItem({
+ item,
+ multipleHosts,
+ now,
+}: {
+ item: ProjectStream;
+ multipleHosts: boolean;
+ now: number;
+}) {
+ const { context, unseen } = item;
+ return (
+
+
+
+ {streamTitle(context)}
+ {unseen && }
+ {typeof context.changedFileCount === "number" && (
+
+ )}
+
+
+ {streamMeta(context, multipleHosts, now)}
+
+
+ );
+}
+
function RepositoryItem({ group, now }: { group: ProjectGroup; now: number }) {
return (
-
-
+
+
{group.name}
{group.streams.some((stream) => stream.unseen) && }
{relativeAge(group.lastActivity, now)}
-
+
);
}
diff --git a/apps/web/src/Navigation.tsx b/apps/web/src/Navigation.tsx
index 38209101..7f96181e 100644
--- a/apps/web/src/Navigation.tsx
+++ b/apps/web/src/Navigation.tsx
@@ -1,12 +1,15 @@
import { useRef, type ReactNode } from "react";
import { FileTreeSkeleton } from "./WorkspaceLoading.tsx";
import { ProjectsNav } from "./ProjectsNav.tsx";
+import { useShellPolicy } from "./shell-policy.ts";
import { useSidebarState } from "./sidebar-context.tsx";
import { useStreamNavState } from "./stream-nav-context.tsx";
/**
* One navigation island: the review column, then the open review's file
- * sidebar. Each pane owns its width and resize boundary.
+ * sidebar. Each pane owns its width and resize boundary. Without review
+ * navigation (a capture) only the file sidebar remains; the island hides,
+ * still mounted, while that sidebar is collapsed.
*/
export function Navigation({
children,
@@ -17,22 +20,27 @@ export function Navigation({
}) {
const sidebar = useSidebarState();
const streams = useStreamNavState();
+ const { reviewNavigation } = useShellPolicy();
const files = Boolean(children) || loading;
const filesWidth = files && !sidebar.collapsed ? sidebar.width : 0;
- const rail = streams.state === "rail";
+ const rail = !reviewNavigation || streams.state === "rail";
+ const empty =
+ !reviewNavigation && !sidebar.mobile && (!files || sidebar.collapsed);
return (
-
-
-
-
streams.setWidth(width, filesWidth)}
- />
-
+
+ {reviewNavigation && (
+
+
+
streams.setWidth(width, filesWidth)}
+ />
+
+ )}
{files && (
;
@@ -83,7 +77,6 @@ export function ProjectPicker({
}) {
const [query, setQuery] = useState("");
const [repositoryId, setRepositoryId] = useState("");
- const piped = repositoryId === pipedGroupId;
const [highlightedId, setHighlightedId] = useState("");
const [filters, setFilters] = useState(defaultPickerFilters);
const [filtersOpen, setFiltersOpen] = useState(false);
@@ -118,12 +111,7 @@ export function ProjectPicker({
const entry = results.find((result) => result.id === id);
if (!entry || !pickerEntryAvailable(entry)) return;
if (entry.kind !== "context") {
- setRepositoryId(
- entry.kind === "piped"
- ? pipedGroupId
- : (entry.context.repositoryId ?? ""),
- );
- if (entry.kind === "piped") setQuery("");
+ setRepositoryId(entry.context.repositoryId ?? "");
setHighlightedId("");
searchRef.current?.focus();
return;
@@ -173,15 +161,15 @@ export function ProjectPicker({
Switch review
- Choose a repository or Piped, then a collected snapshot. Search
- branches, worktrees, hosts, and runs. Use arrow keys to navigate and
- Enter to choose.
+ Choose a repository, then a collected snapshot. Search branches,
+ worktrees, hosts, and runs. Use arrow keys to navigate and Enter to
+ choose.
{
@@ -241,17 +227,15 @@ export function ProjectPicker({
}
actions={
<>
- {!piped && (
- setFiltersOpen((value) => !value)}
- >
-
-
- )}
+ setFiltersOpen((value) => !value)}
+ >
+
+
@@ -262,7 +246,7 @@ export function ProjectPicker({
>
}
/>
-
+
- {!piped && (
-
-
+
+ {pickerFilterSummary(filters)}
+
+ {pickerFiltersChanged(filters) && (
+ {
+ setFilters(defaultPickerFilters);
+ setHighlightedId("");
+ }}
>
- {pickerFilterSummary(filters)}
-
- {pickerFiltersChanged(filters) && (
- {
- setFilters(defaultPickerFilters);
- setHighlightedId("");
- }}
- >
- Clear filters
-
- )}
-
- )}
- {piped && (
-
- Piped · Newest first
-
- )}
+ Clear filters
+
+ )}
+
{results.map((entry) => {
const context = entry.context;
const grouped = entry.kind !== "context";
- const pipedContext = contextIsPiped(context);
const available = pickerEntryAvailable(entry);
- return [
- entry.kind === "piped" && (
-
- ),
+ return (
-
+
-
- {entry.kind === "piped"
- ? "Piped"
- : pipedContext
- ? contextTimestampLabel(context)
- : grouped
- ? context.name
- : context.observation
- ? context.worktreeName || context.name
- : context.name}
+
+ {!grouped && context.observation
+ ? context.worktreeName || context.name
+ : context.name}
- {(!pipedContext || grouped) && (
-
- {!grouped && !pipedContext && (
-
- )}
-
- {grouped
- ? pluralize(entry.contexts.length, "snapshot")
- : contextDetail(context)}
-
+
+ {!grouped && (
+
+ )}
+
+ {grouped
+ ? pluralize(entry.contexts.length, "snapshot")
+ : contextDetail(context)}
- )}
+
{!available && (
{contextUnavailableReason()}
)}
- {(!pipedContext || grouped) && (
-
- {!grouped && contextIsLinkedWorktree(context) && (
- Worktree ·
- )}
- {entry.kind === "piped"
- ? "Choose a snapshot"
- : grouped
- ? context.observation?.remoteUrl ||
- "Choose a collected snapshot"
- : observationDetail(context)}
-
- )}
+
+ {!grouped && contextIsLinkedWorktree(context) && (
+ Worktree ·
+ )}
+ {grouped
+ ? context.observation?.remoteUrl ||
+ "Choose a collected snapshot"
+ : observationDetail(context)}
+
- {!grouped && !pipedContext && context.stale && (
+ {!grouped && context.stale && (
Stale
)}
{!grouped && }
@@ -406,8 +354,8 @@ export function ProjectPicker({
Current review
)}
- ,
- ];
+
+ );
})}
{!results.length && (
, open: () => void) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0)
@@ -148,47 +132,43 @@ export function streamClick(event: MouseEvent, open: () => void) {
export function ProjectsNav() {
const sidebar = useSidebarState();
const streams = useStreamNavState();
- const { heads, current, openStream, openPicker, seen, collecting } =
- useCatalog();
+ const { heads, current, openStream, seen, collecting } = useCatalog();
const [closed, setClosed] = useState(readCollapsedGroups);
const now = useNow();
const groups = projectGroups(heads.contexts, seen);
const currentStream = current ? streamKey(current) : "";
const drawerOpen = sidebar.drawer === "reviews";
const rail = !sidebar.mobile && streams.state === "rail";
- const { focusRepo, clearFocusRepo } = streams;
- useEffect(() => {
- save("projects-closed-groups", JSON.stringify([...closed]));
- }, [closed]);
- useEffect(() => {
- if (!focusRepo || rail) return;
- setClosed((value) => {
- if (!value.has(focusRepo)) return value;
- const next = new Set(value);
- next.delete(focusRepo);
- return next;
- });
- requestAnimationFrame(() => {
- const target = document.getElementById(repoAnchor(focusRepo));
- target?.scrollIntoView({ block: "nearest" });
- target?.focus();
- });
- clearFocusRepo();
- }, [focusRepo, rail, clearFocusRepo]);
- const toggleGroup = (id: string) =>
- setClosed((value) => {
- const next = new Set(value);
- if (!next.delete(id)) next.add(id);
- return next;
- });
+ // Saved before navigating: opening a stream can remount the column (the
+ // loading shell renders its own), which would drop a pending save effect.
+ const updateClosed = (next: Set) => {
+ save("projects-closed-groups", JSON.stringify([...next]));
+ setClosed(next);
+ };
+ const toggleGroup = (id: string) => {
+ const next = new Set(closed);
+ if (!next.delete(id)) next.add(id);
+ updateClosed(next);
+ };
const open = (event: MouseEvent, head: ApiContext) =>
streamClick(event, () => openStream(head));
+ // A repository opens to its most active stream, and its group expands.
+ const openLatest = (group: ProjectGroup) => {
+ const latest = latestStream(group);
+ if (!latest) return;
+ if (closed.has(group.id)) {
+ const next = new Set(closed);
+ next.delete(group.id);
+ updateClosed(next);
+ }
+ openStream(latest.context);
+ };
if (rail)
return (
);
return (
@@ -212,31 +192,6 @@ export function ProjectsNav() {
value={streams.group}
onChange={streams.setGroup}
/>
- {
- sidebar.closeMobile();
- openPicker();
- }}
- >
-
-
- {
- sidebar.closeMobile();
- streams.openInbox();
- }}
- >
-
-
toggleGroup(group.id)}
+ onOpenLatest={() => openLatest(group)}
currentStream={currentStream}
now={now}
onOpen={open}
@@ -331,15 +287,18 @@ function GroupModeMenu({
);
}
-/** Collapsed column: one mark per repository, unread dots, the current repository selected. */
+/**
+ * Collapsed column: one mark per repository, unread dots, the current
+ * repository selected. A mark opens that repository's latest stream.
+ */
function StreamRail({
groups,
currentStream,
- onOpenInbox,
+ onOpenLatest,
}: {
groups: ProjectGroup[];
currentStream: string;
- onOpenInbox: () => void;
+ onOpenLatest: (group: ProjectGroup) => void;
}) {
const streams = useStreamNavState();
return (
@@ -353,17 +312,6 @@ function StreamRail({
>
-
-
-
{groups.map((group) => {
@@ -379,19 +327,15 @@ function StreamRail({
className="relative"
label={
unread
- ? `${group.name}, ${pluralize(unread, "new stream")}`
- : group.name
+ ? `Open latest in ${group.name}, ${pluralize(unread, "new stream")}`
+ : `Open latest in ${group.name}`
}
aria-current={current ? "true" : undefined}
- onClick={() => streams.expand(group.id)}
+ onClick={() => onOpenLatest(group)}
>
- {group.kind === "piped" ? (
-
- ) : (
-
- {repoMonogram(group.name)}
-
- )}
+
+ {repoMonogram(group.name)}
+
{unread > 0 && (
@@ -410,6 +354,7 @@ function ProjectItem({
group,
expanded,
onToggle,
+ onOpenLatest,
currentStream,
now,
onOpen,
@@ -417,6 +362,7 @@ function ProjectItem({
group: ProjectGroup;
expanded: boolean;
onToggle: () => void;
+ onOpenLatest: () => void;
currentStream: string;
now: number;
onOpen: (event: MouseEvent, head: ApiContext) => void;
@@ -425,19 +371,20 @@ function ProjectItem({
const listId = `project-${group.id}`;
return (
-
-
-
-
-
+
{group.name}
@@ -445,7 +392,7 @@ function ProjectItem({
{relativeAge(group.lastActivity, now)}
-
+
{expanded && (
{group.streams.map(({ id, context, unseen: fresh }) => (
@@ -478,32 +425,25 @@ function RecentList({
now: number;
onOpen: (event: MouseEvent, head: ApiContext) => void;
}) {
- const anchored = new Set();
return (
- {recentStreams(groups).map(({ id, context, unseen, group }) => {
- const first = !anchored.has(group.id);
- anchored.add(group.id);
- return (
-
-
-
- );
- })}
+ {recentStreams(groups).map(({ id, context, unseen, group }) => (
+
+
+
+ ))}
);
}
function StreamRow({
- id,
context,
current,
fresh,
@@ -512,7 +452,6 @@ function StreamRow({
depth,
onOpen,
}: {
- id?: string | undefined;
context: ApiContext;
current: boolean;
fresh: boolean;
@@ -524,7 +463,6 @@ function StreamRow({
}) {
return (
}
@@ -533,12 +471,10 @@ function StreamRow({
onClick={(event) => onOpen(event, context)}
>
- {!contextIsPiped(context) && (
-
- )}
+
{streamTitle(context)}
{fresh && !current && }
{typeof context.changedFileCount === "number" && (
@@ -597,111 +533,6 @@ function ProjectsEmpty({
);
}
-/** Lists unread heads; choosing one opens it and clears its mark. */
-export function NewChangesButton() {
- const { unreadHeads, openStream } = useCatalog();
- const now = useNow();
- if (!unreadHeads.length) return null;
- const count = unreadHeads.length;
- return (
-
-
- }
- >
-
-
- {count}
- new
-
-
-
- New changes
-
- {unreadHeads.map((context) => (
- openStream(context)}
- />
- ))}
-
-
- );
-}
-
-function NewChangeItem({
- context,
- now,
- onOpen,
-}: {
- context: ApiContext;
- now: number;
- onOpen: () => void;
-}) {
- const piped = contextIsPiped(context);
- const repository = piped ? "Piped" : context.name;
- const branch = streamTitle(context);
- const worktree = contextIsLinkedWorktree(context)
- ? contextWorktreeName(context)
- : context.observation?.linkedWorktree === false
- ? "Primary checkout"
- : "";
- const age = relativeAge(contextActivity(context), now);
- return (
-
-
-
-
- {repository}
-
-
- {age}
-
-
-
- {!piped && }
- {branch}
- {typeof context.changedFileCount === "number" && (
-
- )}
-
- {worktree && (
-
-
- {worktree}
-
- )}
-
- );
-}
-
/** Announces a newer head for the viewed stream without replacing the review. */
export function NewerSnapshotNotice() {
const session = useSession();
diff --git a/apps/web/src/SessionShell.tsx b/apps/web/src/SessionShell.tsx
index d15bc94c..1554ab43 100644
--- a/apps/web/src/SessionShell.tsx
+++ b/apps/web/src/SessionShell.tsx
@@ -6,13 +6,14 @@ import { navigate } from "./identity-context.tsx";
import { Button } from "@/components/ui/button";
import { ProjectPicker } from "./ProjectPicker.tsx";
import { Navigation } from "./Navigation.tsx";
-import { StreamInbox } from "./StreamInbox.tsx";
import { SyncButton, Topbar } from "./Topbar.tsx";
import { useCatalog, useSessionShell } from "./session-context.tsx";
+import { useShellPolicy } from "./shell-policy.ts";
/**
* Presents the selected review once ready, or the loading, empty and failure
- * shell while SessionProvider resolves it. The picker spans both states.
+ * shell while SessionProvider resolves it. The picker spans both states
+ * wherever the shell offers review navigation.
*/
export function SessionShell({ children }: { children: ReactNode }) {
const { current, selectedId, select, pickerOpen, heads } = useCatalog();
@@ -31,6 +32,7 @@ export function SessionShell({ children }: { children: ReactNode }) {
history,
setPickerOpen,
} = useSessionShell();
+ const { reviewNavigation } = useShellPolicy();
return (
<>
@@ -110,20 +112,21 @@ export function SessionShell({ children }: { children: ReactNode }) {
/>
)}
-
>
)}
-
+ {reviewNavigation && (
+
+ )}
>
);
}
diff --git a/apps/web/src/StreamInbox.tsx b/apps/web/src/StreamInbox.tsx
deleted file mode 100644
index 50d2fd7e..00000000
--- a/apps/web/src/StreamInbox.tsx
+++ /dev/null
@@ -1,323 +0,0 @@
-import type { ApiContext } from "@diffx/api";
-import { GitBranchIcon, XIcon } from "lucide-react";
-import { useEffect, useRef, useState, type KeyboardEvent } from "react";
-import { ContextIcon } from "@/components/context-icon";
-import { CountBadge } from "@/components/count-badge";
-import { EmptyState } from "@/components/empty-state";
-import { ErrorState } from "@/components/error-state";
-import { IconButton } from "@/components/icon-button";
-import { ListRow } from "@/components/list-row";
-import { PanelHeader } from "@/components/panel-header";
-import { SearchField } from "@/components/search-field";
-import { Button } from "@/components/ui/button";
-import { Checkbox } from "@/components/ui/checkbox";
-import { contextActivity } from "./context-activity.ts";
-import { pluralize } from "./format.ts";
-import { MultiFilter } from "./PickerFilters.tsx";
-import { contextChangeLabel, contextIsPiped } from "./project-picker.ts";
-import {
- defaultStreamFilters,
- filterStreams,
- projectGroups,
- recentStreams,
- relativeAge,
- streamFacets,
- streamFiltersChanged,
- streamHost,
- streamKey,
- streamSessions,
- streamTitle,
- streamWorktree,
- type ProjectGroup,
- type ProjectStream,
-} from "./project-nav.ts";
-import { NewMark, streamClick, useNow } from "./ProjectsNav.tsx";
-import { streamPath } from "./review-route.ts";
-import { useCatalog } from "./session-context.tsx";
-import { useStreamNavState } from "./stream-nav-context.tsx";
-
-/**
- * The review column extended over the review island: every current stream
- * with facet filters. The review stays mounted and inert underneath.
- */
-export function StreamInbox() {
- const { inboxOpen } = useStreamNavState();
- return inboxOpen ? : null;
-}
-
-function InboxOverlay() {
- const streams = useStreamNavState();
- const { filters, setFilters, closeInbox } = streams;
- const { heads, seen, current, openStream } = useCatalog();
- const now = useNow();
- const [query, setQuery] = useState("");
- const overlay = useRef(null);
- const search = useRef(null);
- // Choosing a review hands focus to the opened review instead of the opener.
- const restoreFocus = useRef(true);
- const groups = projectGroups(heads.contexts, seen);
- const facets = streamFacets(groups);
- const visible = filterStreams(groups, filters, query);
- const count = visible.reduce(
- (total, group) => total + group.streams.length,
- 0,
- );
- const currentStream = current ? streamKey(current) : "";
- const filtered = streamFiltersChanged(filters) || query.trim() !== "";
- useEffect(() => {
- const element = overlay.current;
- const opener =
- document.activeElement instanceof HTMLElement
- ? document.activeElement
- : null;
- const covered = Array.from(element?.parentElement?.children ?? []).filter(
- (child): child is HTMLElement =>
- child instanceof HTMLElement &&
- child !== element &&
- !child.hasAttribute("inert"),
- );
- for (const child of covered) child.setAttribute("inert", "");
- search.current?.focus();
- return () => {
- for (const child of covered) child.removeAttribute("inert");
- if (!restoreFocus.current) return;
- (opener?.isConnected
- ? opener
- : document.querySelector("#inbox-open")
- )?.focus();
- };
- }, []);
- const choose = (context: ApiContext) => {
- restoreFocus.current = false;
- closeInbox();
- openStream(context);
- };
- const clear = () => {
- setFilters(defaultStreamFilters);
- setQuery("");
- };
- const onKeyDown = (event: KeyboardEvent) => {
- // Facet menus render in a portal; their Escape closes only the menu.
- if (
- event.key === "Escape" &&
- event.target instanceof Node &&
- overlay.current?.contains(event.target)
- ) {
- event.preventDefault();
- closeInbox();
- }
- };
- const row = (
- stream: ProjectStream,
- group: ProjectGroup,
- labeled: boolean,
- ) => (
-
-
-
- );
- return (
-
-
-
-
- }
- />
-
-
- setQuery(event.target.value)}
- />
- setFilters({ ...filters, repos })}
- />
- {(facets.sessions.length > 0 || filters.sessions.length > 0) && (
- setFilters({ ...filters, sessions })}
- />
- )}
- {(facets.worktrees.length > 0 || filters.worktrees.length > 0) && (
- setFilters({ ...filters, worktrees })}
- />
- )}
- {(facets.hosts.length > 1 || filters.hosts.length > 0) && (
- setFilters({ ...filters, hosts })}
- />
- )}
-
- setFilters({ ...filters, unread })}
- />
- Unread only
-
- {filtered && (
-
- Clear filters
-
- )}
-
-
- {filtered ? `${pluralize(count, "review")} shown` : ""}
-
- {heads.error ? (
-
- ) : !heads.loaded ? (
-
- ) : !count ? (
-
- Clear filters
-
- )
- }
- />
- ) : streams.group === "recent" ? (
-
- {recentStreams(visible).map((stream) =>
- row(stream, stream.group, true),
- )}
-
- ) : (
- visible.map((group) => (
-
-
-
- {group.name}
-
-
- {group.streams.map((stream) => row(stream, group, false))}
-
-
- ))
- )}
-
-
- {pluralize(count, "review")}
- {filtered &&
- ` of ${groups.reduce((total, group) => total + group.streams.length, 0)}`}
-
-
- );
-}
-
-function InboxRow({
- stream: { context, unseen },
- repository,
- piped,
- multipleHosts,
- current,
- now,
- onChoose,
-}: {
- stream: ProjectStream;
- /** Labels rows outside a repository section. */
- repository: string | undefined;
- piped: boolean;
- multipleHosts: boolean;
- current: boolean;
- now: number;
- onChoose: (context: ApiContext) => void;
-}) {
- const details = [
- streamWorktree(context),
- ...streamSessions(context),
- multipleHosts ? streamHost(context) : "",
- ]
- .filter(Boolean)
- .join(" · ");
- return (
- }
- aria-current={current ? "page" : undefined}
- onClick={(event) => streamClick(event, () => onChoose(context))}
- >
-
- {repository !== undefined && (
- <>
-
-
- {repository}
-
-
- /
-
- >
- )}
- {!contextIsPiped(context) && (
-
- )}
- {streamTitle(context)}
- {unseen && !current && }
- {typeof context.changedFileCount === "number" && (
-
- )}
-
- {relativeAge(contextActivity(context), now)}
-
-
- {details && (
- {details}
- )}
-
- );
-}
diff --git a/apps/web/src/Topbar.tsx b/apps/web/src/Topbar.tsx
index a218c2e2..8c06f799 100644
--- a/apps/web/src/Topbar.tsx
+++ b/apps/web/src/Topbar.tsx
@@ -1,14 +1,11 @@
import type { ReactNode } from "react";
-import { FolderSyncIcon, LoaderCircleIcon } from "lucide-react";
+import { FolderSyncIcon, LoaderCircleIcon, SearchIcon } from "lucide-react";
import { IconButton } from "@/components/icon-button";
import { AccountButton } from "./account.tsx";
import { ContextIdentity } from "./ContextIdentity.tsx";
-import {
- HeaderBrand,
- NewChangesButton,
- ProjectsToggle,
-} from "./ProjectsNav.tsx";
+import { HeaderBrand, ProjectsToggle } from "./ProjectsNav.tsx";
import { useCatalog } from "./session-context.tsx";
+import { useShellPolicy } from "./shell-policy.ts";
import { useSidebarState } from "./sidebar-context.tsx";
export function CurrentContext() {
@@ -47,6 +44,27 @@ export function SyncButton({
);
}
+/** Opens the review picker; Cmd/Ctrl+K opens it from anywhere. */
+function SearchReviewsButton() {
+ const { openPicker } = useCatalog();
+ const { closeMobile } = useSidebarState();
+ return (
+ {
+ closeMobile();
+ openPicker();
+ }}
+ >
+
+
+ );
+}
+
/**
* The app header shared by the review and its loading/empty shell: brand,
* current context, then trailing actions ending with account.
@@ -64,15 +82,16 @@ export function Topbar({
actions?: ReactNode;
}) {
const { mobile } = useSidebarState();
+ const { reviewNavigation } = useShellPolicy();
return (
- {mobile && }
+ {mobile && reviewNavigation && }
{lead}
{children}
-
+ {reviewNavigation &&
}
{actions}
diff --git a/apps/web/src/components/context-icon.tsx b/apps/web/src/components/context-icon.tsx
index f7cb9d38..43b2b398 100644
--- a/apps/web/src/components/context-icon.tsx
+++ b/apps/web/src/components/context-icon.tsx
@@ -12,15 +12,18 @@ const contextIconVariants = cva("", {
defaultVariants: { size: "inherit", tone: "current" },
});
-/** A review context's kind: a terminal for piped input, a repository otherwise. */
+/**
+ * A review context's kind: a repository, or a terminal for the piped capture
+ * a capture process shows.
+ */
export function ContextIcon({
- piped,
+ piped = false,
size,
tone,
className,
...props
}: Omit, "size"> &
- VariantProps & { piped: boolean }) {
+ VariantProps & { piped?: boolean }) {
const Icon = piped ? SquareTerminalIcon : FolderGit2Icon;
return (
void;
+ /** Accessible name of the chevron, e.g. "Collapse diffx". */
+ toggleLabel: string;
+ /** ID of the children the chevron shows or hides. */
+ controls: string;
+ onActivate: () => void;
+ /** Accessible name and tooltip of the label's action. */
+ label: string;
+ className?: string;
+ /** The label's content: icon, name and trailing metadata. */
+ children: ReactNode;
+}) {
+ return (
+
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx
new file mode 100644
index 00000000..5fafc51d
--- /dev/null
+++ b/apps/web/src/components/ui/combobox.tsx
@@ -0,0 +1,143 @@
+import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox";
+import { CheckIcon, SearchIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupInput,
+} from "@/components/ui/input-group";
+import {
+ menuItemClassName,
+ menuPopupClassName,
+} from "@/components/ui/dropdown-menu";
+
+// A menu whose rows filter as you type: a trigger opens a raised popup with
+// the filter input on top, then the matching rows. Rows and surface match
+// ui/dropdown-menu so switching menus read as one family.
+
+const Combobox = ComboboxPrimitive.Root;
+
+function ComboboxTrigger(props: ComboboxPrimitive.Trigger.Props) {
+ return ;
+}
+
+function ComboboxContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: ComboboxPrimitive.Popup.Props &
+ Pick<
+ ComboboxPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ );
+}
+
+/** The filter field leading the popup; it owns focus while the popup is open. */
+function ComboboxSearch({
+ label,
+ className,
+ ...props
+}: Omit & {
+ /** Accessible name; placeholders are hints, not labels. */
+ label: string;
+}) {
+ return (
+
+
+
+
+
+ }
+ {...props}
+ />
+
+
+ );
+}
+
+function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
+ return (
+
+ );
+}
+
+/** A row; the selected row carries a trailing check. */
+function ComboboxItem({
+ className,
+ children,
+ ...props
+}: ComboboxPrimitive.Item.Props) {
+ return (
+
+ {children}
+
+
+
+
+ );
+}
+
+/** Shown in place of the list when the filter matches nothing. */
+function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
+ return (
+
+ );
+}
+
+export {
+ Combobox,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxItem,
+ ComboboxList,
+ ComboboxSearch,
+ ComboboxTrigger,
+};
diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx
index 36a2ec48..04192f69 100644
--- a/apps/web/src/components/ui/dropdown-menu.tsx
+++ b/apps/web/src/components/ui/dropdown-menu.tsx
@@ -3,7 +3,12 @@ import { CheckIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
-const dropdownMenuItemClassName =
+/** Menu and combobox popups share one raised surface. */
+export const menuPopupClassName =
+ "min-w-32 rounded-control bg-raised text-fg shadow-overlay ring-1 ring-border outline-none";
+
+/** Menu and combobox rows share one highlighted/disabled look. */
+export const menuItemClassName =
"relative flex min-h-control cursor-default items-center gap-2 rounded-control py-1 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-selected focus:text-accent data-highlighted:bg-selected data-highlighted:text-accent data-disabled:pointer-events-none data-disabled:opacity-60 [&_svg]:pointer-events-none [&_svg]:size-icon [&_svg]:shrink-0";
function DropdownMenu(props: MenuPrimitive.Root.Props) {
@@ -38,7 +43,8 @@ function DropdownMenuContent({
);
@@ -127,7 +133,7 @@ function DropdownMenuRadioItem({
{children}
diff --git a/apps/web/src/gallery/Gallery.tsx b/apps/web/src/gallery/Gallery.tsx
index 92b510d7..797f829e 100644
--- a/apps/web/src/gallery/Gallery.tsx
+++ b/apps/web/src/gallery/Gallery.tsx
@@ -19,6 +19,15 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Combobox,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxItem,
+ ComboboxList,
+ ComboboxSearch,
+ ComboboxTrigger,
+} from "@/components/ui/combobox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -603,6 +612,38 @@ function Overlays() {
);
}
+const branches = ["main", "feat/rate-limit", "fix/auth", "docs/release"];
+
+function Comboboxes() {
+ return (
+
+
+
+ main
+
+ }
+ />
+
+
+ No matching branches
+
+ {(branch: string) => (
+
+ {branch}
+
+ )}
+
+
+
+
+ );
+}
+
function Dialogs() {
const [open, setOpen] = useState(true);
return (
@@ -626,6 +667,8 @@ export function Gallery({ page }: { page: string | null }) {
{page === "overlays" ? (
+ ) : page === "combobox" ? (
+
) : page === "dialog" ? (
) : (
diff --git a/apps/web/src/project-nav.ts b/apps/web/src/project-nav.ts
index 3f251ef7..b9d81309 100644
--- a/apps/web/src/project-nav.ts
+++ b/apps/web/src/project-nav.ts
@@ -1,12 +1,8 @@
import type { ApiContext } from "@diffx/api";
import { compareContextActivity, contextActivity } from "./context-activity.ts";
-import {
- contextHasChanges,
- contextIsPiped,
- contextTimestamp,
-} from "./project-picker.ts";
+import { contextHasChanges, searchWords } from "./project-picker.ts";
-// The projects drawer shows only current work: each stream's head with changes.
+// The review column shows only current work: each stream's head with changes.
// Stale snapshots stay reachable through the review picker (Cmd/Ctrl+K).
export type ProjectStream = {
@@ -18,15 +14,11 @@ export type ProjectStream = {
export type ProjectGroup = {
id: string;
name: string;
- kind: "repository" | "piped";
streams: ProjectStream[];
lastActivity: number;
multipleHosts: boolean;
};
-export const pipedProjectId = "piped";
-const pipedLimit = 5;
-
/** Saved per browser: stream ID to the newest context the user has opened. */
export type SeenStreams = Record;
@@ -35,7 +27,6 @@ export function streamKey(context: ApiContext) {
}
export function projectKey(context: ApiContext) {
- if (contextIsPiped(context)) return pipedProjectId;
return context.repositoryId ?? `name:${context.name}`;
}
@@ -44,11 +35,9 @@ export function projectGroups(heads: ApiContext[], seen: SeenStreams | null) {
for (const context of heads) {
if (context.stale || !contextHasChanges(context)) continue;
const id = projectKey(context);
- const piped = id === pipedProjectId;
const group = groups.get(id) ?? {
id,
- name: piped ? "Piped" : context.name,
- kind: piped ? "piped" : "repository",
+ name: context.name,
streams: [],
lastActivity: 0,
multipleHosts: false,
@@ -65,13 +54,8 @@ export function projectGroups(heads: ApiContext[], seen: SeenStreams | null) {
const result = [...groups.values()];
for (const group of result) {
group.streams.sort((left, right) =>
- group.kind === "piped"
- ? contextTimestamp(right.context) - contextTimestamp(left.context) ||
- left.id.localeCompare(right.id)
- : compareContextActivity(left.context, right.context),
+ compareContextActivity(left.context, right.context),
);
- if (group.kind === "piped")
- group.streams = group.streams.slice(0, pipedLimit);
group.multipleHosts =
new Set(group.streams.map((stream) => streamHost(stream.context))).size >
1;
@@ -80,11 +64,9 @@ export function projectGroups(heads: ApiContext[], seen: SeenStreams | null) {
const leftHead = left.streams[0]?.context;
const rightHead = right.streams[0]?.context;
return (
- Number(left.kind === "piped") - Number(right.kind === "piped") ||
(leftHead && rightHead
? compareContextActivity(leftHead, rightHead)
- : 0) ||
- left.id.localeCompare(right.id)
+ : 0) || left.id.localeCompare(right.id)
);
});
}
@@ -100,37 +82,21 @@ export type RecentStream = ProjectStream & { group: ProjectGroup };
/**
* Every visible stream across repositories, most active first, so hopping
- * between agents ignores repository boundaries. Piped keeps its capture order.
+ * between agents ignores repository boundaries.
*/
export function recentStreams(groups: ProjectGroup[]): RecentStream[] {
return groups
.flatMap((group) => group.streams.map((stream) => ({ ...stream, group })))
- .sort(
- (left, right) =>
- Number(left.group.kind === "piped") -
- Number(right.group.kind === "piped") ||
- (left.group.kind === "piped"
- ? 0
- : compareContextActivity(left.context, right.context)),
- );
+ .sort((left, right) => compareContextActivity(left.context, right.context));
}
-/** Persisted inbox facets; an empty facet matches every stream. */
-export type StreamFilters = {
- repos: string[];
- sessions: string[];
- hosts: string[];
- worktrees: string[];
- unread: boolean;
-};
-
-export const defaultStreamFilters: StreamFilters = {
- repos: [],
- sessions: [],
- hosts: [],
- worktrees: [],
- unread: false,
-};
+/**
+ * The stream a repository opens to from the rail, its name in the column, or
+ * the header repository combobox: its most active current stream.
+ */
+export function latestStream(group: ProjectGroup): ProjectStream | undefined {
+ return group.streams[0];
+}
export function streamWorktree(context: ApiContext) {
return context.observation?.linkedWorktree
@@ -155,82 +121,18 @@ export function streamSessions(context: ApiContext) {
];
}
-export function streamFacets(groups: ProjectGroup[]) {
- const streams = groups.flatMap((group) => group.streams);
- const values = (pick: (context: ApiContext) => string[]) =>
- [...new Set(streams.flatMap((stream) => pick(stream.context)))].sort(
- (left, right) => left.localeCompare(right),
- );
- return {
- repos: [...new Set(groups.map((group) => group.name))],
- sessions: values(streamSessions),
- hosts: values((context) => [streamHost(context)].filter(Boolean)),
- worktrees: values((context) => [streamWorktree(context)].filter(Boolean)),
- };
-}
-
-export function streamFiltersChanged(filters: StreamFilters) {
- return (
- filters.unread ||
- filters.repos.length > 0 ||
- filters.sessions.length > 0 ||
- filters.hosts.length > 0 ||
- filters.worktrees.length > 0
- );
-}
-
-/** Keeps groups and order; drops streams outside the facets or text query. */
-export function filterStreams(
- groups: ProjectGroup[],
- filters: StreamFilters,
- query: string,
-): ProjectGroup[] {
- const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
- const matches = (selected: string[], values: string[]) =>
- !selected.length || values.some((value) => selected.includes(value));
- return groups
- .filter((group) => matches(filters.repos, [group.name]))
- .map((group) => ({
- ...group,
- streams: group.streams.filter(({ context, unseen }) => {
- if (filters.unread && !unseen) return false;
- const sessions = streamSessions(context);
- const host = streamHost(context);
- const worktree = streamWorktree(context);
- if (!matches(filters.sessions, sessions)) return false;
- if (!matches(filters.hosts, [host])) return false;
- if (!matches(filters.worktrees, [worktree])) return false;
- const text = [group.name, streamTitle(context), worktree, host]
- .concat(sessions)
- .join(" ")
- .toLowerCase();
- return terms.every((term) => text.includes(term));
- }),
- }))
- .filter((group) => group.streams.length > 0);
-}
-
-export function readStreamFilters(raw: string | null): StreamFilters {
- try {
- const data: unknown = JSON.parse(raw ?? "{}");
- if (typeof data !== "object" || data === null || Array.isArray(data))
- return defaultStreamFilters;
- const strings = (key: string) => {
- const value: unknown = Reflect.get(data, key);
- return Array.isArray(value)
- ? value.filter((item) => typeof item === "string")
- : [];
- };
- return {
- repos: strings("repos"),
- sessions: strings("sessions"),
- hosts: strings("hosts"),
- worktrees: strings("worktrees"),
- unread: Reflect.get(data, "unread") === true,
- };
- } catch {
- return defaultStreamFilters;
- }
+/** Every query word appears in the stream's branch, worktree, host or sessions. */
+export function streamMatches(stream: ProjectStream, query: string) {
+ const { context } = stream;
+ const text = [
+ streamTitle(context),
+ streamWorktree(context),
+ streamHost(context),
+ ...streamSessions(context),
+ ]
+ .join(" ")
+ .toLowerCase();
+ return searchWords(query).every((word) => text.includes(word));
}
/** One or two letters marking a repository in the collapsed review rail. */
@@ -245,7 +147,6 @@ export function repoMonogram(name: string) {
}
export function streamTitle(context: ApiContext) {
- if (contextIsPiped(context)) return "Piped diff";
return context.branch ?? context.observation?.branch ?? "Detached HEAD";
}
@@ -287,22 +188,6 @@ export function baselineSeen(heads: ApiContext[]): SeenStreams {
);
}
-/** Unread content heads, independent of ingestion order and submission touches. */
-export function unseenChangedHeads(
- heads: ApiContext[],
- seen: SeenStreams | null,
-) {
- if (seen === null) return [];
- return heads
- .filter(
- (context) =>
- !context.stale &&
- contextHasChanges(context) &&
- seen[streamKey(context)] !== context.id,
- )
- .sort(compareContextActivity);
-}
-
export function readSeen(raw: string | null): SeenStreams | null {
if (raw === null) return null;
try {
diff --git a/apps/web/src/project-picker.ts b/apps/web/src/project-picker.ts
index 89bd9e40..eeffa348 100644
--- a/apps/web/src/project-picker.ts
+++ b/apps/web/src/project-picker.ts
@@ -16,8 +16,6 @@ export const defaultPickerFilters: PickerFilters = {
worktrees: [],
};
-export const pipedGroupId = "piped";
-
export function contextIsPiped(context: ApiContext) {
return context.source === "stdin";
}
@@ -54,9 +52,7 @@ export function pickerFilterValues(context: ApiContext) {
}
export function pickerFilterOptions(contexts: ApiContext[]) {
- const values = contexts
- .filter((context) => !contextIsPiped(context))
- .map(pickerFilterValues);
+ const values = contexts.map(pickerFilterValues);
const options = (key: "hosts" | "branches" | "worktrees") =>
[...new Set(values.map((value) => value[key]).filter(Boolean))].sort(
(left, right) => left.localeCompare(right),
@@ -69,7 +65,6 @@ export function pickerFilterOptions(contexts: ApiContext[]) {
}
function matchesFilters(context: ApiContext, filters: PickerFilters) {
- if (contextIsPiped(context)) return true;
if (!filters.includeAll && !contextHasChanges(context)) return false;
if (
filters.freshness !== "all" &&
@@ -87,9 +82,7 @@ function matchesFilters(context: ApiContext, filters: PickerFilters) {
}
export function contextDetail(context: ApiContext) {
- return contextIsPiped(context)
- ? "Piped"
- : (context.branch ?? "Branch unknown");
+ return context.branch ?? "Branch unknown";
}
export function contextIsLinkedWorktree(context: ApiContext) {
@@ -136,45 +129,31 @@ function matchScore(value: string, word: string): number {
return position === word.length ? 1 : 0;
}
-function pickerRecency(context: ApiContext) {
- if (contextIsPiped(context)) return contextTimestamp(context);
- return context.lastSubmittedAt;
-}
-
export function searchWords(query: string) {
return query.toLowerCase().trim().split(/\s+/).filter(Boolean);
}
/** Each word's best match across searchable metadata; 0 means no match. */
export function contextMatches(context: ApiContext, words: string[]) {
- const values = (
- contextIsPiped(context)
- ? [
- "Piped",
- contextTimestampLabel(context),
- new Date(contextTimestamp(context)).toLocaleString(),
- context.id,
- ]
- : [
- context.name,
- context.branch,
- context.worktreeName,
- context.root,
- context.observation?.repositoryName,
- context.observation?.remoteUrl,
- context.observation?.hostname,
- context.observation?.sourceId,
- context.observation?.runId,
- context.observation?.agent,
- context.observation?.trigger,
- ...(context.sessions?.flatMap((session) => [
- session.sourceId,
- session.harness,
- session.id,
- session.name,
- ]) ?? []),
- ]
- )
+ const values = [
+ context.name,
+ context.branch,
+ context.worktreeName,
+ context.root,
+ context.observation?.repositoryName,
+ context.observation?.remoteUrl,
+ context.observation?.hostname,
+ context.observation?.sourceId,
+ context.observation?.runId,
+ context.observation?.agent,
+ context.observation?.trigger,
+ ...(context.sessions?.flatMap((session) => [
+ session.sourceId,
+ session.harness,
+ session.id,
+ session.name,
+ ]) ?? []),
+ ]
.filter((value) => typeof value === "string")
.map((value) => value.toLowerCase());
return words.map((word) =>
@@ -201,13 +180,9 @@ export function pickerResults(
.filter(({ matches }) => matches.every((score) => score > 0))
.sort(
(left, right) =>
- Number(contextIsPiped(left.context)) -
- Number(contextIsPiped(right.context)) ||
- (contextIsPiped(left.context) && contextIsPiped(right.context)
- ? 0
- : Number(contextHasChanges(right.context)) -
- Number(contextHasChanges(left.context))) ||
- pickerRecency(right.context) - pickerRecency(left.context) ||
+ Number(contextHasChanges(right.context)) -
+ Number(contextHasChanges(left.context)) ||
+ right.context.lastSubmittedAt - left.context.lastSubmittedAt ||
right.score - left.score ||
left.context.name.localeCompare(right.context.name) ||
contextDetail(left.context).localeCompare(
@@ -219,9 +194,9 @@ export function pickerResults(
}
export function observationSummary(context: ApiContext) {
- if (contextIsPiped(context)) return contextTimestampLabel(context);
const observation = context.observation;
- if (!observation) return context.root ?? context.submittedFrom ?? "Piped";
+ if (!observation)
+ return context.root ?? context.submittedFrom ?? "Path unknown";
const collected = new Date(observation.collectedAt).toLocaleString(
undefined,
{
@@ -257,6 +232,7 @@ function sessionLabels(context: ApiContext) {
}
export function contextDiagnostics(context: ApiContext) {
+ // Only a capture process opens piped reviews; see shell-policy.ts.
if (contextIsPiped(context))
return [
"Piped",
@@ -267,7 +243,7 @@ export function contextDiagnostics(context: ApiContext) {
return [
`${context.name} · ${contextDetail(context)}`,
contextCheckoutLabel(context),
- context.root ?? context.submittedFrom ?? "Piped",
+ context.root ?? context.submittedFrom ?? "Path unknown",
observationSummary(context),
observation ? `Source: ${observation.sourceId}` : "",
observation?.runId ? `Run: ${observation.runId}` : "",
@@ -285,7 +261,7 @@ export function contextDiagnostics(context: ApiContext) {
export type PickerEntry =
| {
id: string;
- kind: "repository" | "piped";
+ kind: "repository";
context: ApiContext;
contexts: ApiContext[];
}
@@ -310,11 +286,7 @@ export function pickerEntries(
const results = pickerResults(contexts, query, filters);
if (repositoryId)
return results
- .filter((context) =>
- repositoryId === pipedGroupId
- ? contextIsPiped(context)
- : !contextIsPiped(context) && context.repositoryId === repositoryId,
- )
+ .filter((context) => context.repositoryId === repositoryId)
.map((context): PickerEntry => ({
id: context.id,
kind: "context",
@@ -322,9 +294,7 @@ export function pickerEntries(
}));
const entries: PickerEntry[] = [];
const repositories = new Map();
- const piped = results.filter(contextIsPiped);
for (const context of results) {
- if (contextIsPiped(context)) continue;
if (!context.repositoryId) {
entries.push({ id: context.id, kind: "context", context });
continue;
@@ -343,13 +313,5 @@ export function pickerEntries(
entries.push(entry);
}
}
- const firstPiped = piped[0];
- if (firstPiped)
- entries.push({
- id: pipedGroupId,
- kind: "piped",
- context: firstPiped,
- contexts: piped,
- });
return entries;
}
diff --git a/apps/web/src/session-context.tsx b/apps/web/src/session-context.tsx
index ed2e9c51..dbb618f7 100644
--- a/apps/web/src/session-context.tsx
+++ b/apps/web/src/session-context.tsx
@@ -12,11 +12,7 @@ import {
} from "react";
import { useIdentity } from "./identity-context.tsx";
import { latestChangedContext } from "./context-activity.ts";
-import {
- streamKey,
- unseenChangedHeads,
- type SeenStreams,
-} from "./project-nav.ts";
+import { streamKey, type SeenStreams } from "./project-nav.ts";
import { useSeenStreams } from "./use-seen-streams.ts";
import { useSidebarState } from "./sidebar-context.tsx";
import {
@@ -57,7 +53,6 @@ interface Catalog {
/** The selected review once loaded, otherwise its catalog entry if known. */
current: ApiContext | undefined;
seen: SeenStreams | null;
- unreadHeads: ApiContext[];
selectedId: string;
/** Pins one snapshot. */
select: (id: string, options?: { restoreFocus?: boolean }) => void;
@@ -402,16 +397,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
heads.loaded,
ready ? current : undefined,
);
- const unreadHeads = useMemo(
- () => unseenChangedHeads(heads.contexts, seen),
- [heads.contexts, seen],
- );
const catalog = useMemo(
() => ({
heads,
current,
seen,
- unreadHeads,
selectedId,
select,
openStream,
@@ -426,7 +416,6 @@ export function SessionProvider({ children }: { children: ReactNode }) {
heads,
current,
seen,
- unreadHeads,
selectedId,
select,
openStream,
diff --git a/apps/web/src/shell-policy.ts b/apps/web/src/shell-policy.ts
new file mode 100644
index 00000000..e7de50b9
--- /dev/null
+++ b/apps/web/src/shell-policy.ts
@@ -0,0 +1,30 @@
+import type { ApiBootstrap } from "@diffx/api";
+import { useIdentity } from "./identity-context.tsx";
+
+/**
+ * What the review shell offers, resolved once from the server's bootstrap.
+ * Components read the policy, never the deployment mode or input.
+ */
+export type ShellPolicy = {
+ /**
+ * Switching between reviews: the Reviews column and rail, header
+ * repository/branch comboboxes, search (Cmd/Ctrl+K) and sync. A local
+ * capture (piped stdin or a fixture) serves one fixed diff with nothing to
+ * switch to, so it shows only that diff and its file sidebar.
+ */
+ reviewNavigation: boolean;
+};
+
+export function shellPolicy(bootstrap: ApiBootstrap): ShellPolicy {
+ return {
+ reviewNavigation:
+ bootstrap.mode === "remote" || bootstrap.input === "checkouts",
+ };
+}
+
+const unresolved: ShellPolicy = { reviewNavigation: false };
+
+export function useShellPolicy(): ShellPolicy {
+ const { state } = useIdentity();
+ return state.status === "ready" ? shellPolicy(state.bootstrap) : unresolved;
+}
diff --git a/apps/web/src/style.css b/apps/web/src/style.css
index 4f17bc42..19ff8bd9 100644
--- a/apps/web/src/style.css
+++ b/apps/web/src/style.css
@@ -196,6 +196,14 @@
.stream-column:has(+ .navigation-panel[hidden]) {
border-radius: var(--radius-lg);
}
+/* A capture has no review column; with its file sidebar collapsed nothing
+ remains, so the island hides (still mounted) and the review fills the row. */
+.navigation-island[data-empty] {
+ display: none;
+}
+.navigation-island[data-empty] + .workspace {
+ margin-left: 0;
+}
.navigation-panel {
display: flex;
flex: 0 0 var(--sidebar-width);
@@ -251,39 +259,6 @@
background: var(--panel);
border-radius: inherit;
}
-/* Covers the review island, which stays mounted and inert underneath. */
-.stream-inbox {
- position: absolute;
- inset: 0;
- z-index: var(--layer-sidebar);
- display: flex;
- flex-direction: column;
- background: var(--bg);
-}
-.inbox-body {
- flex: 1;
- min-height: 0;
- overflow: auto;
- width: 100%;
- max-width: var(--reading-width);
- padding: var(--space-3) var(--page-gutter) var(--space-4);
-}
-.inbox-list {
- margin: 0;
- padding: 0;
- list-style: none;
-}
-.inbox-footer {
- display: flex;
- flex-shrink: 0;
- align-items: center;
- min-height: var(--statusbar-height);
- border-top: 1px solid var(--border);
- padding: 0 var(--page-gutter);
- background: var(--panel);
- color: var(--muted);
- font: var(--text-xs) var(--font-mono);
-}
.projects-scroll {
flex: 1;
min-height: 0;
@@ -301,7 +276,6 @@
.stream-meta {
padding-left: calc(var(--icon-sm) + var(--space-1-5));
}
-.new-changes-menu,
.context-switch-menu {
width: min(22rem, calc(100vw - 2 * var(--page-gutter)));
}
@@ -316,6 +290,11 @@
border-left: 1px solid var(--border-muted);
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
}
+/* Without a review column the file sidebar leads the island alone. */
+.navigation-panel:first-child .sidebar {
+ border-left: 0;
+ border-radius: var(--radius-lg);
+}
.file-filter-row {
display: flex;
gap: var(--space-1-5);
diff --git a/apps/web/src/use-stream-nav.ts b/apps/web/src/use-stream-nav.ts
index 8baa203d..440b9b75 100644
--- a/apps/web/src/use-stream-nav.ts
+++ b/apps/web/src/use-stream-nav.ts
@@ -1,16 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { save, saved } from "./preferences.ts";
-import {
- readStreamFilters,
- type StreamFilters,
- type StreamGroupMode,
-} from "./project-nav.ts";
+import type { StreamGroupMode } from "./project-nav.ts";
/** The desktop review column: a repository rail or the stream list. */
export type StreamColumnState = "rail" | "list";
-// One owner for the review column's desktop state and the inbox overlay that
-// extends it. Mobile drawers stay with use-sidebar.ts.
+// One owner for the review column's desktop state. Mobile drawers stay with
+// use-sidebar.ts.
export function useStreamNav() {
const [state, setState] = useState(() =>
saved("streams-column") === "rail" ? "rail" : "list",
@@ -22,45 +18,23 @@ export function useStreamNav() {
const [group, setGroup] = useState(() =>
saved("streams-group") === "recent" ? "recent" : "repo",
);
- const [filters, setFilters] = useState(() =>
- readStreamFilters(saved("inbox-filters")),
- );
- const [inboxOpen, setInboxOpen] = useState(false);
- // Expanding from a repository mark scrolls the list to that repository.
- const [focusRepo, setFocusRepo] = useState("");
useEffect(() => {
save("streams-column", state);
}, [state]);
useEffect(() => {
save("streams-group", group);
}, [group]);
- useEffect(() => {
- save("inbox-filters", JSON.stringify(filters));
- }, [filters]);
useEffect(() => {
save("streams-width", String(width));
document.documentElement.style.setProperty("--streams-width", `${width}px`);
}, [width]);
- const expand = useCallback((repo = "") => {
- setState("list");
- setFocusRepo(repo);
- }, []);
+ const expand = useCallback(() => setState("list"), []);
const collapse = useCallback(() => setState("rail"), []);
- const openInbox = useCallback(() => setInboxOpen(true), []);
- const closeInbox = useCallback(() => setInboxOpen(false), []);
- const clearFocusRepo = useCallback(() => setFocusRepo(""), []);
return {
state,
width,
group,
setGroup,
- filters,
- setFilters,
- inboxOpen,
- openInbox,
- closeInbox,
- focusRepo,
- clearFocusRepo,
expand,
collapse,
/** `reserved` is the file sidebar's width, kept beside a 320px diff. */
diff --git a/apps/web/test/browser/app.spec.ts b/apps/web/test/browser/app.spec.ts
index 2527bcf0..d845193c 100644
--- a/apps/web/test/browser/app.spec.ts
+++ b/apps/web/test/browser/app.spec.ts
@@ -1,6 +1,12 @@
import { type Locator, type Page } from "@playwright/test";
import { createApiClient } from "@diffx/api";
-import { expect, fileToggle, reviewedToggle, test } from "./fixtures.ts";
+import {
+ expect,
+ fileToggle,
+ reviewedToggle,
+ test,
+ withReviewNavigation,
+} from "./fixtures.ts";
import { resetFixtureState } from "./fixture-state.ts";
async function resolvedRadius(
@@ -1700,9 +1706,49 @@ test("display controls expose state and persist preferences", async ({
.toBe("github");
});
+test("a capture shows only its diff: no review navigation, search or switcher", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await expect(page.locator("#file-count")).not.toHaveText("0");
+ const banner = page.getByRole("banner");
+ // Static piped identity replaces the repository and branch comboboxes.
+ await expect(banner.locator(".context-identity")).toContainText("Piped");
+ await expect(banner.getByRole("combobox")).toHaveCount(0);
+ await expect(page.getByRole("navigation", { name: "Reviews" })).toHaveCount(
+ 0,
+ );
+ await expect(page.locator(".stream-column")).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: "Search reviews" }),
+ ).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: "Sync all checkouts" }),
+ ).toHaveCount(0);
+ await page.locator("#viewer").click({ position: { x: 4, y: 4 } });
+ await page.keyboard.press("Control+K");
+ await page.keyboard.press("Meta+K");
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ // The file sidebar leads the island alone; collapsing it hides the island.
+ await expect(page.locator("#sidebar")).toBeVisible();
+ await page.getByRole("button", { name: "Hide file sidebar" }).click();
+ await expect(page.locator("#sidebar")).toBeHidden();
+ await expect(page.locator(".navigation-island")).toHaveCSS("display", "none");
+ await page.getByRole("button", { name: "Show file sidebar" }).click();
+ await expect(page.locator("#sidebar")).toBeVisible();
+ await page.setViewportSize({ width: 390, height: 844 });
+ await expect(
+ page.getByRole("button", { name: "Show file sidebar" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: /^(?:Show|Hide) reviews$/ }),
+ ).toHaveCount(0);
+});
+
test("header icons and sidebar tab indicator use design-system sizes", async ({
page,
}) => {
+ await withReviewNavigation(page);
await page.goto("/");
const brand = page.locator(".topbar .brand");
const filesToggle = page.getByRole("button", { name: "Hide file sidebar" });
@@ -1720,9 +1766,19 @@ test("header icons and sidebar tab indicator use design-system sizes", async ({
page.getByRole("button", { name: "Delete snapshot", exact: true }),
).toHaveCount(0);
// Desktop shows the review column and the file sidebar together; only the
- // file sidebar toggles from the header.
+ // file sidebar toggles from the header, which also leads review search.
+ await expect(
+ page
+ .getByRole("banner")
+ .getByRole("button", { name: /^(?:Show|Hide) reviews$/ }),
+ ).toHaveCount(0);
await expect(
- page.getByRole("banner").getByRole("button", { name: /reviews$/ }),
+ page.getByRole("banner").getByRole("button", { name: "Search reviews" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("navigation", { name: "Reviews" }).getByRole("button", {
+ name: "Search reviews",
+ }),
).toHaveCount(0);
await expect(filesToggle).toHaveAttribute("aria-expanded", "true");
const reviews = page.getByRole("navigation", { name: "Reviews" });
@@ -1938,6 +1994,7 @@ test("header actions show a visible hover surface in both themes", async ({
test("review column and file sidebar resize independently and persist", async ({
page,
}) => {
+ await withReviewNavigation(page);
await page.goto("/");
const column = page.locator(".stream-column");
const files = page.locator(".navigation-panel");
@@ -2275,6 +2332,7 @@ test("copy dialog reports clipboard status and restores focus", async ({
test("drawer aligns its header and retains desktop navigation density", async ({
page,
}) => {
+ await withReviewNavigation(page);
await page.setViewportSize({ width: 1280, height: 844 });
await page.goto("/");
await showDiff(page);
diff --git a/apps/web/test/browser/fixtures.ts b/apps/web/test/browser/fixtures.ts
index 3c43b968..79499490 100644
--- a/apps/web/test/browser/fixtures.ts
+++ b/apps/web/test/browser/fixtures.ts
@@ -72,3 +72,28 @@ export function fileToggle(scope: Page | Locator, state?: ToggleState) {
...state,
});
}
+
+/**
+ * The fixture server is a capture (one fixed patch), so the shell hides review
+ * navigation. Serving a checkout-process bootstrap restores the Reviews
+ * column, header comboboxes and search for tests that mock several contexts.
+ * Later `page.route` registrations take precedence, so tests can still
+ * override it.
+ */
+export async function withReviewNavigation(
+ page: Page,
+ { collect = false }: { collect?: boolean } = {},
+) {
+ await page.route("**/api/v2/bootstrap", (route) =>
+ route.fulfill({
+ json: {
+ mode: "local",
+ signupEnabled: false,
+ user: null,
+ csrfToken: null,
+ collect,
+ input: "checkouts",
+ },
+ }),
+ );
+}
diff --git a/apps/web/test/browser/local-workspace.spec.ts b/apps/web/test/browser/local-workspace.spec.ts
index 4dad7100..71233fd0 100644
--- a/apps/web/test/browser/local-workspace.spec.ts
+++ b/apps/web/test/browser/local-workspace.spec.ts
@@ -1,5 +1,5 @@
import { type Page, type Route } from "@playwright/test";
-import { expect, fileToggle, test } from "./fixtures.ts";
+import { expect, fileToggle, test, withReviewNavigation } from "./fixtures.ts";
import type { ApiContext, ApiRepositoryDiff } from "@diffx/api";
import {
comparisonKind,
@@ -8,6 +8,10 @@ import {
} from "../../src/diff-mode.ts";
import type { ReviewComment } from "../../src/review-comments.ts";
+// Every scenario here mocks several review contexts; the fixture itself is a
+// capture, so serve a checkout-process bootstrap by default.
+test.beforeEach(({ page }) => withReviewNavigation(page));
+
async function openReviewPicker(page: Page) {
await page.keyboard.press("ControlOrMeta+k");
}
@@ -781,9 +785,17 @@ test("context switching isolates delayed repository responses and fixed captures
await expect(page.locator(".context-identity-name")).toHaveText(
"Second repo",
);
+ // A stored capture is a fixed snapshot, listed like any review.
await openReviewPicker(page);
- await page.getByRole("option", { name: /^Piped/ }).click();
- await page.getByRole("option").click();
+ const picker = page.getByRole("dialog", { name: "Switch review" });
+ await expect(picker).not.toContainText("Piped");
+ await picker
+ .getByRole("combobox", { name: "Search reviews" })
+ .fill("Captured patch");
+ await page.keyboard.press("Enter");
+ await expect(page.locator(".context-identity-name")).toHaveText(
+ "Captured patch",
+ );
await expect(page.locator("#changes-title")).toHaveText("Piped");
await expect(
page.getByRole("button", { name: "Refresh changes" }),
@@ -811,7 +823,7 @@ test("empty service explains local review and remote sync", async ({
).toBeVisible();
});
-test("Piped history ignores repository filters and switches timestamp rows by keyboard", async ({
+test("picker lists stray stdin snapshots like any review, under repository filters", async ({
page,
}) => {
const state = await localWorkspace(page);
@@ -855,47 +867,24 @@ test("Piped history ignores repository filters and switches timestamp rows by ke
await ready(page);
await openReviewPicker(page);
const dialog = page.getByRole("dialog", { name: "Switch review" });
+ // No Piped group: stdin snapshots share repository grouping and filters.
+ await expect(dialog).not.toContainText("Piped");
await dialog.getByRole("button", { name: "Filters", exact: true }).click();
await dialog.getByRole("button", { name: "Stale", exact: true }).click();
await expect(dialog.getByRole("option")).toHaveCount(1);
- await expect(dialog.getByRole("option")).toContainText("Piped");
- await dialog.getByRole("combobox").fill("piped");
- await page.keyboard.press("Enter");
- await expect(
- dialog.getByRole("combobox", { name: "Search Piped" }),
- ).toHaveValue("");
- await expect(
- dialog.getByRole("button", { name: "Filters", exact: true }),
- ).toHaveCount(0);
+ await expect(dialog.getByRole("option")).toContainText("Invoking project");
+ await dialog.getByRole("combobox").press("Enter");
const rows = dialog.getByRole("option");
- await expect(rows).toHaveCount(2);
- await expect(rows.first()).toContainText("No changes");
- await expect(dialog).not.toContainText("Invoking project");
- await expect(dialog).not.toContainText("invoking-branch");
- await expect(rows).not.toContainText(["Stale", "Stale"]);
- await expect(
- dialog.getByRole("button", { name: "Stale", exact: true }),
- ).toBeHidden();
- await page.keyboard.press("ArrowDown");
+ await expect(rows).toHaveCount(1);
+ await expect(rows).toContainText("invoking-branch");
+ await expect(rows).toContainText("Stale");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/contexts\/piped-observation$/);
- const identity = page.locator(".context-identity");
- await expect(identity).toContainText("Piped");
- await expect(identity).not.toContainText("Invoking project");
- await expect(identity.locator(".context-identity-worktree")).toHaveCount(0);
- await expect(page).toHaveTitle("diffx · Piped");
+ await expect(page.locator(".context-identity-name")).toHaveText(
+ "Invoking project",
+ );
await openReviewPicker(page);
- await page.keyboard.press("Enter");
- await expect(
- dialog.getByRole("option", { name: /Current review/ }),
- ).toHaveCount(1);
- await page.keyboard.press("ArrowLeft");
- await expect(
- dialog.getByRole("combobox", { name: "Search reviews" }),
- ).toBeVisible();
- await expect(
- dialog.getByText("Stale snapshots · With changes", { exact: true }),
- ).toBeVisible();
+ await expect(dialog.getByRole("combobox")).toBeFocused();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
});
@@ -1450,7 +1439,7 @@ test("repos drawer lists current changed streams and announces newer snapshots",
await expect(nav).toHaveAccessibleName("Reviews");
await expect(page.locator("#sidebar")).toBeVisible();
await expect(
- nav.getByRole("button", { name: /^(web-app|acme-api)/ }),
+ nav.getByRole("button", { name: /^Open latest in / }),
).toHaveText([/web-app/, /acme-api/]);
await expect(nav).not.toContainText("infra");
await expect(
@@ -1488,7 +1477,15 @@ test("repos drawer lists current changed streams and announces newer snapshots",
await expect(mainStream).toBeFocused();
await expect(page.locator(".context-identity")).toContainText("web-app");
await expect(nav.getByRole("searchbox")).toHaveCount(0);
- await nav.getByRole("button", { name: "Search reviews" }).click();
+ // Search lives in the header; the column has no search or inbox.
+ await expect(nav.getByRole("button", { name: "Search reviews" })).toHaveCount(
+ 0,
+ );
+ await expect(page.getByRole("button", { name: /inbox/i })).toHaveCount(0);
+ const search = page
+ .getByRole("banner")
+ .getByRole("button", { name: "Search reviews" });
+ await search.click();
const picker = page.getByRole("dialog", { name: "Switch review" });
await expect(picker).toBeVisible();
await picker
@@ -1498,9 +1495,7 @@ test("repos drawer lists current changed streams and announces newer snapshots",
await expect(picker.getByRole("option")).toContainText("acme-api");
await page.keyboard.press("Escape");
await expect(picker).toBeHidden();
- await expect(
- nav.getByRole("button", { name: "Search reviews" }),
- ).toBeFocused();
+ await expect(search).toBeFocused();
// Recent first interleaves repositories by activity, labeling each row.
await nav.getByRole("button", { name: "Review list view" }).click();
@@ -1522,16 +1517,39 @@ test("repos drawer lists current changed streams and announces newer snapshots",
await page.reload();
await ready(page);
await expect(marks).toHaveCount(2);
- // Expanding from a mark returns to the list at that repository.
- await nav.getByRole("button", { name: "acme-api" }).click();
+ // A mark opens that repository's latest stream; the rail stays collapsed.
+ await nav
+ .getByRole("button", { name: "Open latest in acme-api", exact: true })
+ .click();
+ await expect(page).toHaveURL(/\/streams\/stream-feat%2Frate-limit$/);
+ await expect(page.locator(".context-identity")).toContainText("acme-api");
+ await expect(marks.nth(0)).toHaveAttribute("aria-current", "true");
await expect(
- nav.getByRole("link", { name: /^feat\/rate-limit/ }),
- ).toBeFocused();
+ nav.getByRole("button", { name: "Expand reviews" }),
+ ).toBeVisible();
+ await nav.getByRole("button", { name: "Expand reviews" }).click();
await expect(nav.getByRole("link")).toHaveText([
/feat\/rate-limit[\s\S]*acme-api/,
/main[\s\S]*web-app/,
]);
+ // Grouped by repository, a repository's name opens its latest stream and
+ // expands it; the chevron alone collapses or expands the group.
+ await nav.getByRole("button", { name: "Review list view" }).click();
+ await page.getByRole("menuitemradio", { name: "Group by repo" }).click();
+ await page.keyboard.press("Escape");
+ await nav.getByRole("button", { name: "Collapse web-app" }).click();
+ await expect(nav.getByRole("link", { name: /^main/ })).toHaveCount(0);
+ await expect(page).toHaveURL(/\/streams\/stream-feat%2Frate-limit$/);
+ await nav
+ .getByRole("button", { name: "Open latest in web-app", exact: true })
+ .click();
+ await expect(page).toHaveURL(/\/streams\/stream-main$/);
+ await expect(
+ nav.getByRole("button", { name: "Collapse web-app" }),
+ ).toHaveAttribute("aria-expanded", "true");
+ await expect(nav.getByRole("link", { name: /^main/ })).toBeFocused();
+
await page.setViewportSize({ width: 390, height: 844 });
const mobileTrigger = page.getByRole("button", { name: "Show reviews" });
await mobileTrigger.click();
@@ -1557,7 +1575,7 @@ test("repos drawer lists current changed streams and announces newer snapshots",
).toBeFocused();
});
-test("header switcher and inbox open streams over a preserved review", async ({
+test("header comboboxes filter and open streams over a preserved review", async ({
page,
}) => {
const state = await localWorkspace(page);
@@ -1599,79 +1617,77 @@ test("header switcher and inbox open streams over a preserved review", async ({
const nav = page.locator("#projects-nav");
const identity = page.locator(".context-identity");
- // The header repository menu opens that repository's latest stream.
- await page
- .getByRole("button", { name: "Repository acme-api. Switch repository" })
- .click();
- await page.getByRole("menuitemradio", { name: /web-app/ }).click();
+ // The header repository combobox opens that repository's latest stream.
+ const repository = (name: string) =>
+ page.getByRole("combobox", {
+ name: `Repository ${name}. Switch repository`,
+ });
+ await repository("acme-api").click();
+ await expect(
+ page.getByRole("combobox", { name: "Filter repositories" }),
+ ).toBeFocused();
+ await expect(
+ page.getByRole("listbox", { name: "Repositories" }).getByRole("option"),
+ ).toHaveText([/acme-api/, /web-app/]);
+ await page.getByRole("option", { name: /web-app/ }).click();
await expect(page).toHaveURL(/\/streams\/stream-main$/);
await expect(identity).toContainText("web-app");
await expect(nav.getByRole("link", { name: /^main/ })).toBeFocused();
- // The branch menu lists only the open repository's streams.
- await page
- .getByRole("button", { name: "Repository web-app. Switch repository" })
- .click();
- await page.getByRole("menuitemradio", { name: /acme-api/ }).click();
+ // Typing filters the rows; Enter opens the first match.
+ await repository("web-app").click();
+ await expect(
+ page.getByRole("combobox", { name: "Filter repositories" }),
+ ).toBeFocused();
+ await page.keyboard.type("ACME");
+ await expect(page.getByRole("option")).toHaveText([/acme-api/]);
+ await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/streams\/stream-fix%2Fauth$/);
- await page
- .getByRole("button", { name: "Branch fix/auth. Switch branch" })
- .click();
- const branches = page.getByRole("menuitemradio");
+
+ // The branch combobox lists only the open repository's streams and filters
+ // them by branch, worktree, session or host.
+ const branch = page.getByRole("combobox", {
+ name: "Branch fix/auth. Switch branch",
+ });
+ const branchFilter = page.getByRole("combobox", {
+ name: "Filter acme-api branches",
+ });
+ await branch.click();
+ await expect(branchFilter).toBeFocused();
+ const branches = page
+ .getByRole("listbox", { name: "acme-api branches" })
+ .getByRole("option");
await expect(branches).toHaveText([/fix\/auth/, /feat\/rate-limit/]);
- await branches.filter({ hasText: "feat/rate-limit" }).click();
+ await page.keyboard.type("nothing-matches");
+ await expect(branches).toHaveCount(0);
+ await expect(page.getByText("No matching branches")).toBeVisible();
+ // Escape closes the popup and keeps the review in place.
+ await page.keyboard.press("Escape");
+ await expect(branch).toBeFocused();
+ await expect(page).toHaveURL(/\/streams\/stream-fix%2Fauth$/);
+ await branch.click();
+ await expect(branchFilter).toBeFocused();
+ await page.keyboard.type("rate");
+ await expect(branches).toHaveText([/feat\/rate-limit/]);
+ await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/streams\/stream-feat%2Frate-limit$/);
await expect(identity).toContainText("feat/rate-limit");
+ await expect(
+ nav.getByRole("link", { name: /^feat\/rate-limit/ }),
+ ).toBeFocused();
await ready(page);
- // The inbox covers the review without resizing or scrolling it.
+ // Opening a combobox never scrolls or replaces the review underneath.
const scroller = page.locator(".diff-code-view");
await scroller.evaluate((element) => {
element.scrollTop = 200;
});
const top = await scroller.evaluate((element) => element.scrollTop);
expect(top).toBeGreaterThan(0);
- const opener = nav.getByRole("button", { name: "Open inbox" });
- await opener.click();
- const inbox = page.getByRole("region", { name: "Inbox" });
- await expect(inbox).toBeVisible();
- await expect(
- inbox.getByRole("searchbox", { name: "Filter reviews" }),
- ).toBeFocused();
- await expect(page.locator("main")).toHaveAttribute("inert", "");
- await expect(inbox.getByRole("link")).toHaveCount(3);
- await expect(inbox.locator(".inbox-footer")).toHaveText("3 reviews");
-
- // Facets combine with the text filter and persist per browser.
- await inbox.getByRole("button", { name: "Repository" }).click();
- await page
- .getByRole("menuitemcheckbox", { name: "acme-api", exact: true })
- .click();
- // Escape in a facet menu closes only the menu.
+ await repository("acme-api").click();
await page.keyboard.press("Escape");
- await expect(inbox).toBeVisible();
- await expect(inbox.getByRole("link")).toHaveCount(2);
- await expect(inbox.locator(".inbox-footer")).toHaveText("2 reviews of 3");
- await inbox.getByRole("searchbox", { name: "Filter reviews" }).fill("auth");
- await expect(inbox.getByRole("link")).toHaveCount(1);
- await inbox
- .getByRole("searchbox", { name: "Filter reviews" })
- .press("Escape");
- await expect(inbox).toBeHidden();
- await expect(opener).toBeFocused();
- await expect(page.locator("main")).not.toHaveAttribute("inert", "");
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(top);
-
- await opener.click();
- await expect(inbox.getByRole("button", { name: "Repository" })).toHaveText(
- "Repository (1)",
- );
- await expect(inbox.getByRole("link")).toHaveCount(2);
- await inbox.getByRole("link", { name: /fix\/auth/ }).click();
- await expect(inbox).toBeHidden();
- await expect(page).toHaveURL(/\/streams\/stream-fix%2Fauth$/);
- await expect(identity).toContainText("fix/auth");
- await expect(nav.getByRole("link", { name: /^fix\/auth/ })).toBeFocused();
+ await expect(page.getByRole("button", { name: /inbox/i })).toHaveCount(0);
});
test("live stream URLs open the latest head on load and keep it until navigation", async ({
@@ -1754,7 +1770,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 lists unread streams to open explicitly`, async ({
+ test(`${mode} arrivals mark unread streams without replacing the review`, async ({
page,
}) => {
await page.clock.install();
@@ -1782,6 +1798,7 @@ for (const mode of ["local", "remote"]) {
mode === "remote" ? { id: "account-a", username: "alice" } : null,
csrfToken: mode === "remote" ? "csrf" : null,
collect: mode === "local",
+ input: "checkouts",
},
}),
);
@@ -1823,14 +1840,16 @@ for (const mode of ["local", "remote"]) {
);
await page.goto("/contexts/viewed");
await ready(page);
- const global = page.getByRole("button", { name: /^New changes/ });
- await expect(global).toHaveCount(0);
+ // Unread arrivals are marked in the column; the header has no counter.
+ await expect(
+ page.getByRole("button", { name: /^New changes/ }),
+ ).toHaveCount(0);
const nav = page.getByRole("navigation", { name: "Reviews", exact: true });
await expect(nav).toBeVisible();
- await expect(nav.getByRole("listitem").getByRole("button")).toHaveText([
- /^api/,
- /^web/,
- ]);
+ const repositories = nav.getByRole("button", { name: /^Open latest in / });
+ await expect(repositories).toHaveText([/^api/, /^web/]);
+ const unread = nav.getByRole("img", { name: "New changes" });
+ await expect(unread).toHaveCount(0);
const newerApi = snapshot("api-new", "api", 200, 900);
const newerWeb = {
@@ -1844,12 +1863,14 @@ for (const mode of ["local", "remote"]) {
await page.evaluate(() =>
window.dispatchEvent(new Event("test-ingestion")),
);
- await expect(global).toHaveAccessibleName("New changes (2 streams)");
- await expect(global).toHaveText("2 new");
- await expect(nav.getByRole("listitem").getByRole("button")).toHaveText([
- /^web/,
- /^api/,
- ]);
+ await expect(repositories).toHaveText([/^web/, /^api/]);
+ // The open stream's newer head raises the notice instead of a mark.
+ const webStream = nav.getByRole("link", { name: /^fix-login/ });
+ const apiStream = nav.getByRole("link", { name: /^main/ });
+ await expect(
+ webStream.getByRole("img", { name: "New changes" }),
+ ).toBeVisible();
+ await expect(unread).toHaveCount(1);
await expect(
page.getByRole("status").filter({ hasText: "Newer snapshot" }),
).toContainText("Newer snapshot");
@@ -1857,60 +1878,24 @@ for (const mode of ["local", "remote"]) {
await page.clock.fastForward(5 * 60_000);
expect(collections).toBe(0);
- // 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();
+ // Opening a stream clears its mark; leaving the viewed stream marks its
+ // unopened newer head.
+ await webStream.click();
+ await ready(page);
+ await expect(page).toHaveURL(/\/streams\/stream-web$/);
+ await expect(webStream).toHaveAttribute("aria-current", "page");
+ await expect(webStream).toBeFocused();
+ await expect(
+ apiStream.getByRole("img", { name: "New changes" }),
+ ).toBeVisible();
+ await expect(unread).toHaveCount(1);
+ await apiStream.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.getByRole("status").filter({ hasText: "Newer snapshot" }),
).toBeHidden();
- await expect(nav.getByRole("link", { name: /^main/ })).toHaveAttribute(
- "aria-current",
- "page",
- );
- await expect(nav.getByRole("link", { name: /^main/ })).toBeFocused();
-
- // 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();
- const bounds = await global.boundingBox();
- expect(bounds && bounds.x >= 0 && bounds.x + bounds.width <= 390).toBe(
- true,
- );
- 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-web$/);
- await expect(global).toHaveCount(0);
- await expect(
- page.getByRole("button", { name: "Show file sidebar" }),
- ).toBeFocused();
+ await expect(unread).toHaveCount(0);
// A new submission of the same head may reorder projects, but adds no unread content.
heads = [
{ ...newerApi, lastSubmittedAt: 1000, lastChangedAt: 500 },
@@ -1919,13 +1904,8 @@ for (const mode of ["local", "remote"]) {
await page.evaluate(() =>
window.dispatchEvent(new Event("test-ingestion")),
);
- await page.setViewportSize({ width: 1440, height: 900 });
- await page.emulateMedia({ colorScheme: "light" });
- await expect(nav.getByRole("listitem").getByRole("button")).toHaveText([
- /^api/,
- /^web/,
- ]);
- await expect(global).toHaveCount(0);
+ await expect(repositories).toHaveText([/^api/, /^web/]);
+ await expect(unread).toHaveCount(0);
expect(collections).toBe(0);
// Root selection shares activity ordering for stored and collected heads.
@@ -1949,6 +1929,7 @@ test("local first launch waits for collection with an empty catalog", async ({
user: null,
csrfToken: null,
collect: true,
+ input: "checkouts",
},
}),
);
@@ -2056,6 +2037,7 @@ for (const initialHeads of ["stored", "arriving"]) {
user: null,
csrfToken: null,
collect: true,
+ input: "checkouts",
},
}),
);
@@ -2134,6 +2116,7 @@ test("local root waits for collection to open its default over earlier heads", a
user: null,
csrfToken: null,
collect: true,
+ input: "checkouts",
},
}),
);
@@ -2196,6 +2179,7 @@ test("unresolved navigation joins collection without reporting a missing stream"
user: null,
csrfToken: null,
collect: true,
+ input: "checkouts",
},
}),
);
@@ -2300,6 +2284,7 @@ for (const path of ["/", "/streams/local-stream"]) {
user: null,
csrfToken: null,
collect: true,
+ input: "checkouts",
},
}),
);
@@ -2356,6 +2341,7 @@ test("local startup reports failed checkouts while opening collected changes", a
user: null,
csrfToken: null,
collect: true,
+ input: "checkouts",
},
}),
);
@@ -2389,6 +2375,7 @@ test("local live URLs sync the checkouts on every load", async ({ page }) => {
user: null,
csrfToken: null,
collect: true,
+ input: "checkouts",
},
}),
);
@@ -2495,6 +2482,7 @@ test("local sync collects every checkout without replacing the open review", asy
user: null,
csrfToken: null,
collect: true,
+ input: "checkouts",
},
}),
);
@@ -2613,15 +2601,11 @@ test("each remote account baselines its own seen heads", async ({ page }) => {
);
await page.goto("/contexts/api-head");
await ready(page);
+ const nav = page.getByRole("navigation", { name: "Reviews", exact: true });
await expect(
- page
- .getByRole("navigation", { name: "Reviews", exact: true })
- .getByRole("listitem")
- .getByRole("button"),
+ nav.getByRole("button", { name: /^Open latest in / }),
).toHaveText([/^api/, /^web/]);
- await expect(page.getByRole("button", { name: /^New changes/ })).toHaveCount(
- 0,
- );
+ await expect(nav.getByRole("img", { name: "New changes" })).toHaveCount(0);
expect(
await page.evaluate(() => localStorage.getItem("projects-seen:account-b")),
).toContain("web-head");
@@ -2700,6 +2684,7 @@ test("scope history keeps live snapshot and draft through newer heads, then foll
user: null,
csrfToken: null,
collect: true,
+ input: "checkouts",
},
}),
);
diff --git a/apps/web/test/browser/native-scrollbars.spec.ts b/apps/web/test/browser/native-scrollbars.spec.ts
index 9d8b98d3..7d782b97 100644
--- a/apps/web/test/browser/native-scrollbars.spec.ts
+++ b/apps/web/test/browser/native-scrollbars.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test } from "./fixtures.ts";
+import { expect, test, withReviewNavigation } from "./fixtures.ts";
import { resetFixtureState } from "./fixture-state.ts";
test.use({ launchOptions: { ignoreDefaultArgs: ["--hide-scrollbars"] } });
@@ -7,6 +7,8 @@ test.beforeEach(async ({ request }) => resetFixtureState(request));
test("diff tracks fill frames and headers align with their gutters", async ({
page,
}) => {
+ // Header geometry is measured against the compact "Show reviews" toggle.
+ await withReviewNavigation(page);
const colorSchemes: ("light" | "dark")[] = ["light", "dark"];
for (const colorScheme of colorSchemes) {
await page.emulateMedia({ colorScheme });
diff --git a/apps/web/test/gallery/gallery.spec.ts b/apps/web/test/gallery/gallery.spec.ts
index 55a618e9..9701d4a2 100644
--- a/apps/web/test/gallery/gallery.spec.ts
+++ b/apps/web/test/gallery/gallery.spec.ts
@@ -43,6 +43,15 @@ for (const theme of themes) {
await expect(page).toHaveScreenshot(`overlays-${theme}.png`);
});
+ test(`combobox in ${theme}`, async ({ page }) => {
+ await open(page, `theme=${theme}&page=combobox`);
+ await expect(
+ page.getByRole("combobox", { name: "Filter branches" }),
+ ).toBeFocused();
+ await expect(page.getByRole("option", { name: "main" })).toBeVisible();
+ await expect(page).toHaveScreenshot(`combobox-${theme}.png`);
+ });
+
test(`confirm dialog in ${theme}`, async ({ page }) => {
await open(page, `theme=${theme}&page=dialog`);
const dialog = page.getByRole("dialog", { name: "Delete comment?" });
diff --git a/apps/web/test/gallery/goldens/combobox-dark-linux.png b/apps/web/test/gallery/goldens/combobox-dark-linux.png
new file mode 100644
index 00000000..ec1e0095
Binary files /dev/null and b/apps/web/test/gallery/goldens/combobox-dark-linux.png differ
diff --git a/apps/web/test/gallery/goldens/combobox-light-linux.png b/apps/web/test/gallery/goldens/combobox-light-linux.png
new file mode 100644
index 00000000..8fc2bfd0
Binary files /dev/null and b/apps/web/test/gallery/goldens/combobox-light-linux.png differ
diff --git a/apps/web/test/identity.test.ts b/apps/web/test/identity.test.ts
index 606a7c46..998487eb 100644
--- a/apps/web/test/identity.test.ts
+++ b/apps/web/test/identity.test.ts
@@ -10,6 +10,7 @@ test("server mode requires explicit bootstrap and consistent session identity",
user: null,
csrfToken: null,
collect: true,
+ input: "checkouts",
}),
{
mode: "local",
@@ -17,6 +18,7 @@ test("server mode requires explicit bootstrap and consistent session identity",
user: null,
csrfToken: null,
collect: true,
+ input: "checkouts",
},
);
assert.deepEqual(
@@ -37,8 +39,24 @@ test("server mode requires explicit bootstrap and consistent session identity",
user: null,
csrfToken: null,
collect: false,
+ input: "capture",
},
{ mode: "local", signupEnabled: false, user: null, csrfToken: null },
+ {
+ mode: "local",
+ signupEnabled: false,
+ user: null,
+ csrfToken: null,
+ collect: false,
+ },
+ {
+ mode: "local",
+ signupEnabled: false,
+ user: null,
+ csrfToken: null,
+ collect: false,
+ input: "stdin",
+ },
{
mode: "remote",
signupEnabled: false,
diff --git a/apps/web/test/project-nav.test.ts b/apps/web/test/project-nav.test.ts
index 9d0a867a..44b62729 100644
--- a/apps/web/test/project-nav.test.ts
+++ b/apps/web/test/project-nav.test.ts
@@ -3,20 +3,15 @@ import { test } from "node:test";
import type { ApiContext } from "@diffx/api";
import {
baselineSeen,
- defaultStreamFilters,
- filterStreams,
+ latestStream,
newerHead,
projectGroups,
readSeen,
- readStreamFilters,
recentStreams,
relativeAge,
repoMonogram,
- streamFacets,
- streamFiltersChanged,
+ streamMatches,
streamMeta,
- unseenChangedHeads,
- type StreamFilters,
} from "../src/project-nav.ts";
import {
compareContextActivity,
@@ -63,7 +58,7 @@ function head(
};
}
-test("projects list current changed streams by recent activity, piped last", () => {
+test("projects list current changed streams by recent activity", () => {
const groups = projectGroups(
[
head("old-api", "api", 10),
@@ -72,11 +67,6 @@ test("projects list current changed streams by recent activity, piped last", ()
head("clean", "api", 40, { changedFileCount: 0 }),
head("stale", "api", 50, { stale: true }),
head("gone", "web", 60, { availability: "unavailable" }),
- head("piped", "", 70, {
- source: "stdin",
- repositoryId: null,
- streamId: null,
- }),
],
null,
);
@@ -88,12 +78,23 @@ test("projects list current changed streams by recent activity, piped last", ()
[
["web", ["web"]],
["api", ["new-api", "old-api"]],
- ["Piped", ["piped"]],
],
);
assert.equal(groups[1]?.lastActivity, 20);
});
+test("a repository opens to its most active stream", () => {
+ const [api] = projectGroups(
+ [
+ head("quiet", "api", 10, { lastChangedAt: 10 }),
+ head("busy", "api", 5, { lastChangedAt: 90 }),
+ ],
+ null,
+ );
+ assert.ok(api);
+ assert.equal(latestStream(api)?.context.id, "busy");
+});
+
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 });
@@ -181,7 +182,7 @@ test("activity ties use stream then context identity, including same-name projec
}
});
-test("equal-activity repository order aligns initial and unread targets over names", () => {
+test("equal-activity repository order aligns the initial target over names", () => {
const contexts = [
head("first", "repo-z", 50, { name: "Zebra", streamId: "stream-a" }),
head("last", "repo-a", 50, { name: "Alpha", streamId: "stream-z" }),
@@ -190,61 +191,28 @@ test("equal-activity repository order aligns initial and unread targets over nam
const top = projectGroups(heads, {}).at(0)?.streams.at(0)?.context;
assert.equal(top?.id, "first");
assert.equal(top, latestChangedContext(heads));
- assert.equal(top, unseenChangedHeads(heads, {}).at(0));
- }
-});
-
-test("Piped stays last and preserves captured chronology despite activity or arrival", () => {
- const contexts = [
- head("older", "", 900, {
- source: "stdin",
- streamId: null,
- createdAt: 10,
- lastChangedAt: 500,
- }),
- head("newer", "", 100, {
- source: "stdin",
- streamId: null,
- createdAt: 20,
- lastChangedAt: 50,
- }),
- head("repository", "api", 1),
- ];
- for (const heads of [contexts, [...contexts].reverse()]) {
- const groups = projectGroups(heads, null);
- assert.equal(groups.at(0)?.id, "api");
- assert.deepEqual(
- groups.at(-1)?.streams.map((stream) => stream.context.id),
- ["newer", "older"],
- );
}
});
-test("global unread heads count changed streams and open by activity", () => {
- const viewed = head("viewed", "api", 1);
- const seen = baselineSeen([viewed]);
- const unchanged = { ...viewed, lastSubmittedAt: 999, lastChangedAt: 500 };
- const recent = head("recent", "web", 10, { lastChangedAt: 300 });
- const arrival = head("arrival", "api", 200, { lastChangedAt: 100 });
- const heads = [
- unchanged,
- arrival,
- recent,
- head("clean", "api", 400, { changedFileCount: 0 }),
- head("stale", "api", 500, { stale: true }),
- ];
- assert.deepEqual(unseenChangedHeads(heads, null), []);
- assert.deepEqual(
- unseenChangedHeads(heads, seen).map((context) => context.id),
- ["recent", "arrival"],
+test("stray stdin contexts group like any repository, by name and activity", () => {
+ const groups = projectGroups(
+ [
+ head("repository", "api", 1),
+ head("capture", "", 50, {
+ source: "stdin",
+ repositoryId: null,
+ streamId: null,
+ name: "Piped diff",
+ }),
+ ],
+ null,
);
- assert.deepEqual(unseenChangedHeads(heads, baselineSeen(heads)), []);
assert.deepEqual(
- unseenChangedHeads(heads, {
- ...seen,
- [recent.streamId ?? recent.id]: recent.id,
- }).map((context) => context.id),
- ["arrival"],
+ groups.map((group) => [group.id, group.name]),
+ [
+ ["name:Piped diff", "Piped diff"],
+ ["api", "api"],
+ ],
);
});
@@ -368,19 +336,18 @@ function observed(
});
}
-test("recent streams interleave repositories by activity, piped still last", () => {
+test("recent streams interleave repositories by activity", () => {
const groups = projectGroups(
[
head("api-old", "api", 10),
head("web-new", "web", 30),
head("api-new", "api", 20),
- head("pipe", "", 99, { source: "stdin", streamId: null }),
],
null,
);
assert.deepEqual(
groups.map((group) => group.id),
- ["web", "api", "piped"],
+ ["web", "api"],
);
assert.deepEqual(
recentStreams(groups).map(({ context, group }) => [context.id, group.id]),
@@ -388,7 +355,6 @@ test("recent streams interleave repositories by activity, piped still last", ()
["web-new", "web"],
["api-new", "api"],
["api-old", "api"],
- ["pipe", "piped"],
],
);
});
@@ -404,90 +370,32 @@ test("recent stream activity ties keep stable stream identity order", () => {
);
});
-test("inbox facets list repositories, sessions, hosts and worktrees once", () => {
- const groups = projectGroups(
- [
- observed("auth", "api", 3, {
- worktree: "wt-auth",
- sessions: [
- { harness: "claude", name: "fix auth" },
- { harness: "codex" },
- ],
- }),
- observed("main", "api", 2, { hostname: "ci" }),
- observed("site", "web", 1, { sessions: [{ harness: "codex" }] }),
- ],
- null,
- );
- assert.deepEqual(streamFacets(groups), {
- repos: ["api", "web"],
- sessions: ["codex", "fix auth (claude)"],
- hosts: ["ci", "laptop"],
- worktrees: ["wt-auth"],
- });
+test("repository marks use word initials or the name's first letters", () => {
+ assert.equal(repoMonogram("diffx"), "Di");
+ assert.equal(repoMonogram("agent-skills"), "AS");
+ assert.equal(repoMonogram("tokenInsights"), "TI");
+ assert.equal(repoMonogram("x"), "X");
});
-test("inbox filters match within each facet and across facets", () => {
- const seen = { "stream-main": "main", "stream-site": "site" };
- const groups = projectGroups(
+test("branch filter matches every word across branch, worktree, host and sessions", () => {
+ const [group] = projectGroups(
[
observed("auth", "api", 3, {
worktree: "wt-auth",
sessions: [{ harness: "claude", name: "fix auth" }],
}),
observed("main", "api", 2, { hostname: "ci" }),
- observed("site", "web", 1, { sessions: [{ harness: "codex" }] }),
],
- seen,
- );
- const ids = (filters: Partial, query = "") =>
- filterStreams(groups, { ...defaultStreamFilters, ...filters }, query).map(
- (group) => [group.id, group.streams.map((stream) => stream.context.id)],
- );
- assert.deepEqual(ids({}), [
- ["api", ["auth", "main"]],
- ["web", ["site"]],
- ]);
- assert.deepEqual(ids({ repos: ["web"] }), [["web", ["site"]]]);
- assert.deepEqual(ids({ sessions: ["codex", "fix auth (claude)"] }), [
- ["api", ["auth"]],
- ["web", ["site"]],
- ]);
- assert.deepEqual(ids({ repos: ["api"], hosts: ["laptop"] }), [
- ["api", ["auth"]],
- ]);
- assert.deepEqual(ids({ worktrees: ["wt-auth"] }), [["api", ["auth"]]]);
- assert.deepEqual(ids({ unread: true }), [["api", ["auth"]]]);
- assert.deepEqual(ids({}, "CI branch-main"), [["api", ["main"]]]);
- assert.deepEqual(ids({}, "fix"), [["api", ["auth"]]]);
- assert.deepEqual(ids({ repos: ["web"] }, "auth"), []);
- assert.equal(streamFiltersChanged(defaultStreamFilters), false);
- assert.equal(
- streamFiltersChanged({ ...defaultStreamFilters, unread: true }),
- true,
- );
-});
-
-test("saved inbox filters keep only known string and flag values", () => {
- assert.deepEqual(readStreamFilters(null), defaultStreamFilters);
- assert.deepEqual(readStreamFilters("not json"), defaultStreamFilters);
- assert.deepEqual(readStreamFilters("[]"), defaultStreamFilters);
- assert.deepEqual(
- readStreamFilters(
- '{"repos":["api",1],"sessions":"codex","hosts":["ci"],"unread":"yes"}',
- ),
- { ...defaultStreamFilters, repos: ["api"], hosts: ["ci"] },
+ null,
);
- assert.deepEqual(readStreamFilters('{"unread":true}'), {
- ...defaultStreamFilters,
- unread: true,
- });
-});
-
-test("repository marks use word initials or the name's first letters", () => {
- assert.equal(repoMonogram("diffx"), "Di");
- assert.equal(repoMonogram("agent-skills"), "AS");
- assert.equal(repoMonogram("tokenInsights"), "TI");
- assert.equal(repoMonogram("x"), "X");
- assert.equal(repoMonogram("Piped"), "Pi");
+ assert.ok(group);
+ const ids = (query: string) =>
+ group.streams
+ .filter((stream) => streamMatches(stream, query))
+ .map((stream) => stream.context.id);
+ assert.deepEqual(ids(""), ["auth", "main"]);
+ assert.deepEqual(ids("WT-AUTH"), ["auth"]);
+ assert.deepEqual(ids("ci branch-main"), ["main"]);
+ assert.deepEqual(ids("fix claude"), ["auth"]);
+ assert.deepEqual(ids("auth ci"), []);
});
diff --git a/apps/web/test/project-picker.test.ts b/apps/web/test/project-picker.test.ts
index 694a2a07..e23bf9f6 100644
--- a/apps/web/test/project-picker.test.ts
+++ b/apps/web/test/project-picker.test.ts
@@ -15,7 +15,6 @@ import {
pickerFilterOptions,
contextIsPiped,
contextTimestampLabel,
- pipedGroupId,
} from "../src/project-picker.ts";
const main: ApiContext = {
@@ -206,7 +205,7 @@ test("search lists newest changes before stronger matches", () => {
);
});
-test("captures stay selectable", () => {
+test("stray captures stay selectable and search by name like any review", () => {
const capture: ApiContext = {
...main,
id: "capture",
@@ -225,8 +224,11 @@ test("captures stay selectable", () => {
results.find((context) => context.id === "capture")?.name,
"Captured patch",
);
- assert.equal(pickerResults([main, capture], "piped")[0]?.id, "capture");
- assert.deepEqual(pickerResults([main, capture], "captured"), []);
+ assert.deepEqual(
+ pickerResults([main, capture], "captured").map((context) => context.id),
+ ["capture"],
+ );
+ assert.deepEqual(pickerResults([main, capture], "piped"), []);
});
test("changed checkouts precede newer empty projects, including search results", () => {
@@ -457,7 +459,7 @@ test("freshly reviewed observations sort by latest review without changing colle
assert.equal(repeatedReview.observation?.collectedAt, 1000);
});
-test("Piped groups stdin observations independently of repositories and filters", () => {
+test("stdin observations share repository grouping and filters; piped identity stays for captures", () => {
const piped: ApiContext = {
...observation("piped-old", "host", "run"),
source: "stdin",
@@ -465,55 +467,32 @@ test("Piped groups stdin observations independently of repositories and filters"
};
if (!piped.observation) throw new Error("Missing observation");
piped.observation.linkedWorktree = true;
- const capture: ApiContext = {
- ...main,
- id: "piped-new",
- kind: "observation",
- source: "stdin",
- createdAt: 2000,
- changedFileCount: 0,
- };
const repo = observation("repo-context", "host", "run");
- const contexts = [piped, repo, capture];
- const filters = {
+ const contexts = [piped, repo];
+ const stale = {
...defaultPickerFilters,
freshness: "stale",
- branches: ["unrelated"],
- worktrees: ["elsewhere"],
- hosts: ["absent"],
} satisfies typeof defaultPickerFilters;
- const entries = pickerEntries(contexts, "", "", filters);
- assert.equal(entries.length, 1);
- const group = entries[0];
- if (group?.kind !== "piped") throw new Error("Missing Piped group");
assert.deepEqual(
- group.contexts.map((context) => context.id),
- ["piped-new", "piped-old"],
+ pickerEntries(contexts, "", "repo", stale).map((entry) => entry.id),
+ ["piped-old"],
);
assert.deepEqual(
- pickerEntries(contexts, "", pipedGroupId, filters).map((entry) => entry.id),
- ["piped-new", "piped-old"],
+ pickerEntries(contexts, "", "", {
+ ...stale,
+ hosts: ["absent"],
+ }),
+ [],
);
assert.deepEqual(
- pickerEntries(contexts, "", "repo").map((entry) => entry.id),
- ["repo-context"],
+ pickerEntries(contexts, "", "").map((entry) => entry.id),
+ ["repository-repo"],
);
+ assert.deepEqual(pickerFilterOptions([piped]).hosts, ["host"]);
assert.equal(contextIsPiped(piped), true);
assert.equal(contextIsLinkedWorktree(piped), false);
- assert.deepEqual(pickerResults([piped], "diffx"), []);
- assert.deepEqual(pickerResults([piped], "main"), []);
- assert.deepEqual(pickerFilterOptions([piped, capture]), {
- hosts: [],
- branches: [],
- worktrees: [],
- });
assert.ok(!contextDiagnostics(piped).includes("diffx"));
- assert.ok(!contextDiagnostics(piped).includes("main"));
assert.ok(contextDiagnostics(piped).includes("Context: piped-old"));
- assert.deepEqual(
- pickerResults(contexts, "").map((context) => context.id),
- ["repo-context", "piped-new", "piped-old"],
- );
});
test("Piped timestamp labels use capture time and distinguish today, yesterday, and older years", () => {
diff --git a/docs-website/src/pages/piped-diffs.md b/docs-website/src/pages/piped-diffs.md
index 6975bb85..6419725c 100644
--- a/docs-website/src/pages/piped-diffs.md
+++ b/docs-website/src/pages/piped-diffs.md
@@ -14,6 +14,8 @@ diffx < saved.patch
```
The process serves the UI, REST and MCP until Ctrl-C, without watching a checkout.
+The page shows only that diff and its file tree; there are no other reviews to
+switch to.
Piped diffs ignore remote configuration. `diffx -` explicitly selects stdin;
combining redirected stdin with a path argument or `--path` is rejected.
Collection does not inspect Git or attach repository identity.
diff --git a/docs-website/src/pages/reviewing-changes.md b/docs-website/src/pages/reviewing-changes.md
index c8c0e40d..47c38f65 100644
--- a/docs-website/src/pages/reviewing-changes.md
+++ b/docs-website/src/pages/reviewing-changes.md
@@ -5,20 +5,24 @@ description: Navigate projects, snapshots, comments and reviewed marks.
# Reviewing changes
-The Projects drawer lists repositories by recent activity, each with its current
+The Reviews column lists repositories by recent activity, each with its current
streams: one per source, checkout, branch and comparison. Each stream shows its
newest snapshot only; clean or merged streams and superseded snapshots are hidden.
-A dot marks streams with changes you have not opened in this browser. Search
-projects to filter current streams by repository, branch, worktree, host or agent.
-Collapse the drawer from its header; it stays collapsed across visits. The header
-names the open review's repository, branch and worktree. In a local checkout
+A dot marks streams with changes you have not opened in this browser. Click a
+repository's name to open its most active stream; its chevron expands or
+collapses the list. Collapse the column to a rail of repository marks; it stays
+collapsed across visits, and each mark opens that repository's latest stream.
+The header names the open review's repository, branch and worktree; the
+repository and branch are menus you can type into to filter, then press Enter
+to switch. In a local checkout
process, **Sync all checkouts** (Alt+R) collects every checkout again; like a
remote arrival, a newer snapshot of the open stream is announced, not swapped in.
Collapsed files and folders are remembered across reloads in the same tab. When a newer snapshot
arrives for the stream you are reviewing, a notice offers **View latest** without
replacing the open review.
-Browse all reviews (Ctrl/Cmd+K) to reach older or empty snapshots. Select a
+Search reviews (the header magnifier or Ctrl/Cmd+K) to reach older or empty
+snapshots. Select a
repository, then an observation. Search/filter stored metadata to find
a branch, worktree, host, source or agent run. Switch between its collected all,
staged and unstaged scopes. Use the file tree to navigate, mark files as reviewed
diff --git a/docs/development.md b/docs/development.md
index f68fa027..11ec08b8 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -294,13 +294,15 @@ navigation, draft, review, reviewed files, and collapse state. Collapsed diff
files (per stream and scope), closed tree folders and Hide reviewed files (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 Reviews column (`ProjectsNav.tsx`),
-its inbox overlay (`StreamInbox.tsx`), the header repository and branch menus
-(`ContextIdentity.tsx`) and the popup picker (Cmd/Ctrl+K or the column's
-“Search reviews” magnifier); `project-nav.ts` owns their grouping, ordering and
-facet rules. Selecting a stream keeps the column and file sidebar open.
-`use-stream-nav.ts`, provided by `stream-nav-context.tsx`, owns the column's
-rail/list state, width, grouping, inbox visibility and filters.
+across scopes. Context selection uses the Reviews column and rail
+(`ProjectsNav.tsx`), the header repository and branch comboboxes
+(`ContextIdentity.tsx`) and the popup picker (Cmd/Ctrl+K or the header's
+“Search reviews” magnifier); `project-nav.ts` owns their grouping, ordering,
+latest-stream and branch-filter rules. Selecting a stream keeps the column and
+file sidebar open. `use-stream-nav.ts`, provided by `stream-nav-context.tsx`,
+owns the column's rail/list state, width and grouping. `shell-policy.ts`
+resolves review navigation once from the bootstrap (`input: "capture"` hides
+all of it); components read that policy, never the deployment mode.
`use-sidebar.ts` owns file sidebar visibility and width and which mobile drawer
is open, with its focus. `Navigation.tsx` owns the island and both resize
handles. The selected context scopes every request. The session owner exposes
@@ -337,8 +339,8 @@ uses catalog `lastChangedAt`, falling back to captured collection time, with
stable stream/context identity ties. Repositories use their most active visible
stream. Startup and root selection follow the same ordering. Collection triggers
are unchanged; no periodic collection or Git watcher is scheduled.
-The “Switch review” picker first selects a repository
-or Piped, then an immutable observation. Repository observations retain branch,
+The “Switch review” picker first selects a repository,
+then an immutable observation. Repository observations retain branch,
worktree, source/run, comparison, and collection-time metadata. Submission/session
associations remain separate from original snapshot metadata; catalog API filters
include `harness`, `sessionId`, `sessionName` and legacy `runId`. Global search is labeled
diff --git a/docs/system.md b/docs/system.md
index 13ca3288..6e513bed 100644
--- a/docs/system.md
+++ b/docs/system.md
@@ -144,14 +144,14 @@ stream's current `latestContextId`. The column hides clean, unavailable and
unknown-status heads, orders streams by recent code activity and repositories by
their most active visible stream, and marks heads this account has not opened in
this browser.
-Equal activity uses stable stream and context identities. A global header action
-counts unopened changed heads and opens the most active one. First visits mark
-existing heads as seen; opening a head clears its mark. Ingestion events reload
+Equal activity uses stable stream and context identities. Unopened heads carry
+accent marks in the column, rail and header combobox rows; there is no global
+counter. First visits mark 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. Review rows, header and inbox
-choices, and “View latest” open
+`/contexts/{contextId}` pins one snapshot. Review rows, rail marks, header
+combobox choices, 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
@@ -169,15 +169,18 @@ selection and the local default MCP context use the same collection default.
The Reviews column leads the navigation island, beside the open review's file
sidebar, aligned with the diff below the header. Switching streams replaces
everything to its right and keeps the column. It collapses to a rail of
-repository marks or extends over the review as the inbox, an unrouted overlay
-that filters every current stream by text and by repository, session, worktree,
-host and unread facets; the review stays mounted underneath. The column lists
-streams grouped by repository or as one recent-first list. List or rail state,
-grouping, inbox facets and each pane's width persist per browser. The header
-names the open review as repository and branch menus that switch streams.
-Compact layouts open the column and file sidebar as drawers from header
-toggles; choosing a stream closes the drawer. The column's magnifier opens the
-review picker. An explicit local sync, like an arrival,
+repository marks. A repository (its rail mark or its name in the list) opens
+its most active stream; the list's chevron alone collapses or expands it. The
+column lists streams grouped by repository or as one recent-first list. List
+or rail state, grouping and each pane's width persist per browser. The header
+names the open review as repository and branch comboboxes that filter as you
+type and switch streams. Compact layouts open the column and file sidebar as
+drawers from header toggles; choosing a stream closes the drawer. The header's
+“Search reviews” magnifier opens the review picker. A capture process
+(`input: "capture"` in the local bootstrap: piped stdin or a fixture) serves
+one fixed diff, so the shell shows no column, rail, comboboxes, search or
+picker; only checkout and remote processes navigate between reviews. An
+explicit local sync, like an arrival,
raises the notice instead of replacing the open review. Scrolling the diff
selects its top file in the tree; a file chosen from the tree, the keyboard or a
comment stays selected until it leaves view. Trailing space lets the last file
@@ -204,7 +207,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 Reviews “Search reviews”
+The review picker, opened by Cmd/Ctrl+K or the header's “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
diff --git a/internal/localserver/server_test.go b/internal/localserver/server_test.go
index 9b9aa714..5b440203 100644
--- a/internal/localserver/server_test.go
+++ b/internal/localserver/server_test.go
@@ -92,6 +92,7 @@ func TestCheckoutServerServesBeforeCollectionAndWaitsForCollectorShutdown(t *tes
for _, expected := range []struct{ path, body string }{
{"/", " {
user: null,
csrfToken: null,
collect: false,
+ input: "capture",
});
const collection = await fetch(`${url}/api/v2/collections`, {
method: "POST",