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