From 635347452668f28cd42ec6590dc49ecccf94fdc5 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Wed, 28 Oct 2026 22:09:27 -0700 Subject: [PATCH] Show documents someone else creates in the sidebar without a reload A document a teammate created showed in another person's Projects sidebar only after they reloaded, or up to a minute later. Renames, archives, restores and deletions by someone else were just as late. The sidebar socket now carries `sidebar:catalogue`. When a document is created, renamed, archived, restored or permanently deleted, by a person, the Planner's drafted title, a question opened as a page, or `create_document` over MCP, the server publishes it to the repository's topic, which every sidebar watching that repository already subscribes to. The frame names the repository and the document but no title, so a socket learns nothing it couldn't read: the client reads that repository's documents again through the usual access check, once a burst of changes has been quiet for 250 ms. - packages/protocol: Sidebar.Catalogue. - server: announceCatalogue, called from announceChannel (rename, archive, restore, question pages, Planner titles), the create route and MCP create_document (new onChannelCreated hooks), and permanent deletion. - web: useSidebarDecisions passes the frame on; SidebarDecisionCounts refreshes the project it names. - AGENTS.md and docs/channels.md describe it. Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: inline Assistant-verification: Chromium E2E passed: e2e/sidebar-decision-counts.e2e.ts and e2e/document-creation.e2e.ts (28 pass); the new test (another person creates, renames, archives and deletes while the viewer's page never reloads) failed with the client handler disabled and passes with it Assistant-verification: bun test unit passed: src/mcp src/channels src/socket (198 pass), with a create_document announce test (once, not on a replay) Assistant-verification: typecheck and lint passed: bun run types and bun run ci User-preference: A document created by one person should appear on other people's clients without a refresh Co-authored-by: Alex Lavaee --- AGENTS.md | 2 +- apps/server/src/channels/routes.ts | 3 ++ apps/server/src/main.ts | 26 +++++++++- apps/server/src/mcp/hosted.test.ts | 16 +++++++ apps/server/src/mcp/hosted.ts | 3 ++ apps/web/src/sidebar-decision-counts.tsx | 35 +++++++++++++- apps/web/src/use-sidebar-decisions.ts | 9 +++- docs/channels.md | 9 ++++ e2e/sidebar-decision-counts.e2e.ts | 60 ++++++++++++++++++++++++ packages/protocol/index.d.ts | 21 ++++++++- 10 files changed, 176 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3f3888ea2..84b981cdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ harnesses as runtime package boundaries. The browser edits Lexical bound to Yjs. One WebSocket multiplexes session, document (`plan:*` on the wire), conversation, questions, comments, and implementation lifecycle messages; the Projects sidebar's own `/ws/sidebar` -socket carries only decision counts. The server keeps each open channel as an +socket carries decision counts and says when a repository's documents change. The server keeps each open channel as an authoritative Y.Doc with a headless Lexical mirror so it can validate and serialize the document without trusting a browser. diff --git a/apps/server/src/channels/routes.ts b/apps/server/src/channels/routes.ts index 9f3adb297..e304ff062 100644 --- a/apps/server/src/channels/routes.ts +++ b/apps/server/src/channels/routes.ts @@ -253,6 +253,8 @@ export function registerChannelRoutes( onChannelArchived?: (channelId: string, now: Date) => Promise; onChannelDeleted?: (channelId: string) => Promise; onChannelRenamed?: (channel: ChannelRecord) => void; + /** A person created a document. */ + onChannelCreated?: (channel: ChannelRecord) => void; renameChannel?: (id: string, title: string) => Promise; onChannelRestored?: (channelId: string, now: Date) => Promise; /** Restores an earlier version's prose as a new edit by `author` (a user id). */ @@ -387,6 +389,7 @@ export function registerChannelRoutes( } } if (!channel) throw new StorageError("conflict", "could not reserve a generated title"); + options.onChannelCreated?.(channel); return json( { repository: repository(repo), diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index af687e26f..90d62b1e9 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -125,7 +125,16 @@ import { SessionSockets } from "./socket/session-sockets"; import { StorageError } from "./storage/errors"; import { createStorage } from "./storage/registry"; import { WriterLease } from "./storage/writer-lease"; -import { broadcast, fail, relay, reply, repositoryTopic, tell, topic } from "./wire"; +import { + broadcast, + broadcastRepository, + fail, + relay, + reply, + repositoryTopic, + tell, + topic, +} from "./wire"; import { AGENT_PATH, LINK_CLOSE } from "@chopin/agent-link"; @@ -1939,9 +1948,21 @@ async function resetOpenAgents( ); } +/** Tell every sidebar watching the document's repository that its documents changed. */ +function announceCatalogue(channel: Pick): void { + if (!server || draining) return; + broadcastRepository(server, channel.repositoryId, { + kind: "sidebar:catalogue", + ts: 0, + repositoryId: channel.repositoryId, + channelId: channel.id, + }); +} + function announceChannel(channel: ChannelRecord): void { // A machine running its Planner hears a new address on its next Planner poll; wake that poll. experiments?.connections.wake(channel.repositoryId); + announceCatalogue(channel); let room = Rooms.get(channel.id); if (!room) return; for (let ws of room.members.values()) { @@ -2095,6 +2116,7 @@ async function deleteChannelLocked(channelId: string): Promise { if (room) Rooms.forget(room); buildReadiness?.forget(channelId); void Service.announceDeletedUnanswered(server, storage, channel); + announceCatalogue(channel); summaryCoordinator?.resume(channelId); return true; } catch (err) { @@ -2738,6 +2760,7 @@ registerMcpRoutes(router, hostedAuth, { archiveChannel, isChannelDeleting: channelId => deletingChannels.has(channelId), onChannelRenamed: announceChannel, + onChannelCreated: announceCatalogue, onDocumentPersisted: target => { void documentHistory.record(target); if (summaryCoordinator) void summaryCoordinator.enqueueNow(target).catch(() => {}); @@ -2803,6 +2826,7 @@ registerChannelRoutes(router, hostedAuth, { onAgentReset: channelOwnerReset, onChannelArchived: archiveChannel, onChannelDeleted: deleteChannel, + onChannelCreated: announceCatalogue, renameChannel: (id, title) => withImplementationPlan( id, diff --git a/apps/server/src/mcp/hosted.test.ts b/apps/server/src/mcp/hosted.test.ts index da68dc38b..09d728fd5 100644 --- a/apps/server/src/mcp/hosted.test.ts +++ b/apps/server/src/mcp/hosted.test.ts @@ -237,6 +237,22 @@ describe("the hosted MCP adapter", () => { expect(await adapter.create!.create(caller, creation)).toMatchObject({ kind: "replayed" }); }); + it("tells sidebars about a document create_document made, once, not on a replay", async () => { + let context = setup(); + context.github.repositoryValue.permissions.push = true; + let lease = (await context.storage.leases.acquire("writer", "announce-create", 60_000))!; + let created: string[] = []; + let adapter = hosted(context.auth, { lease: () => lease }, { + onChannelCreated: channel => created.push(`${channel.repositoryId} ${channel.id}`), + }); + let caller = (await adapter.caller(request("Bearer allowed")))!; + expect(await adapter.create!.create(caller, creation)).toMatchObject({ kind: "created" }); + let id = deterministicChannelId("R_score", creation.idempotencyKey); + expect(created).toEqual([`R_score ${id}`]); + expect(await adapter.create!.create(caller, creation)).toMatchObject({ kind: "replayed" }); + expect(created).toEqual([`R_score ${id}`]); + }); + it("never recreates an import deleted after its durable replay was read", async () => { let context = setup(); context.github.repositoryValue.permissions.push = true; diff --git a/apps/server/src/mcp/hosted.ts b/apps/server/src/mcp/hosted.ts index 5c3a949bc..aa6a9330e 100644 --- a/apps/server/src/mcp/hosted.ts +++ b/apps/server/src/mcp/hosted.ts @@ -67,6 +67,8 @@ export type HostedCallbacks = { isChannelDeleting?: (channelId: string) => boolean; serializeDocument?: (channelId: string, action: () => Promise) => Promise; onChannelRenamed?: (channel: ChannelRecord) => void; + /** `create_document` created a document. */ + onChannelCreated?: (channel: ChannelRecord) => void; onDocumentPersisted?: (target: Plan.DocumentTarget) => void; invokePlanner?: (input: PlannerInvocation) => Promise; /** What `planner-not-attached` says for a document, at its URL; how to attach one by default. */ @@ -616,6 +618,7 @@ export function hosted( if (!stored) return { kind: "title-taken" }; return replay(stored); } + callbacks.onChannelCreated?.(created); callbacks.onDocumentPersisted?.({ channelId: id, revision: 0, diff --git a/apps/web/src/sidebar-decision-counts.tsx b/apps/web/src/sidebar-decision-counts.tsx index fe1d0d16e..967837eae 100644 --- a/apps/web/src/sidebar-decision-counts.tsx +++ b/apps/web/src/sidebar-decision-counts.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { decisionWatchPlan, fallbackRefreshBatch } from "./sidebar-decision-plan"; import { useSidebarDecisions } from "./use-sidebar-decisions"; @@ -9,6 +9,8 @@ import type * as Api from "./api"; import type { ProjectDocuments } from "./document-actions"; const UNWATCHED_REFRESH_MS = 60_000; +/** Changes to one repository's documents within this long are read once, after the last. */ +export const CATALOGUE_SETTLE_MS = 250; export default function SidebarDecisionCounts({ archived, @@ -32,7 +34,36 @@ export default function SidebarDecisionCounts({ archived ? { watched: [], unwatched: [] } : decisionWatchPlan(projects, priorityRepositoryId), [archived, priorityRepositoryId, projects], ); - let resyncDecisions = useSidebarDecisions(decisionWatch.watched, { onCounts, onSnapshot }); + let latestProjects = useRef(projects); + latestProjects.current = projects; + let catalogueTimers = useRef(new Map>()); + useEffect(() => { + let timers = catalogueTimers.current; + return () => { + for (let timer of timers.values()) clearTimeout(timer); + timers.clear(); + }; + }, []); + /** Someone changed a shown repository's documents: read them again, once a burst settles. */ + let onCatalogue = useCallback((change: Sidebar.Catalogue) => { + let timers = catalogueTimers.current; + clearTimeout(timers.get(change.repositoryId)); + timers.set( + change.repositoryId, + setTimeout(() => { + timers.delete(change.repositoryId); + let shown = latestProjects.current.find(value => + value.project.repositoryId === change.repositoryId + ); + if (shown) refreshProject(shown.project); + }, CATALOGUE_SETTLE_MS), + ); + }, [refreshProject]); + let resyncDecisions = useSidebarDecisions(decisionWatch.watched, { + onCounts, + onSnapshot, + onCatalogue, + }); useEffect(() => { resync.current = resyncDecisions; return () => { diff --git a/apps/web/src/use-sidebar-decisions.ts b/apps/web/src/use-sidebar-decisions.ts index 700f88983..966cdada5 100644 --- a/apps/web/src/use-sidebar-decisions.ts +++ b/apps/web/src/use-sidebar-decisions.ts @@ -18,11 +18,13 @@ import type { ResyncQueue, WatchLedger } from "./sidebar-decision-watch"; type Handlers = { onCounts: (counts: Sidebar.Decisions) => void; onSnapshot: (snapshot: Sidebar.Snapshot) => void; + /** A watched repository's documents changed: one was created, renamed, archived, restored or deleted. */ + onCatalogue: (change: Sidebar.Catalogue) => void; }; /** - * Keep the Projects sidebar's decision counts live through its own socket, whether or - * not a document is open, watching every repository the sidebar shows. Returns a + * Keep the Projects sidebar's decision counts and documents live through its own socket, + * whether or not a document is open, watching every repository the sidebar shows. Returns a * function that asks for a fresh snapshot of a repository whose total conflicts. */ export function useSidebarDecisions( @@ -66,6 +68,9 @@ export function useSidebarDecisions( socket.on("sidebar:snapshot", frame => { latestHandlers.current.onSnapshot(frame); }), + socket.on("sidebar:catalogue", frame => { + latestHandlers.current.onCatalogue(frame); + }), ]; setWire(socket); return () => { diff --git a/docs/channels.md b/docs/channels.md index 91fed6512..7c6f6a8e4 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -121,6 +121,15 @@ session and each watched repository. A denied repository is unsubscribed, an unavailable answer keeps an existing subscription, an expired session closes the socket, and closing the socket drops every subscription. +When a document is created, renamed, archived, restored or permanently deleted, +by a person, the Planner or a coding agent over MCP, the server also publishes +`sidebar:catalogue` to that repository's topic. It names the repository and the +document but no title, so a sidebar learns nothing it couldn't read: it reads that +repository's documents again through the usual access check, once a burst of +changes has been quiet for 250 ms. A document someone else creates therefore +shows in every sidebar watching its repository without a reload. Repositories +past the watch cap still refresh every minute. + When a commit changes a document's count, or a document is archived, restored or permanently deleted, the server publishes `sidebar:decisions` to that repository's topic, so every sidebar socket watching it receives the update. A diff --git a/e2e/sidebar-decision-counts.e2e.ts b/e2e/sidebar-decision-counts.e2e.ts index 9d681ea87..75f24b901 100644 --- a/e2e/sidebar-decision-counts.e2e.ts +++ b/e2e/sidebar-decision-counts.e2e.ts @@ -802,3 +802,63 @@ test("deleting an archived child updates the project total for a viewer in its p await editor.context.close(); } }); + +test("documents someone else creates, renames, archives or deletes show in the sidebar without a reload", async ({ baseURL, browser, join, room }) => { + let port = Number(new URL(baseURL!).port); + let existing = crypto.randomUUID(); + await createChannel(port, existing, ARCHIVED_CHILD); + let creator = await openAs( + browser, + baseURL!, + `document-creator-${crypto.randomUUID()}`, + documentPath(ARCHIVED_CHILD, existing), + ); + try { + let viewer = await join(`catalogue-live-${room.slice(0, 8)}`); + await addProject(viewer, baseURL!, ARCHIVED_CHILD); + await viewer.goto(`/documents/${ARCHIVED_CHILD.owner}/${ARCHIVED_CHILD.name}`); + await expect(viewer.getByRole("heading", { name: "No document open" })).toBeVisible(); + let projects = sidebar(viewer); + await expect(projects.getByRole("link", { name: new RegExp(`^Test ${existing.slice(0, 8)}`) })) + .toBeVisible(); + let reloads = 0; + viewer.on("framenavigated", frame => { + if (frame === viewer.mainFrame()) reloads++; + }); + let headers = { origin: baseURL! }; + + // Created in another browser: it appears in the viewer's sidebar on its own. + let title = `Live ${room.slice(0, 8)}`; + let created = await creator.page.request.post( + `/api/repositories/${ARCHIVED_CHILD.owner}/${ARCHIVED_CHILD.name}/channels`, + { headers, data: { title } }, + ); + expect(created.status()).toBe(201); + let id = (await created.json()).channel.id as string; + await expect(projects.getByRole("link", { name: new RegExp(`^${title}`) })).toBeVisible({ + timeout: 10_000, + }); + + // Renamed: the row follows. + let renamedTitle = `Renamed ${room.slice(0, 8)}`; + let renamed = await creator.page.request.patch(`/api/channels/${id}`, { + headers, + data: { title: renamedTitle }, + }); + expect(renamed.ok()).toBe(true); + await expect(projects.getByRole("link", { name: new RegExp(`^${renamedTitle}`) })) + .toBeVisible({ timeout: 10_000 }); + await expect(projects.getByRole("link", { name: new RegExp(`^${title}`) })).toHaveCount(0); + + // Archived, then deleted: it leaves. + let archived = await creator.page.request.post(`/api/channels/${id}/archive`, { headers }); + expect(archived.ok()).toBe(true); + await expect(projects.getByRole("link", { name: new RegExp(`^${renamedTitle}`) })) + .toHaveCount(0, { timeout: 10_000 }); + let deleted = await creator.page.request.delete(`/api/channels/${id}`, { headers }); + expect(deleted.status()).toBe(204); + expect(reloads).toBe(0); + } finally { + await creator.context.close(); + } +}); diff --git a/packages/protocol/index.d.ts b/packages/protocol/index.d.ts index 8c6dd121c..68a058965 100644 --- a/packages/protocol/index.d.ts +++ b/packages/protocol/index.d.ts @@ -149,14 +149,31 @@ export declare namespace Session { /** * The Projects sidebar's own socket at `/ws/sidebar`, which carries unanswered * decision counts for every repository the sidebar shows whether or not a - * document is open. Admission authenticates the browser session only; each + * document is open, and says when a repository's documents change. Admission authenticates the browser session only; each * repository is authorized when it is watched. Room sockets carry none of these * frames. Requests fail with `Session.Failure`, and `Session.Ping` works here too. */ export declare namespace Sidebar { export type Incoming = Request | Request | Request; - export type Outgoing = Decisions | Watched | Snapshot | Session.Failure | Session.Ping; + export type Outgoing = + | Decisions + | Catalogue + | Watched + | Snapshot + | Session.Failure + | Session.Ping; + + /** + * A document in a watched repository joined, changed in or left its catalogue: someone + * created, renamed, archived, restored or deleted it. Sent to every socket watching the + * repository, the change's author's included. It names no title: read the repository's + * documents again, through the same access check as any read. + */ + export type Catalogue = KIND<"sidebar:catalogue"> & { + repositoryId: string; + channelId: string; + }; /** * Unanswered decision counts read from authoritative question records after they