Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,9 @@ such detail into a token. New layout spacing must use the scale.
Keep the last header action's outer edge flush with the shell; never apply
the diff scrollbar inset to header actions. Theme and snapshot deletion are
absent from the header.
A local checkout process replaces Refresh with an icon-only “Sync all
checkouts” action (folder-sync icon) that never swaps the open review.
A local checkout process replaces Refresh with an icon-only sync action
(folder-sync icon), “Sync checkout” for one selected checkout and “Sync all
checkouts” for discovered checkouts, that never swaps the open review.
- Header text uses cap-height trimming with token padding to center UI and
monospace labels without clipping ascenders or descenders.
- Header lead: brand and divider identify the app. The file-sidebar toggle
Expand All @@ -292,6 +293,14 @@ such detail into a token. New layout spacing must use the scale.
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 visibility is part of that policy: a remote server always shows
the column; a local checkout process shows the column, rail and “Show
reviews” drawer toggle only once the column would list two current streams,
then keeps them for the page session so a stream leaving never reflows the
review. Until then the island holds only the file sidebar, exactly as a
capture's, with no placeholder or motion; search, Cmd/Ctrl+K, the header
comboboxes and sync remain. The column appears instantly in its persisted
list or rail state.
- 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
version: 1
slug: "apps-web-src-diffworkspace-tsx"
primary_target: "apps/web/src/DiffWorkspace.tsx"
slug: "src-diffworkspace-tsx"
primary_target: "src/DiffWorkspace.tsx"
related_targets: []
---

Expand Down
2 changes: 1 addition & 1 deletion apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ A local review workspace that combines a browser-based Git diff with comments th

## Operating Context

- A developer runs `diffx` from a Git repository or workspace. The Go server opens the review UI in a browser, shows the checkouts and worktrees discovered under its paths, and collects them all again when the developer syncs.
- A developer runs `diffx` from a Git repository or workspace. The Go server opens the review UI in a browser and shows that checkout (plus its other worktrees with `--scan-worktrees`) or the checkouts and worktrees discovered under its paths, collecting the same scope again when the developer syncs. With one review to look at, the Reviews column stays out of the way; it appears once there are two to switch between.
- 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.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/ContextIdentity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
} from "./project-nav.ts";
import { NewMark, StreamIcon, useNow } from "./ProjectsNav.tsx";
import { useCatalog } from "./session-context.tsx";
import { useShellPolicy } from "./shell-policy.ts";
import { useShellPolicy } from "./use-shell-policy.ts";

