feat(webapp): a "What's new" page anchored at your last visit (BEA-65)

The change feed is flat reverse-chron with no notion of when you last
looked, so "what happened while I was away" is date arithmetic in your
head — and cheap agents write more files per day than that scales to.

Server: `?since=<RFC3339>` on GET /api/p/<id>/history, one case in the
existing filter switch. It sits after the kinds[] classification (which
must keep walking every op, or a filtered view relabels an edit as an
add) and before the sort, so next_cursor is minted from the filtered
list and paging a since-feed terminates on its own oldest match.

Client: /<project-id>/since — a real VIEW_ROUTES entry — renders the
existing HistoryView with that filter and a header line. The anchor is a
per-(account, project) localStorage marker, read once from a useState
initializer and stamped after entries render: read it any later and the
page empties itself while you are reading it. SinceView is the only
caller of stampVisit, so no other page moves the marker.

Known ceiling: the marker is per browser, not per account — laptop and
phone keep separate last-visit times. The server-side marker is the
larger follow-up.
This commit is contained in:
Snow Lee
2026-08-02 09:13:43 -07:00
parent 9f13c70b78
commit 7edef42d76
15 changed files with 415 additions and 61 deletions
+4 -2
View File
@@ -27,7 +27,7 @@ classDiagram
}
class router {
+VIEW_ROUTES dashboard history install settings
+VIEW_ROUTES dashboard history since install settings
+LEGACY_VIEWS insights to dashboard
+top-level routes orgs billing
+parseRoute(url, mode) Route
@@ -69,6 +69,7 @@ classDiagram
class components {
FileView FolderListing FileTree
HistoryView HistoryRow DiffView VersionBanner
SinceView
Insights ShareDialog NewProjectDialog
ShareBanner SharesTable AdminTable
OrgAdmin HubSettings ProjectSettings
@@ -80,12 +81,13 @@ classDiagram
class lib {
+diff.ts splitLines lcsDiff diffText
+runs.ts groupRuns runFileCount
+lastVisit.ts lastVisit stampVisit (localStorage)
+heat.ts heatFor heatTotal heatText heatLevel hotPathSplit
+heat.ts ageRange isFlatRange ageSpanLabel (treemap scale)
+sniff.ts sniffBytes BlobText MAX_BYTES
+utils.ts
}
note for lib "pure, no React, unit-tested on node (npm test) — the line diff is ~40 lines, cheaper than auditing a diff package. heat.ts is the one read-count arithmetic: every surface (file header, folder listing, Dashboard bar) totals and splits through it, so they cannot disagree; useBrowse re-exports it"
note for lib "pure, no React, unit-tested on node (npm test) — the line diff is ~40 lines, cheaper than auditing a diff package. heat.ts is the one read-count arithmetic: every surface (file header, folder listing, Dashboard bar) totals and splits through it, so they cannot disagree; useBrowse re-exports it. lastVisit.ts is the exception to the purity: the only localStorage in the frontend (the What's-new marker), every access try/caught so private mode degrades to the 7-day fallback rather than a white screen"
App --> HubApp
App --> VolumeApp
@@ -0,0 +1,80 @@
import { test, expect } from "@playwright/test";
import { login, wikiId } from "./helpers";
// "What's new" is the history feed anchored at a localStorage marker. Every
// Playwright test gets a fresh context, so storage starts empty and "first
// visit" needs no setup — which is also why each test that wants a stored
// marker has to make one by visiting the page first.
test("first visit falls back to the last 7 days and says so", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/since`);
await expect(page.locator(".since-sub")).toContainText("showing the last 7 days");
// The seeded ops are 90min72h old, so they are all inside the window.
await expect(page.locator(".hrun-note")).toContainText("claude-code session 8f21e4");
});
test("a change lands, then the revisit is empty", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/since`); // stamps the marker
await expect(page.locator(".since-sub")).toContainText("since your last visit");
await page.request.put(
`/api/p/${pid}/upload/content?path=${encodeURIComponent("since-probe.md")}`,
{ data: "# Probe\n" },
);
await page.goto(`/${pid}/since`);
await expect(page.locator(".since-sub")).toContainText("1 change since your last visit");
await expect(page.locator(".hpath")).toHaveText(["since-probe.md"]);
await page.goto(`/${pid}/since`);
await expect(page.locator(".since-sub")).toContainText("Nothing new since your last visit");
await expect(page.locator(".hpath")).toHaveCount(0);
});
test("only this view stamps the marker", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/since`); // marker: now
await expect(page.locator(".since-sub")).toContainText("since your last visit");
// A change lands, then the user wanders through the other project pages.
// If any of them stamped, the change below would be swallowed.
await page.request.put(
`/api/p/${pid}/upload/content?path=${encodeURIComponent("since-stamp-probe.md")}`,
{ data: "# Stamp probe\n" },
);
await page.goto(`/${pid}/history`);
await expect(page.locator(".history")).toBeVisible();
await page.goto(`/${pid}/dashboard`);
await expect(page.locator(".in-treemap")).toBeVisible();
await page.goto(`/${pid}/since`);
await expect(page.locator(".hpath")).toHaveText(["since-stamp-probe.md"]);
});
test("the baseline stays put while the page is open", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/since`);
await expect(page.locator(".since-sub")).toContainText("showing the last 7 days");
const shown = (await page.locator(".since-sub").textContent()) || "";
// The marker was written under us; the page must not notice.
await page.waitForTimeout(1200);
await expect(page.locator(".since-sub")).toHaveText(shown);
});
test("the nav item navigates there and is the only active row", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}`);
await page.click("#nav-since");
await expect(page).toHaveURL(new RegExp(`/${pid}/since$`));
await expect(page.locator(".nav-menu .row.active")).toHaveCount(1);
await expect(page.locator("#nav-since")).toHaveClass(/active/);
// A deep link survives a hard reload (SPA fallback), not just client nav.
await page.reload();
await expect(page.locator(".since-head")).toHaveText("What's new");
});
@@ -30,6 +30,7 @@ import { Palette, type PaletteItem } from "../components/Palette";
import { ConnectGuide } from "../components/ConnectGuide";
import { Insights, useInsightsDevices } from "../components/Insights";
import { HistoryView, historyTitle } from "../components/HistoryView";
import { SinceView } from "../components/SinceView";
import { VersionBanner } from "../components/VersionBanner";
// The browsing surface shared by hub projects and single-volume mode: the
@@ -337,6 +338,22 @@ export default function Browser(props: {
isFolder={isFolderFn}
/>
);
} else if (route.view === "since" && project) {
// Whole project only — no viewTarget in v1, so a subtree link falls
// through to History.
view = (
<SinceView
apiBase={apiBase}
projectId={project.id}
account={config.me?.email}
isFolder={isFolderFn}
onOpen={openPath}
onMeta={setMeta}
onRendered={onRendered}
restore={canRestore ? { onRestore, busy: restoring } : undefined}
remove={canRestore ? { onRemove, busy: removing } : undefined}
/>
);
} else if (route.view === "history") {
// structured view — default app column, like the folder listing it shares rows with
view = (
@@ -447,6 +464,8 @@ export default function Browser(props: {
<Breadcrumbs path={path} onOpenFolder={openPath} />
) : route.view === "dashboard" ? (
"Dashboard — " + (route.viewTarget || project?.name || "")
) : route.view === "since" ? (
"What's new — " + (project?.name || "")
) : route.view === "history" ? (
"History — " + historyTitle(route.viewTarget || "", isFolderFn)
) : isHome ? (
+12 -5
View File
@@ -297,11 +297,13 @@ export default function HubApp({ config }: { config: ServerConfig }) {
? "dashboard"
: route.view === "install"
? "install"
: route.view === "history" && !route.viewTarget
? "history"
: route.view === "settings"
? "settings"
: null,
: route.view === "since"
? "since"
: route.view === "history" && !route.viewTarget
? "history"
: route.view === "settings"
? "settings"
: null,
// Each page is a URL; explicitly close overlay panels because
// same-path navigation doesn't change pathname.
onDashboard: () => {
@@ -314,6 +316,11 @@ export default function HubApp({ config }: { config: ServerConfig }) {
navigate(urlForView("install", current.id));
closeSidebarOnMobile();
},
onSince: () => {
setPanel(null);
navigate(urlForView("since", current.id));
closeSidebarOnMobile();
},
onHistory: () => {
setPanel(null);
navigate(urlForView("history", current.id));
@@ -28,17 +28,25 @@ export function HistoryView(props: {
onRendered?: () => void;
restore?: RestoreAction;
remove?: RemoveAction;
/* The three below are what "What's new" (SinceView) needs and /history
does not: with all three absent this renders exactly as it always did. */
since?: string; // RFC3339; narrows the feed to changes after it
emptyText?: string; // "" when a wrapper carries the empty message itself
onLoaded?: (n: number, more: boolean) => void; // entries loaded so far, and whether more exist
}) {
const { apiBase, target, isFolder, onMeta, onRendered, restore, remove } = props;
const { apiBase, target, isFolder, onMeta, onRendered, restore, remove, since, onLoaded } = props;
const q = !target
? { prefix: "" }
: isFolder(target)
? { prefix: target + "/" }
: { path: target };
const qs =
"path" in q && q.path !== undefined
("path" in q && q.path !== undefined
? "path=" + encodeURIComponent(q.path)
: "prefix=" + encodeURIComponent(q.prefix ?? "");
: "prefix=" + encodeURIComponent(q.prefix ?? "")) +
// Part of qs, so it is also part of the queryKey: a revisit with a fresh
// baseline refetches instead of showing the previous visit's cached page.
(since ? "&since=" + encodeURIComponent(since) : "");
// Paged: the server hands back a cursor while entries remain, so a project
// with thousands of changes is reachable to its first one. Pages accumulate
// into one array — groupRuns and prevBlob both work over the whole window,
@@ -65,6 +73,9 @@ export function HistoryView(props: {
useEffect(() => {
if (data) onRendered?.();
}, [data, onRendered]);
useEffect(() => {
if (data) onLoaded?.(data.pages.reduce((n, p) => n + (p.entries || []).length, 0), hasNextPage);
}, [data, hasNextPage, onLoaded]);
if (!data) return null;
const entries = data.pages.flatMap((p) => p.entries || []);
@@ -87,7 +98,9 @@ export function HistoryView(props: {
entries[i].kind === "delete" ? prevBlob(i) : entries[i].blob;
return (
<div className="history">
{entries.length === 0 && <div className="empty">No history yet.</div>}
{entries.length === 0 && (props.emptyText ?? "No history yet.") !== "" && (
<div className="empty">{props.emptyText ?? "No history yet."}</div>
)}
{groupRuns(entries).map((item, n) =>
item.run ? (
<RunGroup
@@ -20,9 +20,10 @@ export function projColor(s: string): string {
}
export interface ProjectMenu {
active: "dashboard" | "install" | "history" | "settings" | null;
active: "dashboard" | "install" | "since" | "history" | "settings" | null;
onDashboard: () => void;
onInstall: () => void;
onSince: () => void;
onHistory: () => void;
onSettings: () => void;
}
@@ -113,6 +114,8 @@ export function ProjectNav({
[
["dashboard", "Dashboard", "dashboard", menu.onDashboard],
["install", "Installation", "terminal", menu.onInstall],
/* the two change-feed views sit together, catch-up first */
["since", "What's new", "clock", menu.onSince],
["history", "History", "hist", menu.onHistory],
["settings", "Settings", "gear", menu.onSettings],
] as const
@@ -0,0 +1,87 @@
import { useCallback, useRef, useState } from "react";
import { HistoryView } from "./HistoryView";
import type { RemoveAction, RestoreAction } from "./HistoryRow";
import { lastVisit, stampVisit } from "../lib/lastVisit";
/* ---- what's new ----
The change feed, cut to what landed since you were last here. Everything
below the header is HistoryView unchanged same run cards, same rows,
same diffs so this file is only the anchor: read the marker once, show
what it means, then move it.
Two things are the whole reason this wrapper exists:
* the baseline comes from a useState INITIALIZER, so it is frozen for the
life of the mount. Read it during render or in an effect and the page
empties itself while you are reading it it would re-read the marker it
just wrote;
* stamping is behind a ref guard, so "Load more" cannot restamp. Harmless
either way (the baseline is frozen) but the invariant should be visible.
This is the only call site of stampVisit: no other project page moves the
marker, which is what makes "what's new" mean anything. */
export function SinceView(props: {
apiBase: string;
projectId: string;
account?: string;
isFolder: (p: string) => boolean;
onOpen: (path: string, version?: string) => void;
onMeta: (meta: string) => void;
onRendered?: () => void;
restore?: RestoreAction;
remove?: RemoveAction;
}) {
const { projectId, account } = props;
const [base] = useState(() => lastVisit(projectId, account));
const [loaded, setLoaded] = useState<{ n: number; more: boolean } | null>(null);
const stamped = useRef(false);
const onLoaded = useCallback(
(n: number, more: boolean) => {
setLoaded({ n, more });
if (stamped.current) return;
stamped.current = true;
stampVisit(projectId, account);
},
[projectId, account],
);
const when = new Date(base.since).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
let sub: string;
if (!loaded) sub = "Looking for changes…";
else if (loaded.n === 0) sub = "Nothing new since your last visit · " + when;
else {
const n = loaded.n + (loaded.more ? "+" : "");
sub = `${n} change${loaded.n === 1 && !loaded.more ? "" : "s"} since your last visit · ${when}`;
}
return (
<div className="since">
<h1 className="since-head">What's new</h1>
<div className="since-sub">
{sub}
{base.first && (
<span className="since-first"> showing the last 7 days; we'll remember this visit.</span>
)}
</div>
<HistoryView
apiBase={props.apiBase}
target=""
isFolder={props.isFolder}
onOpen={props.onOpen}
onMeta={props.onMeta}
onRendered={props.onRendered}
restore={props.restore}
remove={props.remove}
since={base.since}
emptyText="" /* the subline above already says it */
onLoaded={onLoaded}
/>
</div>
);
}
@@ -0,0 +1,38 @@
// The last-visit marker behind "What's new" (/<project-id>/since) — the one
// storage-touching module in an otherwise pure lib/, and the only
// localStorage in the frontend. Every access is wrapped: Safari private mode
// and disabled site storage throw on get and on set alike, and a catch-up
// view is not worth a white screen.
//
// Keyed by account as well as project: two people sharing a laptop (or a demo
// switching personas) would otherwise steal each other's last-visit time.
// Per browser, not per account on the server — a laptop and a phone keep
// separate markers. That is the price of the small version.
const key = (project: string, account?: string) =>
"bdrive.lastVisit." + (account || "anon") + "." + project;
const WINDOW_DAYS = 7;
// The baseline for one mount, read exactly once. No marker (first visit,
// fresh browser, storage off) falls back to the last 7 days, flagged so the
// view can say so rather than pretending it knows when you were last here.
export function lastVisit(project: string, account?: string): { since: string; first: boolean } {
let v: string | null = null;
try {
v = localStorage.getItem(key(project, account));
} catch {
/* storage off — treat as a first visit */
}
return v
? { since: v, first: false }
: { since: new Date(Date.now() - WINDOW_DAYS * 864e5).toISOString(), first: true };
}
export function stampVisit(project: string, account?: string): void {
try {
localStorage.setItem(key(project, account), new Date().toISOString());
} catch {
/* storage off — the view degrades to "always the last 7 days" */
}
}
+3 -2
View File
@@ -19,18 +19,19 @@ export function decodePath(p: string): string {
// after the project id is reserved when it names a view:
// /<project-id>/dashboard[/<path>] the read×staleness dashboard (optionally scoped)
// /<project-id>/history[/<path>] change feed (project / subtree / file)
// /<project-id>/since the change feed since your last visit
// /<project-id>/install connect-a-device guide
// /<project-id>/settings project settings
// Rule: every page gets its own URL path (see CLAUDE.md) — new surfaces are
// view routes here, not ephemeral panel state. (Root-level files literally
// named like a view lose the URL shortcut and remain reachable via the tree.)
export const VIEW_ROUTES = new Set(["dashboard", "history", "install", "settings"]);
export const VIEW_ROUTES = new Set(["dashboard", "history", "since", "install", "settings"]);
// Shipped URLs that were renamed. Parsed into the new view and normalized
// away on arrival, so bookmarks resolve without a second live name.
const LEGACY_VIEWS: Record<string, ViewName> = { insights: "dashboard" };
export type ViewName = "dashboard" | "history" | "install" | "settings";
export type ViewName = "dashboard" | "history" | "since" | "install" | "settings";
export interface Route {
// Org administration is not project-scoped, so it is a top-level route
+5
View File
@@ -658,6 +658,11 @@ a.ai-main:hover { color: var(--accent); }
.in-matrix rect { transition: opacity .1s; }
.in-matrix rect:hover { opacity: .85; }
/* ---- what's new (the history feed, anchored at your last visit) ---- */
.since-head { margin: 0; font-size: 19px; font-weight: 600; color: var(--text); }
.since-sub { margin: 5px 0 16px; font-size: 13px; color: var(--text-dim); }
.since-first { color: var(--text-faint); }
/* ---- history ---- */
/* .history width comes from .page (app) */
.hentry { padding: 11px 12px; border-bottom: 1px solid var(--border); --hindent: 72px; }
+18 -1
View File
@@ -98,7 +98,9 @@ func decodeCursor(s string) (journal.Op, error) {
// handleHistory serves ?path=<file> (one file's versions) or
// ?prefix=<folder/> (everything underneath, "" = the whole project),
// newest first by wall-clock time, at most ?n= entries (default 100).
// optionally narrowed to changes strictly newer than ?since=<RFC3339>
// (what the "What's new" view asks for), newest first by wall-clock time,
// at most ?n= entries (default 100).
//
// Paging: the response carries next_cursor when more entries exist, and
// ?cursor= resumes just past the entry it was minted from — so history older
@@ -129,6 +131,14 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
return
}
}
var since time.Time
if raw := q.Get("since"); raw != "" {
var err error
if since, err = time.Parse(time.RFC3339, raw); err != nil {
http.Error(w, "invalid since", http.StatusBadRequest)
return
}
}
all, err := rs.loadOps(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
@@ -164,6 +174,13 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
continue
case path == "" && prefix != "" && !strings.HasPrefix(op.Path, strings.TrimSuffix(prefix, "/")+"/"):
continue
// Strictly after, so re-asking with the timestamp of the newest entry
// you've already seen doesn't hand it back a second time. Filtering
// here — after kinds[], before the sort — means next_cursor is minted
// from the filtered list, so paging a since-feed terminates on its own
// oldest match.
case !since.IsZero() && !op.Time.After(since):
continue
}
// Unregistered device (or volume mode, where Devices is nil): fall back
// to the op's own id + self-reported name.
+82
View File
@@ -400,6 +400,88 @@ func TestHistoryPagingAcrossLamportAndTime(t *testing.T) {
}
}
// ?since= is the anchor the "What's new" view is built on: the same feed,
// cut to what landed after a timestamp. It composes with the path/prefix
// filter and with cursor paging, because it is applied in the same switch
// and before the same sort.
func TestHistorySince(t *testing.T) {
srv, p, root := newHub(t, false, nil)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
t1 := time.Date(2026, 7, 26, 9, 0, 0, 0, time.UTC)
t2 := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
t3 := time.Date(2026, 7, 26, 15, 0, 0, 0, time.UTC)
f.putAt("dev", "notes/old.md", "old", t1)
f.putAt("dev", "notes/mid.md", "mid", t2)
f.putAt("dev", "docs/new.md", "new", t3)
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
page := func(u string) ([]string, string) {
t.Helper()
rec := do(t, h, "GET", u, nil)
if rec.Code != 200 {
t.Fatalf("history: %d %s", rec.Code, rec.Body)
}
var out struct {
Entries []HistoryEntry `json:"entries"`
Next string `json:"next_cursor"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
var got []string
for _, e := range out.Entries {
got = append(got, e.Path)
}
return got, out.Next
}
arg := func(at time.Time) string { return "since=" + url.QueryEscape(at.Format(time.RFC3339)) }
// only what landed after the marker, newest first
if got, _ := page(base + "history?" + arg(t1.Add(time.Minute))); !slices.Equal(got, []string{"docs/new.md", "notes/mid.md"}) {
t.Fatalf("since=t1+1m = %v", got)
}
// strictly after: an op at exactly the marker is already seen
if got, _ := page(base + "history?" + arg(t2)); !slices.Equal(got, []string{"docs/new.md"}) {
t.Fatalf("since=t2 = %v, want the t2 op excluded", got)
}
// composes with prefix=: both filters apply
if got, _ := page(base + "history?prefix=notes/&" + arg(t1)); !slices.Equal(got, []string{"notes/mid.md"}) {
t.Fatalf("since+prefix = %v", got)
}
// composes with path=
if got, _ := page(base + "history?path=notes/old.md&" + arg(t1)); len(got) != 0 {
t.Fatalf("since+path = %v, want empty", got)
}
// a marker older than everything is the unfiltered feed, byte for byte
full := do(t, h, "GET", base+"history", nil).Body.String()
old := do(t, h, "GET", base+"history?"+arg(t1.Add(-24*time.Hour)), nil).Body.String()
if full != old {
t.Fatalf("since=<very old> differs from no since:\n%s\n%s", old, full)
}
// paging a since-feed reaches its oldest match and then stops
var got []string
cursor, pages := "", 0
for {
entries, next := page(base + "history?n=1&" + arg(t1) + cursorArg(cursor))
got = append(got, entries...)
if pages++; pages > 5 {
t.Fatalf("paging did not terminate: %v", got)
}
if next == "" {
break
}
cursor = next
}
if !slices.Equal(got, []string{"docs/new.md", "notes/mid.md"}) {
t.Fatalf("paged since-feed = %v", got)
}
// unparseable is an error, not a silently unfiltered feed
if rec := do(t, h, "GET", base+"history?since=yesterday", nil); rec.Code != http.StatusBadRequest {
t.Fatalf("bad since: %d, want 400", rec.Code)
}
}
// BenchmarkHistoryPage measures what a deep page costs: every request
// re-lists and re-parses every journal (loadOps), so page 20 should cost
// about what page 1 costs — the ceiling is gone, the per-page work is not.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-DKWyO9UQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index--FdGCYbV.css">
<script type="module" crossorigin src="/assets/index-BJXwJ42N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJC1dk7l.css">
</head>
<body>
<div id="root"></div>