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
19 changes: 13 additions & 6 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,8 @@ such detail into a token. New layout spacing must use the scale.
remain in the header because the rail is hidden.
- Activity bar: a fixed `--activity-width` column (48px; 52px for coarse
pointers), spanning the workbench below the header to the shell's bottom inset.
Diff (file-diff icon) followed by Repos (repository icon) select one shared panel.
Repos (repository icon) followed by Diff (file-diff icon) select one shared panel.
Repos is the initial view.
Clicking the active view toggles its panel; clicking another view opens it.
Keep the rail and selected panel in one rounded island, separated by a 1px
`--border-muted` edge. Views use `--activity-item` square ghost buttons,
Expand All @@ -248,7 +249,8 @@ such detail into a token. New layout spacing must use the scale.
secondary text), a ghost magnifier labeled “Search reviews” that opens the
existing picker, and a close action in the modal drawer only. Search is
available through that popup or Cmd/Ctrl+K. Selecting a stream loads its diff
and switches the shared panel to Diff; mobile closes the drawer.
and keeps Repos selected, including the open modal drawer; people switch to
Diff themselves for the file tree.
Repositories sort by their most active visible stream. Streams sort by
estimated code activity, with captured collection time as fallback and stable
identity ties. Rows retain compact tree density, truncation, ages and metadata.
Expand All @@ -260,7 +262,12 @@ such detail into a token. New layout spacing must use the scale.
- A newer head for the viewed stream shows a one-line `--accent-bg` notice below
the header with an outline-muted “View latest” action. Never swap the open review.
- A compact header action counts unopened changed heads across projects and
opens the most recently active one. Share the drawer's per-browser seen state,
opens a “New changes” menu anchored below it, on `--surface-raised` with the
shared menu shadow. Opening the menu never swaps the review. Rows follow
activity order and show repository name (medium, primary text) with age, then
branch with changed-file count, then the linked worktree name or “Primary
checkout” (secondary metadata, single-line truncation). Choosing a row opens
that stream. Share the drawer's per-browser seen state,
baseline existing heads on first visit, and clear a head's mark when opened.
Keep the same-stream notice available. Unchanged submissions must not create
unread prompts. Use existing ghost-button typography, icons and accessible
Expand Down Expand Up @@ -484,8 +491,8 @@ Do not add motion for decoration. New animation must honor
- Keep the real shell, Repos navigation and theme actions available while review
data loads. Reserve the shared navigation panel width and split/unified layout
with neutral skeletons for repo rows, file rows, the toolbar and diff frames.
Keep Diff disabled until a review is ready; switching to Repos replaces the file
placeholders within the same panel.
Keep Diff disabled until a review is ready; Repos shows its own placeholders
in the same panel, and switching to Diff shows the file placeholders.
Narrow screens hide the placeholder file sidebar and follow the narrow layout.
- Skeletons use `--border-muted`, existing spacing and radius tokens, and a gentle
shared opacity pulse. Disable animation under `prefers-reduced-motion`. Keep
Expand Down Expand Up @@ -515,7 +522,7 @@ Do not add motion for decoration. New animation must honor
- Collapse navigation to the toggle-controlled modal drawer; its width is
`min(320px, 88vw)`. Keep file selection, comments, and export reachable there.
The repos drawer follows the same modal pattern from its own toggle; opening
it closes the file drawer, and choosing a stream closes it.
it closes the file drawer. Choosing a stream keeps it open.
Its tab strip matches `--topbar-height`, including the tablet header height.
File/folder rows, filter, summary toggle, and review progress retain desktop
control heights and spacing; do not apply the general mobile enlargement here.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/ActivityBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { useSidebarState } from "./sidebar-context.tsx";
import { useCompactChrome } from "./use-compact-chrome.ts";