/**
* The open review's repository, branch and worktree. With review navigation,
Expand Down
17 changes: 9 additions & 8 deletions apps/web/src/Navigation.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { useRef, type ReactNode } from "react";
import { FileTreeSkeleton } from "./WorkspaceLoading.tsx";
import { ProjectsNav } from "./ProjectsNav.tsx";
import { useShellPolicy } from "./shell-policy.ts";
import { useCatalog } from "./session-context.tsx";
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. Without review
* navigation (a capture) only the file sidebar remains; the island hides,
* still mounted, while that sidebar is collapsed.
* sidebar. Each pane owns its width and resize boundary. Without the review
* column (a capture, or a local process with one review to switch to) only
* the file sidebar remains; the island hides, still mounted, while that
* sidebar is collapsed.
*/
export function Navigation({
children,
Expand All @@ -20,15 +21,15 @@ export function Navigation({
}) {
const sidebar = useSidebarState();
const streams = useStreamNavState();
const { reviewNavigation } = useShellPolicy();
const { reviewColumn } = useCatalog();
const files = Boolean(children) || loading;
const filesWidth = files && !sidebar.collapsed ? sidebar.width : 0;
const rail = !reviewNavigation || streams.state === "rail";
const rail = !reviewColumn || streams.state === "rail";
const empty =
!reviewNavigation && !sidebar.mobile && (!files || sidebar.collapsed);
!reviewColumn && !sidebar.mobile && (!files || sidebar.collapsed);
return (
<div className="navigation-island" data-empty={empty || undefined}>
{reviewNavigation && (
{reviewColumn && (
<div className={`stream-column${rail ? " is-rail" : ""}`}>
<ProjectsNav />
<PaneResizer
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/SessionShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ProjectPicker } from "./ProjectPicker.tsx";
import { Navigation } from "./Navigation.tsx";
import { SyncButton, Topbar } from "./Topbar.tsx";
import { useCatalog, useSessionShell } from "./session-context.tsx";
import { useShellPolicy } from "./shell-policy.ts";
import { useShellPolicy } from "./use-shell-policy.ts";

/**
* Presents the selected review once ready, or the loading, empty and failure
Expand Down
13 changes: 9 additions & 4 deletions apps/web/src/Topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ import { AccountButton } from "./account.tsx";
import { ContextIdentity } from "./ContextIdentity.tsx";
import { HeaderBrand, ProjectsToggle } from "./ProjectsNav.tsx";
import { useCatalog } from "./session-context.tsx";
import { useShellPolicy } from "./shell-policy.ts";
import { useShellPolicy } from "./use-shell-policy.ts";
import { useSidebarState } from "./sidebar-context.tsx";

export function CurrentContext() {
const catalog = useCatalog();
return catalog.current ? <ContextIdentity current={catalog.current} /> : null;
}

/** Collects every local checkout; shared by the review and its loading shell. */
/**
* Collects the local process's checkout, or every discovered checkout; shared
* by the review and its loading shell.
*/
export function SyncButton({
busy,
onClick,
Expand All @@ -22,11 +25,12 @@ export function SyncButton({
/** Omitted while the loading shell collects: the action is unavailable. */
onClick?: () => void;
}) {
const { collection } = useShellPolicy();
return (
<IconButton
variant="shell"
touch
label="Sync all checkouts"
label={collection === "checkout" ? "Sync checkout" : "Sync all checkouts"}
shortcut={{ alt: true, key: "R" }}
aria-busy={busy}
disabled={!onClick}
Expand Down Expand Up @@ -83,9 +87,10 @@ export function Topbar({
}) {
const { mobile } = useSidebarState();
const { reviewNavigation } = useShellPolicy();
const { reviewColumn } = useCatalog();
return (
<header className="topbar">
{mobile && reviewNavigation && <ProjectsToggle />}
{mobile && reviewColumn && <ProjectsToggle />}
<HeaderBrand />
{lead}
<CurrentContext />
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/project-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ function currentWork(context: ApiContext) {
return state !== "closed" && state !== "merged";
}

/** How many streams the review column lists across every repository. */
export function streamCount(heads: ApiContext[]) {
return projectGroups(heads, null).reduce(
(total, group) => total + group.streams.length,
0,
);
}

export function projectGroups(heads: ApiContext[], seen: SeenStreams | null) {
const groups = new Map<string, ProjectGroup>();
for (const context of heads) {
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/session-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import {
useState,
} from "react";
import { useIdentity } from "./identity-context.tsx";
import { showsReviewColumn } from "./shell-policy.ts";
import { useShellPolicy } from "./use-shell-policy.ts";
import { latestChangedContext } from "./context-activity.ts";
import { streamKey, type SeenStreams } from "./project-nav.ts";
import { streamCount, streamKey, type SeenStreams } from "./project-nav.ts";
import { useSeenStreams } from "./use-seen-streams.ts";
import { useSidebarState } from "./sidebar-context.tsx";
import {
Expand Down Expand Up @@ -74,6 +76,8 @@ interface Catalog {
collect: (() => Promise<SyncSummary | null>) | null;
collecting: boolean;
collectError: string;
/** Whether the shell shows the Reviews column, rail and drawer toggle. */
reviewColumn: boolean;
}
/** Loading, empty and failure state for the shell shown until a review is ready. */
export interface SessionShellState {
Expand Down Expand Up @@ -397,6 +401,12 @@ export function SessionProvider({ children }: { children: ReactNode }) {
heads.loaded,
ready ? current : undefined,
);
const policy = useShellPolicy();
const [reviewColumn, setReviewColumn] = useState(false);
// Once shown, the column stays for the page session, so a stream leaving the
// list never reflows the review beneath the reader.
if (!reviewColumn && showsReviewColumn(policy, streamCount(heads.contexts)))
setReviewColumn(true);
const catalog = useMemo(
() => ({
heads,
Expand All @@ -411,6 +421,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
collect,
collecting,
collectError,
reviewColumn,
}),
[
heads,
Expand All @@ -425,6 +436,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
collect,
collecting,
collectError,
reviewColumn,
],
);
// The root URL keeps waiting for changed work while showing the empty state.
Expand Down
27 changes: 19 additions & 8 deletions apps/web/src/shell-policy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { ApiBootstrap } from "@diffx/api";
import { useIdentity } from "./identity-context.tsx";

/**
* What the review shell offers, resolved once from the server's bootstrap.
Expand All @@ -13,18 +12,30 @@ export type ShellPolicy = {
* switch to, so it shows only that diff and its file sidebar.
*/
reviewNavigation: boolean;
/**
* When the Reviews column, rail and compact drawer toggle show. A remote
* server always shows them; a local checkout process waits until there are
* two current reviews to switch between.
*/
reviewColumn: "always" | "multiple";
/** What Sync collects: one selected checkout or every discovered one. */
collection: "checkout" | "checkouts" | null;
};

export function shellPolicy(bootstrap: ApiBootstrap): ShellPolicy {
if (bootstrap.mode === "remote")
return { reviewNavigation: true, reviewColumn: "always", collection: null };
const collection = bootstrap.input === "capture" ? null : bootstrap.input;
return {
reviewNavigation:
bootstrap.mode === "remote" || bootstrap.input === "checkouts",
reviewNavigation: collection !== null,
reviewColumn: "multiple",
collection,
};
}

const unresolved: ShellPolicy = { reviewNavigation: false };

export function useShellPolicy(): ShellPolicy {
const { state } = useIdentity();
return state.status === "ready" ? shellPolicy(state.bootstrap) : unresolved;
/** Whether the Reviews column shows while it lists `streams` streams. */
export function showsReviewColumn(policy: ShellPolicy, streams: number) {
return (
policy.reviewNavigation && (policy.reviewColumn === "always" || streams > 1)
);
}
13 changes: 13 additions & 0 deletions apps/web/src/use-shell-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useIdentity } from "./identity-context.tsx";
import { shellPolicy, type ShellPolicy } from "./shell-policy.ts";

const unresolved: ShellPolicy = {
reviewNavigation: false,
reviewColumn: "multiple",
collection: null,
};

export function useShellPolicy(): ShellPolicy {
const { state } = useIdentity();
return state.status === "ready" ? shellPolicy(state.bootstrap) : unresolved;
}
4 changes: 4 additions & 0 deletions apps/web/test/browser/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
reviewedToggle,
test,
withReviewNavigation,
withSecondReview,
} from "./fixtures.ts";
import { resetFixtureState } from "./fixture-state.ts";

Expand Down Expand Up @@ -1799,6 +1800,7 @@ test("header icons and sidebar tab indicator use design-system sizes", async ({
page,
}) => {
await withReviewNavigation(page);
await withSecondReview(page);
await page.goto("/");
const brand = page.locator(".topbar .brand");
const filesToggle = page.getByRole("button", { name: "Hide file sidebar" });
Expand Down Expand Up @@ -2045,6 +2047,7 @@ test("review column and file sidebar resize independently and persist", async ({
page,
}) => {
await withReviewNavigation(page);
await withSecondReview(page);
await page.goto("/");
const column = page.locator(".stream-column");
const files = page.locator(".navigation-panel");
Expand Down Expand Up @@ -2383,6 +2386,7 @@ test("drawer aligns its header and retains desktop navigation density", async ({
page,
}) => {
await withReviewNavigation(page);
await withSecondReview(page);
await page.setViewportSize({ width: 1280, height: 844 });
await page.goto("/");
await showDiff(page);
Expand Down
43 changes: 39 additions & 4 deletions apps/web/test/browser/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@ export function fileToggle(scope: Page | Locator, state?: ToggleState) {

/**
* 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.
* navigation. Serving a checkout-process bootstrap restores the header
* comboboxes and search for tests that mock several contexts; the Reviews
* column also needs two current reviews (see withSecondReview). Later
* `page.route` registrations take precedence, so tests can still override it.
*/
export async function withReviewNavigation(
page: Page,
Expand All @@ -97,3 +97,38 @@ export async function withReviewNavigation(
}),
);
}

/**
* A local process shows the Reviews column once it lists two current reviews.
* Adds a second repository's copy of the fixture's review to every catalog
* page, so layout tests over the single fixture review get the column.
*/
export async function withSecondReview(page: Page) {
await page.route("**/api/v2/contexts?*", async (route) => {
const response = await route.fetch();
const body: unknown = await response.json();
if (
typeof body !== "object" ||
body === null ||
!("contexts" in body) ||
!Array.isArray(body.contexts)
)
return route.fulfill({ response });
const contexts: unknown[] = body.contexts;
const first = contexts[0];
if (typeof first !== "object" || first === null)
return route.fulfill({ response });
const second = {
...first,
id: "second-review",
streamId: "second-review",
latestContextId: "second-review",
name: "second-repo",
repositoryId: "second-repo",
};
return route.fulfill({
response,
json: { ...body, contexts: [...contexts, second] },
});
});
}
Loading
Loading