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
12 changes: 10 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,10 @@ such detail into a token. New layout spacing must use the scale.
ghost “Browse all reviews” icon action that opens the review picker, and a
close action in the modal drawer only. Below it: a “Search projects” field
styled like the file filter that filters current streams in place (Escape
clears it; matching repositories expand), then repositories ordered by latest
arrival.
clears it; matching repositories expand), then repositories ordered by their
most active visible stream. Streams sort by estimated code activity, with
captured collection time as the fallback and stable identity ties. Repository
and stream ages describe this activity; snapshot history retains its chronology.
Repository rows match tree-row height with a chevron, repository icon, medium
name and muted monospace age. Stream rows indent under them: branch icon, name,
trailing monospace changed-file count, and a muted metadata line (worktree,
Expand All @@ -253,6 +255,12 @@ such detail into a token. New layout spacing must use the scale.
Collapse state persists per browser.
- 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,
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.
- Status bar: `--statusbar-height` (28), with shortcuts shed before server metrics.
- The diff display toolbar uses `--panel`, matching the header and status bar.
- Desktop sidebar: `--sidebar-width` defaults to 330px. `use-sidebar.ts` owns
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { FolderSyncIcon, ListTreeIcon, RefreshCwIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ShellActions } from "./ActivityBar.tsx";
import { DeleteContextButton } from "./DeleteContextButton.tsx";
import { ProjectsToggle } from "./ProjectsNav.tsx";
import { NewChangesButton, ProjectsToggle } from "./ProjectsNav.tsx";
import { useDiffSource } from "./app-state.tsx";
import { useSidebarState } from "./sidebar-context.tsx";
import { CurrentContext, useCatalog, useSession } from "./session-context.tsx";
Expand Down Expand Up @@ -58,6 +58,7 @@ export function Header() {
</p>
</div>
<div className="header-actions">
<NewChangesButton />
<DeleteContextButton />
{collect ? (
<Button
Expand Down
63 changes: 28 additions & 35 deletions apps/web/src/ProjectsNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,16 @@ import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { save, saved } from "./preferences.ts";
import { contextChangeLabel, contextIsPiped } from "./project-picker.ts";
import { contextActivity } from "./context-activity.ts";
import {
baselineSeen,
newerHead,
projectGroups,
readSeen,
relativeAge,
searchProjectGroups,
streamKey,
streamMeta,
streamTitle,
type ProjectGroup,
type SeenStreams,
} from "./project-nav.ts";
import { useProjectsNavState } from "./projects-nav-context.tsx";
import { streamPath, useCatalog, useSession } from "./session-context.tsx";
Expand Down Expand Up @@ -88,40 +86,10 @@ function readCollapsedGroups() {
}
}

// Seen state is per browser: the first visit marks existing work as seen, and
// opening a stream's newest snapshot clears its new-changes mark.
function useSeenStreams(heads: ApiContext[], loaded: boolean) {
const { current } = useCatalog();
const [seen, setSeen] = useState<SeenStreams | null>(() =>
readSeen(saved("projects-seen")),
);
useEffect(() => {
if (seen === null && loaded) setSeen(baselineSeen(heads));
}, [seen, loaded, heads]);
const currentHead =
current && heads.some((context) => context.id === current.id)
? current
: undefined;
useEffect(() => {
if (!currentHead) return;
const stream = streamKey(currentHead);
setSeen((value) =>
!value || value[stream] === currentHead.id
? value
: { ...value, [stream]: currentHead.id },
);
}, [currentHead]);
useEffect(() => {
if (seen) save("projects-seen", JSON.stringify(seen));
}, [seen]);
return seen;
}

export function ProjectsNav() {
const projects = useProjectsNavState();
const sidebar = useSidebarState();
const { heads, current, openStream, openPicker } = useCatalog();
const seen = useSeenStreams(heads.contexts, heads.loaded);
const { heads, current, openStream, openPicker, seen } = useCatalog();
const [closed, setClosed] = useState(readCollapsedGroups);
const [query, setQuery] = useState("");
const now = useNow();
Expand Down Expand Up @@ -362,6 +330,31 @@ function ProjectsEmpty({
);
}

/** Opens the most active unread head without interrupting the open review. */
export function NewChangesButton() {
const { unreadHeads, openStream } = useCatalog();
const latest = unreadHeads[0];
if (!latest) return null;
const count = unreadHeads.length;
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)}
>
<HistoryIcon aria-hidden="true" />
<span>
{count}
<span className="max-md:hidden"> new</span>
</span>
</Button>
);
}

/** Announces a newer head for the viewed stream without replacing the review. */
export function NewerSnapshotNotice() {
const session = useSession();
Expand All @@ -375,7 +368,7 @@ export function NewerSnapshotNotice() {
<HistoryIcon className="size-(--icon-sm)" aria-hidden="true" />
<p>
Newer snapshot of {streamTitle(newer)} · {contextChangeLabel(newer)}{" "}
· {relativeAge(newer.lastSubmittedAt, now)}
· {relativeAge(contextActivity(newer), now)}
</p>
<Button
type="button"
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/context-activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ApiContext } from "@diffx/api";
import { contextHasChanges } from "./project-picker.ts";

/** Advisory code activity, falling back to the snapshot's captured time. */
export function contextActivity(context: ApiContext) {
return context.lastChangedAt > 0
? context.lastChangedAt
: (context.observation?.collectedAt ?? context.createdAt);
}

export function compareContextActivity(left: ApiContext, right: ApiContext) {
return (
contextActivity(right) - contextActivity(left) ||
(left.streamId ?? left.id).localeCompare(right.streamId ?? right.id) ||
left.id.localeCompare(right.id)
);
}

export function latestChangedContext(contexts: ApiContext[]) {
return contexts
.filter((context) => !context.stale && contextHasChanges(context))
.sort(compareContextActivity)[0];
}
46 changes: 35 additions & 11 deletions apps/web/src/project-nav.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { ApiContext } from "@diffx/api";
import { compareContextActivity, contextActivity } from "./context-activity.ts";
import {
contextHasChanges,
contextIsPiped,
contextMatches,
contextTimestamp,
searchWords,
} from "./project-picker.ts";

Expand Down Expand Up @@ -59,28 +61,34 @@ export function projectGroups(heads: ApiContext[], seen: SeenStreams | null) {
context,
unseen: seen !== null && seen[stream] !== context.id,
});
group.lastActivity = Math.max(group.lastActivity, context.lastSubmittedAt);
group.lastActivity = Math.max(group.lastActivity, contextActivity(context));
groups.set(id, group);
}
const result = [...groups.values()];
for (const group of result) {
group.streams.sort(
(left, right) =>
right.context.lastSubmittedAt - left.context.lastSubmittedAt ||
left.id.localeCompare(right.id),
group.streams.sort((left, right) =>
group.kind === "piped"
? contextTimestamp(right.context) - contextTimestamp(left.context) ||
left.id.localeCompare(right.id)
: 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;
}
return result.sort(
(left, right) =>
return result.sort((left, right) => {
const leftHead = left.streams[0]?.context;
const rightHead = right.streams[0]?.context;
return (
Number(left.kind === "piped") - Number(right.kind === "piped") ||
right.lastActivity - left.lastActivity ||
left.name.localeCompare(right.name),
);
(leftHead && rightHead
? compareContextActivity(leftHead, rightHead)
: 0) ||
left.id.localeCompare(right.id)
);
});
}

/** Keeps streams whose metadata matches every search word, preserving order. */
Expand Down Expand Up @@ -124,7 +132,7 @@ export function streamMeta(
worktree,
harness,
multipleHosts ? streamHost(context) : "",
relativeAge(context.lastSubmittedAt, now),
relativeAge(contextActivity(context), now),
]
.filter(Boolean)
.join(" · ");
Expand All @@ -146,6 +154,22 @@ 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 {
Expand Down
47 changes: 37 additions & 10 deletions apps/web/src/session-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,14 @@ import { useIdentity, navigate } from "./identity-context.tsx";
import { Button } from "@/components/ui/button";
import { ContextIdentity } from "./ContextIdentity.tsx";
import { ProjectPicker } from "./ProjectPicker.tsx";
import { ProjectsNav, ProjectsToggle } from "./ProjectsNav.tsx";
import { contextHasChanges } from "./project-picker.ts";
import {
NewChangesButton,
ProjectsNav,
ProjectsToggle,
} from "./ProjectsNav.tsx";
import { latestChangedContext } from "./context-activity.ts";
import { unseenChangedHeads, type SeenStreams } from "./project-nav.ts";
import { useSeenStreams } from "./use-seen-streams.ts";
import { useCompactChrome } from "./use-compact-chrome.ts";
import {
useContextCatalog,
Expand All @@ -37,6 +43,8 @@ interface Catalog {
heads: ContextCatalog;
/** 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;
Expand Down Expand Up @@ -235,8 +243,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
removeHead(id);
removeHistory(id);
if (selectedRef.current === id) {
const next = heads.contexts.find(
(context) => context.id !== id && contextHasChanges(context),
const next = latestChangedContext(
heads.contexts.filter((context) => context.id !== id),
);
if (next) openStream(next, { replace: true });
else {
Expand Down Expand Up @@ -280,7 +288,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const head =
route.kind === "live"
? contexts.find((context) => context.streamId === route.stream)
: contexts.findLast(contextHasChanges);
: latestChangedContext(contexts);
if (head) openStream(head, { replace: true, restoreFocus: false });
else setUncollected(route);
});
Expand All @@ -291,7 +299,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const head =
route.kind === "live"
? heads.contexts.find((context) => context.streamId === route.stream)
: heads.contexts.find(contextHasChanges);
: latestChangedContext(heads.contexts);
if (head) openStream(head, { replace: true, restoreFocus: false });
else if (route.kind === "live") setMissing(true);
}, [
Expand Down Expand Up @@ -334,10 +342,21 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const current = ready
? state.session
: heads.contexts.find((context) => context.id === selectedId);
const seen = useSeenStreams(
heads.contexts,
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,
Expand All @@ -351,6 +370,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
[
heads,
current,
seen,
unreadHeads,
selectedId,
select,
openStream,
Expand All @@ -370,10 +391,15 @@ export function SessionProvider({ children }: { children: ReactNode }) {
return;
restorePickerFocus.current = false;
if (empty) statusRef.current?.focus();
else
document
.querySelector<HTMLElement>('#projects-nav [aria-current="page"]')
?.focus();
else {
const selected = document.querySelector<HTMLElement>(
'#projects-nav [aria-current="page"]',
);
const target = selected?.getClientRects().length
? selected
: document.querySelector<HTMLElement>("#projects-toggle");
target?.focus();
}
}, [state, empty]);
return (
<CatalogContext value={catalog}>
Expand All @@ -388,6 +414,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
<ProjectsToggle />
<CurrentContext />
<div className="header-actions">
<NewChangesButton />
{compact && <ShellActions side="bottom" />}
</div>
</header>
Expand Down
Loading
Loading