const views = [
{ id: "diff", label: "Diff", Icon: FileDiffIcon },
{ id: "projects", label: "Repos", Icon: FolderGit2Icon },
{ id: "diff", label: "Diff", Icon: FileDiffIcon },
] satisfies {
id: "projects" | "diff";
label: string;
Expand Down
127 changes: 109 additions & 18 deletions apps/web/src/ProjectsNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ApiContext } from "@diffx/api";
import {
ChevronRightIcon,
FolderGit2Icon,
FolderTreeIcon,
GitBranchIcon,
HistoryIcon,
SearchIcon,
Expand All @@ -11,9 +12,20 @@ import {
} from "lucide-react";
import { useEffect, useState, type MouseEvent } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { save, saved } from "./preferences.ts";
import { contextChangeLabel, contextIsPiped } from "./project-picker.ts";
import {
contextChangeLabel,
contextIsLinkedWorktree,
contextIsPiped,
contextWorktreeName,
} from "./project-picker.ts";
import { contextActivity } from "./context-activity.ts";
import {
newerHead,
Expand Down Expand Up @@ -108,7 +120,7 @@ export function ProjectsNav() {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0)
return;
event.preventDefault();
openStream(head);
openStream(head, { preserveNavigation: true });
};
return (
<>
Expand Down Expand Up @@ -314,28 +326,107 @@ function ProjectsEmpty({
);
}

/** Opens the most active unread head without interrupting the open review. */
/** Lists unread heads; choosing one opens it and clears its mark. */
export function NewChangesButton() {
const { unreadHeads, openStream } = useCatalog();
const latest = unreadHeads[0];
if (!latest) return null;
const now = useNow();
if (!unreadHeads.length) return null;
const count = unreadHeads.length;
const label = `New changes (${count} ${count === 1 ? "stream" : "streams"})`;
return (
<Button
type="button"
id="new-changes"
variant="ghost"
size="sm"
aria-label={`Open latest new changes (${count} ${count === 1 ? "stream" : "streams"})`}
title={`Open ${latest.name} · ${streamTitle(latest)}`}
onClick={() => openStream(latest)}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
id="new-changes"
variant="ghost"
size="sm"
aria-label={label}
title={label}
/>
}
>
<HistoryIcon aria-hidden="true" />
<span>
{count}
<span className="max-md:hidden"> new</span>
</span>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
className="new-changes-menu"
aria-label="New changes"
>
<div className="new-changes-header" aria-hidden="true">
New changes
</div>
{unreadHeads.map((context) => (
<NewChangeItem
key={context.id}
context={context}
now={now}
onOpen={() => openStream(context)}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

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);
const Icon = piped ? SquareTerminalIcon : FolderGit2Icon;
return (
<DropdownMenuItem
className="new-changes-item"
aria-label={[
repository,
branch,
worktree,
contextChangeLabel(context),
age,
]
.filter(Boolean)
.join(", ")}
onClick={onOpen}
>
<HistoryIcon aria-hidden="true" />
<span>
{count}
<span className="max-md:hidden"> new</span>
<span className="new-changes-line">
<Icon className="size-(--icon-sm)" aria-hidden="true" />
<span className="new-changes-repo">{repository}</span>
<span className="new-changes-age">{age}</span>
</span>
</Button>
<span className="new-changes-line new-changes-detail">
{!piped && (
<GitBranchIcon className="size-(--icon-sm)" aria-hidden="true" />
)}
<span className="new-changes-text">{branch}</span>
<span className="new-changes-count">{context.changedFileCount}</span>
</span>
{worktree && (
<span className="new-changes-line new-changes-detail">
<FolderTreeIcon className="size-(--icon-sm)" aria-hidden="true" />
<span className="new-changes-text">{worktree}</span>
</span>
)}
</DropdownMenuItem>
);
}

Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/project-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,13 @@ export function contextIsLinkedWorktree(context: ApiContext) {
: Boolean(context.worktreeName);
}

export function contextWorktreeName(context: ApiContext) {
return context.worktreeName || context.observation?.worktreeName || "Unknown";
}

export function contextCheckoutLabel(context: ApiContext) {
if (contextIsLinkedWorktree(context))
return `Linked worktree: ${context.worktreeName || context.observation?.worktreeName || "Unknown"}`;
return `Linked worktree: ${contextWorktreeName(context)}`;
if (context.observation?.linkedWorktree === false) return "Primary checkout";
return "Checkout type unknown";
}
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/session-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,14 @@ interface Catalog {
selectedId: string;
/** Pins one snapshot. */
select: (id: string, options?: { restoreFocus?: boolean }) => void;
/** Follows a stream's latest head; piped snapshots stay pinned. */
openStream: (head: ApiContext, options?: { restoreFocus?: boolean }) => void;
/**
* Follows a stream's latest head; piped snapshots stay pinned. Opens the Diff
* view unless navigation is preserved.
*/
openStream: (
head: ApiContext,
options?: { restoreFocus?: boolean; preserveNavigation?: boolean },
) => void;
deleteContext: (id: string) => Promise<void>;
pickerOpen: boolean;
openPicker: () => void;
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,61 @@ kbd {
border-radius: 50%;
background: var(--accent);
}
.new-changes-menu {
width: min(22rem, calc(100vw - 2 * var(--page-gutter)));
}
.new-changes-header {
margin-bottom: var(--space-1);
padding: var(--space-1) var(--space-2) var(--space-2);
border-bottom: 1px solid var(--border-muted);
color: var(--text-secondary);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
}
.new-changes-item {
flex-direction: column;
align-items: stretch;
gap: var(--space-0-5);
padding: var(--space-1-5) var(--space-2);
}
.new-changes-item svg {
width: var(--icon-sm);
height: var(--icon-sm);
color: var(--muted);
}
.new-changes-line {
display: flex;
align-items: center;
gap: var(--space-1-5);
min-width: 0;
}
.new-changes-detail {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.new-changes-repo,
.new-changes-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.new-changes-repo {
color: var(--fg);
font-weight: var(--weight-medium);
}
.new-changes-age,
.new-changes-count {
flex-shrink: 0;
color: var(--muted);
font: var(--text-xs) var(--font-mono);
font-weight: var(--weight-normal);
font-variant-numeric: tabular-nums;
}
.new-changes-item[data-highlighted] :is(.new-changes-repo, svg) {
color: var(--accent);
}
.projects-empty {
padding: var(--space-2);
color: var(--text-secondary);
Expand Down
22 changes: 13 additions & 9 deletions apps/web/src/use-modal-drawer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ export function useModalDrawer(
) {
useEffect(() => {
if (!active) return;
const main = document.querySelector<HTMLElement>("main");
const header = document.querySelector<HTMLElement>(".topbar");
const views = document.querySelector<HTMLElement>(".activity-bar");
// The background can remount while the drawer stays open (opening a
// review replaces <main>), so keep whichever elements are current inert.
const background = () =>
document.querySelectorAll<HTMLElement>("main, .topbar, .activity-bar");
const makeInert = () => {
for (const element of background())
if (!element.hasAttribute("inert")) element.setAttribute("inert", "");
};
const drawer = document.querySelector<HTMLElement>(drawerSelector);
main?.setAttribute("inert", "");
header?.setAttribute("inert", "");
views?.setAttribute("inert", "");
makeInert();
const remounts = new MutationObserver(makeInert);
remounts.observe(document.body, { childList: true, subtree: true });
const focusable = () =>
Array.from(
drawer?.querySelectorAll<HTMLElement>(
Expand All @@ -40,9 +45,8 @@ export function useModalDrawer(
document.addEventListener("keydown", trapFocus);
return () => {
document.removeEventListener("keydown", trapFocus);
main?.removeAttribute("inert");
header?.removeAttribute("inert");
views?.removeAttribute("inert");
remounts.disconnect();
for (const element of background()) element.removeAttribute("inert");
document.querySelector<HTMLElement>(toggleSelector)?.focus();
};
}, [active, drawerSelector, toggleSelector]);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/use-sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export function useSidebar() {
const [mobile, setMobile] = useState(
() => matchMedia("(max-width: 1011px)").matches,
);
const [view, setView] = useState<WorkspaceView>("diff");
const [view, setView] = useState<WorkspaceView>("projects");
const [open, setOpen] = useState(false);
const [collapsed, setCollapsed] = useState(
() => saved("sidebar-collapsed") === "true",
Expand Down
Loading
Loading