diff --git a/.changeset/config.json b/.changeset/config.json index d02916564e..882c1d795a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -41,7 +41,6 @@ "@corbits/mocks", "@corbits/notify", "@corbits/plugins-ui", - "@corbits/presence", "@corbits/process-provisioner", "@corbits/provider-pricing", "@corbits/sandbox-sidecar", diff --git a/apps/hub/package.json b/apps/hub/package.json index b57010df05..f77ca40f3c 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -44,7 +44,6 @@ "@corbits/morning-brief-workflow": "workspace:*", "@corbits/notify": "workspace:*", "@corbits/pain-point-collateral-workflow": "workspace:*", - "@corbits/presence": "workspace:*", "@corbits/process-granola-call-workflow": "workspace:*", "@corbits/process-provisioner": "workspace:*", "@corbits/reddit-opportunity-scanner-workflow": "workspace:*", @@ -78,7 +77,6 @@ }, "devDependencies": { "@types/bun": "catalog:", - "typescript": "catalog:", - "yjs": "^13.6.32" + "typescript": "catalog:" } } diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index ccca0ada8a..6d19f801c5 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -177,10 +177,6 @@ import { type ProcessProvisionerRole, } from "@corbits/process-provisioner"; import { createWorkflowRunAuthenticator } from "@corbits/artifacts-hub"; -import { - createPresenceRoomRegistry, - createPresenceRoutes, -} from "@corbits/presence"; import { createConnectionRoutes, isInferenceProvider, @@ -1048,9 +1044,6 @@ export async function createHub(config: HubConfig) { // nothing reported. See `hubErrorHandler`'s own doc comment. app.onError(hubErrorHandler()); - // Presence rooms are ephemeral and process-local by design. - const presenceRoomRegistry = createPresenceRoomRegistry(); - // The hub's own grant store, built the same way `createApp` builds its // default when none is supplied (see `@intx/hub-api`'s // `mountHubRoutes`). `createRequireGrant` is the published construction @@ -1117,16 +1110,6 @@ export async function createHub(config: HubConfig) { }, ); } - app.route( - `${TENANT_PREFIX}/presence`, - createPresenceRoutes({ - registry: presenceRoomRegistry, - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: grantConditionRegistry, - }), - }), - ); const threadStore = createDrizzleThreadStore(db); const blockResponseStore = createDrizzleBlockResponseStore(db); const reactionStore = createDrizzleReactionStore(db); diff --git a/apps/hub/test/artifact-doc-persistence.test.ts b/apps/hub/test/artifact-doc-persistence.test.ts deleted file mode 100644 index 94bc53851f..0000000000 --- a/apps/hub/test/artifact-doc-persistence.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -// DB-gated: skipped when DATABASE_URL is unreachable, matching this repo's -// existing convention for tests that talk to a real Postgres (see -// apps/hub/src/memory-mount.test.ts). Proves the exact wiring -// `src/index.ts` composes between `@corbits/presence`'s -// `createArtifactDocPersistence` and `@corbits/artifacts`' own -// `getArtifact`/`writeArtifactVersion` seam actually lands a new artifact -// version row — not just that the two packages' unit tests independently -// pass with fakes standing in for each other. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { dbGate } from "../../../scripts/e2e/db-gate"; -import * as Y from "yjs"; -import { and, eq } from "drizzle-orm"; - -import { createDB, schema, type DB } from "@intx/db"; -import { generateId } from "@intx/hub-common"; -import { - artifactVersion, - createArtifact, - createArtifactDb, - getArtifact, - getArtifactVersion, - runArtifactMigrations, - writeArtifactVersion, - type ArtifactDb, -} from "@corbits/artifacts"; -import { - createArtifactDocPersistence, - createPresenceRoomRegistry, -} from "@corbits/presence"; - -function dbConfigFromUrl(databaseUrl: string) { - const url = new URL(databaseUrl); - return { - host: url.hostname, - port: url.port === "" ? 5432 : Number(url.port), - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - }; -} - -const databaseUrl = process.env["DATABASE_URL"]; -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb( - "artifact doc persistence: a real snapshot write against Postgres", - () => { - let controlDb: DB; - let artifactDb: ArtifactDb; - - const tenantId = generateId("tenant"); - const principalId = generateId("principal"); - - beforeAll(async () => { - controlDb = createDB(dbConfigFromUrl(databaseUrl as string)); - await controlDb.db.insert(schema.tenant).values({ - id: tenantId, - name: "Co-editing Test Bench", - slug: `coediting-${tenantId}`, - domain: `coediting-${tenantId}.localhost`, - }); - await controlDb.db.insert(schema.principal).values({ - id: principalId, - tenantId, - kind: "user", - refId: "not-a-real-user", - status: "active", - }); - - const created = createArtifactDb(databaseUrl as string); - artifactDb = created.db; - await runArtifactMigrations(artifactDb); - }); - - afterAll(async () => { - await controlDb.db - .delete(schema.principal) - .where(eq(schema.principal.id, principalId)); - await controlDb.db - .delete(schema.tenant) - .where(eq(schema.tenant.id, tenantId)); - }); - - test("a debounced snapshot writes a new artifact_version row via writeArtifactVersion", async () => { - const created = await artifactDb.transaction((tx) => - createArtifact(tx, { - scope: { tenantId, principalId }, - ownerPrincipalId: principalId, - kind: "document", - title: "Co-edited doc", - content: "original content", - source: {}, - }), - ); - - const registry = createPresenceRoomRegistry(); - const errors: unknown[] = []; - const persistence = createArtifactDocPersistence({ - registry, - debounceMs: 10, - loadArtifactContent: async (tid, artifactId) => { - const row = await getArtifact(artifactDb, artifactId); - if (row === null || row.tenantId !== tid) return null; - return row.content; - }, - writeArtifactSnapshot: async (tid, artifactId, author, content) => { - const written = await writeArtifactVersion(artifactDb, { - scope: { tenantId: tid, principalId: author }, - artifactId, - content, - }); - return { version: written.version }; - }, - onSnapshotError: (_key, error) => errors.push(error), - }); - - const key = { tenantId, surface: `artifact:${created.id}` }; - await persistence.seedOnJoin(key); - expect(registry.docText(key)).toBe("original content"); - - // A real co-editor catches up to the seeded state first (mirroring - // the join route's `docUpdate` response), then edits from there — - // editing an unrelated, un-synced doc would just concurrently merge - // instead of replacing, which is correct Yjs behavior but not what - // this test means to exercise. - const doc = new Y.Doc(); - Y.applyUpdate(doc, registry.docStateAsUpdate(key)); - const text = doc.getText("content"); - text.delete(0, text.length); - text.insert(0, "edited by a co-editor"); - registry.applyDocUpdate(key, Y.encodeStateAsUpdate(doc), principalId); - - // The snapshot path is a 10ms-debounce timer followed by a real - // Postgres write — a fixed sleep flakes under load, so poll to a - // deadline for the version row instead of assuming any fixed delay. - const deadline = Date.now() + 10_000; - for (;;) { - const row = await getArtifact(artifactDb, created.id); - if (row?.version === 2) break; - if (Date.now() > deadline) { - throw new Error( - `the debounced snapshot never landed a version-2 row (last version: ${row?.version})`, - ); - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - - expect(errors).toEqual([]); - const updated = await getArtifact(artifactDb, created.id); - expect(updated?.version).toBe(2); - expect(updated?.content).toBe("edited by a co-editor"); - - const versionTwo = await getArtifactVersion(artifactDb, created.id, 2); - expect(versionTwo?.content).toBe("edited by a co-editor"); - - const [versionTwoRow] = await artifactDb - .select({ authorId: artifactVersion.authorId }) - .from(artifactVersion) - .where( - and( - eq(artifactVersion.artifactId, created.id), - eq(artifactVersion.version, 2), - ), - ); - expect(versionTwoRow?.authorId).toBe(principalId); - - persistence.dispose(); - }); - }, -); diff --git a/apps/hub/test/presence-mount.test.ts b/apps/hub/test/presence-mount.test.ts deleted file mode 100644 index 9bef1ba299..0000000000 --- a/apps/hub/test/presence-mount.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Proves the presence mount exists inside the native tenant middleware — -// the same shape `chat-mount.test.ts` proves for chat. Presence's own -// join/heartbeat/stream behavior belongs to `packages/presence`'s own -// tests; this only proves the composition-root wiring in `src/index.ts` -// actually reaches a live app. - -import { afterAll, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import type { HubConfig } from "../src/config.ts"; -import { createHub } from "../src/index.ts"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -// DB-gated: skipped when DATABASE_URL is unset, matching this repo's -// convention for tests that talk to a real Postgres. -const databaseUrl = process.env["DATABASE_URL"] ?? ""; -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const root = mkdtempSync(path.join(tmpdir(), "hub-presence-mount-")); -const staticDir = path.join(root, "static"); -mkdirSync(staticDir, { recursive: true }); -writeFileSync(path.join(staticDir, "index.html"), "shell"); -mkdirSync(path.join(root, "data"), { recursive: true }); - -const config: HubConfig = { - databaseUrl, - baseUrl: "http://localhost:3000", - sessionSecret: "insecure-test-only-session-secret-0000", - hubDataDir: path.join(root, "data"), - hubStaticDir: staticDir, - signupRateLimit: { windowSeconds: 60, max: 5 }, - signInRateLimit: { windowSeconds: 60, max: 10 }, - socialProviders: {}, - allowPlaintextSecrets: true, - sidecarProvisioners: [], -}; - -const closers: (() => Promise)[] = []; - -afterAll(async () => { - for (const close of closers) await close(); - rmSync(root, { recursive: true, force: true }); -}); - -describeIfDb("presence mount", () => { - test("presence routes mount inside the native tenant middleware", async () => { - const hub = await createHub(config); - closers.push(hub.close); - - const gated = await hub.app.request( - "/api/tenants/some-tenant/presence/rooms/workbench:chn_1/join", - { method: "POST" }, - ); - expect(gated.status).toBe(401); - expect(await gated.json()).toEqual({ - error: { code: "unauthorized", message: "Authentication required" }, - }); - - // The route exists only under the tenant scope; outside it the path - // falls through to the interface shell. - const outside = await hub.app.request( - "/presence/rooms/workbench:chn_1/join", - ); - expect(await outside.text()).toBe("shell"); - }); -}); diff --git a/apps/web/package.json b/apps/web/package.json index 37af7651f7..c57af9e4af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,7 +31,6 @@ "@corbits/inbox": "workspace:*", "@corbits/inference-settings": "workspace:*", "@corbits/plugins-ui": "workspace:*", - "@corbits/presence": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", "@corbits/settings-ui": "workspace:*", "@corbits/shell-layout": "workspace:*", @@ -47,8 +46,7 @@ "@corbits/connections": "workspace:*", "arktype": "catalog:", "react": "^19.2.0", - "react-dom": "^19.2.0", - "yjs": "^13.6.32" + "react-dom": "^19.2.0" }, "devDependencies": { "@happy-dom/global-registrator": "^20.11.2", diff --git a/apps/web/src/presence/use-presence-room.ts b/apps/web/src/presence/use-presence-room.ts deleted file mode 100644 index f72a17d877..0000000000 --- a/apps/web/src/presence/use-presence-room.ts +++ /dev/null @@ -1,160 +0,0 @@ -// The one place apps/web talks to `@corbits/presence/client` — every -// composition site (the workbench header's who's-here stack, the canvas -// artifact pane's cursor overlay) goes through this hook rather than -// calling `connectPresence` itself, so there is exactly one connect/ -// disconnect lifecycle to reason about per (tenant, surface) pair. -import { useCallback, useEffect, useRef, useState } from "react"; -import type * as Y from "yjs"; -import { reportError } from "@corbits/error-sink"; -import { - connectPresence, - type PresenceError, - type PresenceHandle, -} from "@corbits/presence/client"; - -export interface PresenceRoomMember { - readonly principalId: string; - readonly displayName: string; - readonly color: string; - readonly cursor?: { - readonly x: number; - readonly y: number; - readonly surfaceVersion: number; - }; - readonly typing?: boolean; -} - -/** Healthy until two consecutive transport failures; a single retry stays quiet. */ -export type PresenceConnection = "ok" | "degraded"; - -export interface PresenceRoom { - readonly members: readonly PresenceRoomMember[]; - readonly connection: PresenceConnection; - readonly publishCursor: ( - x: number, - y: number, - surfaceVersion?: number, - ) => void; - readonly publishTyping: (typing: boolean) => void; -} - -export interface UsePresenceRoomOptions { - /** - * A shared `Y.Doc` to keep in sync over this room — see - * `@corbits/presence/client`'s own `doc` option. Only artifact rooms - * (CL-5958 phase 2 co-editing) pass this; the workbench who's-here stack - * never does, since it has no doc content. Reconnects when the `Y.Doc` - * instance itself changes (a different artifact), not on every render. - */ - readonly doc?: Y.Doc; - /** Called for every confirmed server-side snapshot — see `formatSaveStateLine` in `@corbits/artifact-ui`. */ - readonly onSaved?: (info: { version: number; savedAt: number }) => void; - /** The signed-in principal, carried on `reportError` so a failed join is scoped. */ - readonly principalId?: string; -} - -const TRANSIENT_FAILURE_BUDGET = 1; - -function presenceErrorMessage(error: PresenceError): string { - return error.status === undefined - ? `Presence ${error.operation} failed` - : `Presence ${error.operation} failed (${String(error.status)})`; -} - -/** - * Connects to a presence room for as long as `tenantId`/`surface` are both - * present, tearing down and reconnecting whenever either changes (workbench - * switch, artifact switch, workbench switch). `null` for either means - * "nothing to connect to" — mirrors `useWorkbenchStream`'s empty-url guard in - * `@corbits/chat-ui`. - */ -export function usePresenceRoom( - tenantId: string | null, - surface: string | null, - displayName?: string, - options?: UsePresenceRoomOptions, -): PresenceRoom { - const [members, setMembers] = useState([]); - const [connection, setConnection] = useState("ok"); - const handleRef = useRef(null); - const consecutiveFailuresRef = useRef(0); - // `onSaved` is read through a ref, not a `connectPresence` dependency: - // a caller re-rendering with a fresh inline callback must never tear - // down and reconnect the stream (it would re-fetch the whole doc state - // for no reason) — only the `Y.Doc` identity changing means a genuinely - // different room to sync. - const onSavedRef = useRef(options?.onSaved); - onSavedRef.current = options?.onSaved; - const principalIdRef = useRef(options?.principalId); - principalIdRef.current = options?.principalId; - const doc = options?.doc; - - useEffect(() => { - setMembers([]); - setConnection("ok"); - consecutiveFailuresRef.current = 0; - if (tenantId === null || surface === null) { - handleRef.current = null; - return; - } - const connectOptions: { - roomUrl: string; - onSaved: (info: { version: number; savedAt: number }) => void; - displayName?: string; - doc?: Y.Doc; - } = { - roomUrl: `/api/tenants/${tenantId}/presence/rooms/${surface}`, - onSaved: (info) => onSavedRef.current?.(info), - }; - if (displayName !== undefined) connectOptions.displayName = displayName; - if (doc !== undefined) connectOptions.doc = doc; - const handle = connectPresence(connectOptions); - handleRef.current = handle; - const unsubscribe = handle.subscribe((snapshot) => - setMembers(snapshot as readonly PresenceRoomMember[]), - ); - const unsubscribeErrors = handle.onError((error) => { - const extra: Record = {}; - if (principalIdRef.current !== undefined) { - extra.principalId = principalIdRef.current; - } - if (error.status !== undefined) extra.status = error.status; - reportError(new Error(presenceErrorMessage(error)), { - operation: `presence.${error.operation}`, - tenantId, - roomId: surface, - ...(Object.keys(extra).length > 0 ? { extra } : {}), - }); - consecutiveFailuresRef.current += 1; - if (consecutiveFailuresRef.current > TRANSIENT_FAILURE_BUDGET) { - setConnection("degraded"); - } - }); - const unsubscribeRecovered = handle.onRecovered(() => { - consecutiveFailuresRef.current = 0; - setConnection("ok"); - }); - return () => { - unsubscribe(); - unsubscribeErrors(); - unsubscribeRecovered(); - handle.disconnect(); - handleRef.current = null; - }; - // `displayName` deliberately isn't a dependency: a later rename - // shouldn't tear down and reconnect an otherwise-unaffected stream. - }, [tenantId, surface, doc]); - - const publishCursor = useCallback( - (x: number, y: number, surfaceVersion = 1) => { - handleRef.current?.publishCursor({ x, y, surfaceVersion }); - }, - [], - ); - - const publishTyping = useCallback((typing: boolean) => { - handleRef.current?.publishTyping(typing); - }, []); - - return { members, connection, publishCursor, publishTyping }; -} diff --git a/apps/web/src/shell/app-shell.tsx b/apps/web/src/shell/app-shell.tsx index 05756d4abf..c07091694d 100644 --- a/apps/web/src/shell/app-shell.tsx +++ b/apps/web/src/shell/app-shell.tsx @@ -19,7 +19,6 @@ import { useState, type ReactNode, } from "react"; -import * as Y from "yjs"; import type { ArtifactSaveState } from "@corbits/artifact-ui"; import { WorkbenchLoadingState } from "@corbits/chat-ui"; @@ -27,7 +26,7 @@ import { WorkbenchLoadingState } from "@corbits/chat-ui"; import { usePendingApprovalCount } from "../pending-approvals"; import { useBench } from "../bench-context"; import { useNavigate } from "../navigation"; -import { usePresenceRoom } from "../presence/use-presence-room"; +import { saveArtifactContent } from "./library-artifacts"; import { APP_ROUTES, matchesRoute } from "../routes"; import type { SessionUser } from "../session"; import { StageTopBar } from "./stage-top-bar"; @@ -85,30 +84,24 @@ export function AppShell({ const canvasArtifact = useCanvasColumnArtifact(); const canvasRoutine = useCanvasColumnRoutine(); const canvasFocus = useCanvasColumnFocus(); - const { selectedTenantId: tenantId, selectedPrincipalId: viewerPrincipalId } = - useBench(); + const { selectedTenantId: tenantId } = useBench(); - // A text-kind artifact's shared `Y.Doc` (CL-5958 phase 2): one instance - // per artifact id, torn down and replaced the moment the open artifact - // changes so a stale doc from a previous artifact can never leak into a - // newly opened one. Non-"doc" kinds never get one — there's nothing to - // co-edit, so `usePresenceRoom` connects awareness-only for them, same - // as phase 1. - const [artifactDoc, setArtifactDoc] = useState(null); + // A text-kind artifact's save state (CL-8189: single-user editing, no + // co-edit presence). Resets to a fresh state the moment the open + // artifact changes so a stale "Saved · v3" from a previous artifact can + // never leak into a newly opened one. const [artifactSaveState, setArtifactSaveState] = useState( { kind: "read-only" }, ); - const artifactDocForId = useRef(null); + const artifactSaveStateForId = useRef(null); useEffect(() => { if (canvasArtifact === null || canvasArtifact.rendererKind !== "doc") { - artifactDocForId.current = null; - setArtifactDoc(null); + artifactSaveStateForId.current = null; setArtifactSaveState({ kind: "read-only" }); return; } - if (artifactDocForId.current === canvasArtifact.id) return; - artifactDocForId.current = canvasArtifact.id; - setArtifactDoc(new Y.Doc()); + if (artifactSaveStateForId.current === canvasArtifact.id) return; + artifactSaveStateForId.current = canvasArtifact.id; setArtifactSaveState( canvasArtifact.canEdit === true ? { kind: "unsaved" } @@ -116,38 +109,25 @@ export function AppShell({ ); }, [canvasArtifact]); - // Co-viewers of the open artifact, if any — see canvas-column.tsx's own - // `PresenceCursor` doc for why this stays plain data across the - // package boundary. - const artifactPresence = usePresenceRoom( - tenantId, - canvasArtifact === null ? null : `artifact:${canvasArtifact.id}`, - undefined, - { - ...(viewerPrincipalId === null ? {} : { principalId: viewerPrincipalId }), - ...(artifactDoc === null - ? {} - : { - doc: artifactDoc, - onSaved: (info: { version: number; savedAt: number }) => - setArtifactSaveState({ - kind: "saved", - version: info.version, - savedAt: info.savedAt, - }), - }), - }, - ); - const editingCoworkers = artifactPresence.members - .filter( - (member) => - member.typing === true && member.principalId !== viewerPrincipalId, - ) - .map((member) => member.displayName); - const artifactSaveStateWithEditors: ArtifactSaveState = - canvasArtifact?.canEdit === true && editingCoworkers.length > 0 - ? { kind: "editing", by: editingCoworkers } - : artifactSaveState; + const saveArtifact = (content: string) => { + if (tenantId === null || canvasArtifact === null) return; + const artifactId = canvasArtifact.id; + setArtifactSaveState({ kind: "saving" }); + void saveArtifactContent(tenantId, artifactId, content).then( + (saved) => { + if (artifactSaveStateForId.current !== artifactId) return; + setArtifactSaveState({ + kind: "saved", + version: saved.version, + savedAt: Date.now(), + }); + }, + () => { + if (artifactSaveStateForId.current !== artifactId) return; + setArtifactSaveState({ kind: "unsaved" }); + }, + ); + }; const closeCanvas = useCloseCanvas(); const toggleCanvasFocus = useToggleCanvasFocus(); const mainRef = useRef(null); @@ -203,20 +183,8 @@ export function AppShell({ onClose={closeCanvas} onToggleFocus={toggleCanvasFocus} onNavigate={navigate} - presenceCursors={artifactPresence.members - .filter((member) => member.cursor !== undefined) - .map((member) => ({ - principalId: member.principalId, - displayName: member.displayName, - color: member.color, - x: member.cursor?.x ?? 0, - y: member.cursor?.y ?? 0, - }))} - onCursorMove={artifactPresence.publishCursor} - {...(artifactDoc !== null ? { artifactDoc } : {})} - artifactSaveState={artifactSaveStateWithEditors} - onArtifactTyping={artifactPresence.publishTyping} - presenceConnection={artifactPresence.connection} + artifactSaveState={artifactSaveState} + onSaveArtifact={saveArtifact} /> ) : null} diff --git a/apps/web/src/shell/canvas-column.tsx b/apps/web/src/shell/canvas-column.tsx index 4e4cd6be4f..368f343f71 100644 --- a/apps/web/src/shell/canvas-column.tsx +++ b/apps/web/src/shell/canvas-column.tsx @@ -4,16 +4,12 @@ // from a chat artifact chip or the Library page. Primary workbench // conversation lives in the main stage, not here. // -// CL-5958 phase 1 added a co-viewer cursor overlay on top of the -// read-only renderer, driven by `@corbits/presence/client` in -// `app-shell.tsx` and handed down here as plain `PresenceCursor` data. -// Phase 2 adds real co-editing for text-kind artifacts: when the host -// hands down a `doc` (a `Y.Doc` already synced over presence) alongside -// `artifact.canEdit`, the pane renders `@corbits/artifact-ui`'s -// `ArtifactTextEditor` instead of the read-only `ArtifactRenderer`. Every -// other kind, and every artifact without a `doc`, stays exactly as -// read-only as phase 1 left it — this module still never imports -// `@corbits/presence` itself, only plain data and a `Y.Doc` handle. +// CL-8189: co-edit presence is gone. A "doc"-kind, editable artifact +// renders `@corbits/artifact-ui`'s `ArtifactTextEditor` as a plain +// single-user controlled textarea, debounced-saved through the artifacts +// HTTP route; every other kind stays the read-only `ArtifactRenderer`. No +// co-viewer cursors, no shared doc — that capability returns, if it does, +// on top of the preserved `@corbits/presence` library, not here. // // The collapse/expand motion lives entirely in `shell.css` as a CSS // transition on `transform`/`opacity` (plus width, so the main pane @@ -49,10 +45,8 @@ import { } from "@corbits/icons"; import { useEffect, useState } from "react"; import type { ReactNode } from "react"; -import type * as Y from "yjs"; import { useBench } from "../bench-context"; -import type { PresenceConnection } from "../presence/use-presence-room"; import { workbenchPath } from "../workbench-path"; import { ensureProfileDm, loadSharedWorkbenches } from "../profile-relations"; import type { @@ -61,21 +55,6 @@ import type { } from "./canvas-availability"; import { useInsertIntoComposer } from "./composer-insertion"; -/** - * One co-viewer's cursor, in the artifact pane's own fractional coordinate - * space (`x`/`y` in `[0, 1]` of the pane's content box) so it survives a - * pane resize between the moment it was published and the moment it - * renders. Plain data — the same reasoning as `PresenceMember` in - * `@corbits/chat-ui`: this module never depends on `@corbits/presence`. - */ -export interface PresenceCursor { - readonly principalId: string; - readonly displayName: string; - readonly color: string; - readonly x: number; - readonly y: number; -} - export function CanvasColumn({ open, profile, @@ -84,12 +63,8 @@ export function CanvasColumn({ onClose, onToggleFocus, onNavigate, - presenceCursors, - onCursorMove, - artifactDoc, artifactSaveState, - onArtifactTyping, - presenceConnection = "ok", + onSaveArtifact, }: { readonly open: boolean; readonly profile: ProfileSubject | null; @@ -99,26 +74,11 @@ export function CanvasColumn({ readonly onClose: () => void; readonly onToggleFocus: () => void; readonly onNavigate: (path: string) => void; - /** Co-viewers currently looking at `artifact`, if any — see `PresenceCursor`. */ - readonly presenceCursors?: readonly PresenceCursor[]; - /** Fired with the pointer's fractional position over the artifact body - * (see `PresenceCursor`'s own doc) as it moves — the host publishes it - * through `@corbits/presence/client`. */ - readonly onCursorMove?: (x: number, y: number) => void; - /** The shared `Y.Doc` for a "doc"-kind `artifact`, already synced over - * presence. Its presence alone decides whether the pane shows the - * live-updating `ArtifactTextEditor` at all (vs. the static - * `ArtifactRenderer`) — `artifact.canEdit` separately decides whether - * that editor accepts keystrokes or is itself read-only. Absent for - * every other renderer kind, and briefly absent for a "doc" artifact - * whose presence connection hasn't handed over a doc yet. */ - readonly artifactDoc?: Y.Doc; - /** The honest save-state line for `artifactDoc` — see `ArtifactSaveState`. */ + /** The honest save-state line for an editable "doc"-kind `artifact` — see `ArtifactSaveState`. */ readonly artifactSaveState?: ArtifactSaveState; - /** Fired on local typing start/stop in the text editor, for the host to publish through presence's `typing` awareness field. */ - readonly onArtifactTyping?: (typing: boolean) => void; - /** Quiet reconnecting caption when presence has failed more than once. */ - readonly presenceConnection?: PresenceConnection; + /** Fired (debounced) with the editor's full current text — the host's + * one seam to the artifacts PUT route. Absent for a non-editable artifact. */ + readonly onSaveArtifact?: (content: string) => void; }) { // `inert` rather than `aria-hidden`: a collapsed column has to be out of // both the accessibility tree and the tab order, and `aria-hidden` alone @@ -147,12 +107,8 @@ export function CanvasColumn({ focus={focus} onClose={onClose} onToggleFocus={onToggleFocus} - {...(presenceCursors !== undefined ? { presenceCursors } : {})} - {...(onCursorMove !== undefined ? { onCursorMove } : {})} - {...(artifactDoc !== undefined ? { artifactDoc } : {})} {...(artifactSaveState !== undefined ? { artifactSaveState } : {})} - {...(onArtifactTyping !== undefined ? { onArtifactTyping } : {})} - presenceConnection={presenceConnection} + {...(onSaveArtifact !== undefined ? { onSaveArtifact } : {})} /> ) : ( void; readonly onToggleFocus: () => void; - readonly presenceCursors?: readonly PresenceCursor[]; - readonly onCursorMove?: (x: number, y: number) => void; - readonly artifactDoc?: Y.Doc; readonly artifactSaveState?: ArtifactSaveState; - readonly onArtifactTyping?: (typing: boolean) => void; - readonly presenceConnection?: PresenceConnection; + readonly onSaveArtifact?: (content: string) => void; }) { - const showEditor = showsTextEditor(artifact, artifactDoc); + const showEditor = showsTextEditor(artifact); return (
- Reconnecting… - - ), - } - : {})} /> -
{ - const bounds = event.currentTarget.getBoundingClientRect(); - if (bounds.width === 0 || bounds.height === 0) return; - onCursorMove( - (event.clientX - bounds.left) / bounds.width, - (event.clientY - bounds.top) / bounds.height, - ); - } - } - > +
{showEditor ? ( {})} /> ) : ( )} - {presenceCursors.length > 0 ? ( - - ) : null}
); diff --git a/apps/web/src/shell/library-artifacts.ts b/apps/web/src/shell/library-artifacts.ts index f17df0a086..43026a7046 100644 --- a/apps/web/src/shell/library-artifacts.ts +++ b/apps/web/src/shell/library-artifacts.ts @@ -96,6 +96,44 @@ export async function uploadArtifactFiles( return body.data; } +/** + * PUT `/api/tenants/:tenantId/artifacts/:id` (CL-8189) — the artifact + * editor's one save path now that co-edit presence is gone. Throws with + * status on non-2xx so the host can render an honest failed-save state + * instead of silently pretending the write landed. + */ +export async function saveArtifactContent( + tenantId: string, + artifactId: string, + content: string, +): Promise { + let response: Response; + try { + response = await fetch( + `/api/tenants/${tenantId}/artifacts/${encodeURIComponent(artifactId)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }, + ); + } catch (cause) { + throw new ApiQueryError( + cause instanceof Error ? cause.message : String(cause), + ); + } + if (response.status === 401) { + throw new UnauthenticatedError(); + } + if (!response.ok) { + throw new ApiQueryError( + `The server answered ${response.status} for artifact save.`, + response.status, + ); + } + return (await response.json()) as ArtifactDetail; +} + /** True when the hub answered "artifacts plane not configured" (503) — * read off the query's own status field, never string-matched out of a * rendered message (that copy is display-boundary plain by design and diff --git a/apps/web/test/artifact-cursor-overlay.test.tsx b/apps/web/test/artifact-cursor-overlay.test.tsx deleted file mode 100644 index f0bde0a681..0000000000 --- a/apps/web/test/artifact-cursor-overlay.test.tsx +++ /dev/null @@ -1,160 +0,0 @@ -// The canvas artifact pane's co-viewer cursor overlay (CL-5958): rendered -// only when `presenceCursors` has entries, positioned in the pane's own -// fractional coordinate space, and `onCursorMove` fires with fractional -// coordinates as the pointer moves — mirrors canvas-column.test.tsx's own -// mount harness. - -import { afterEach, describe, expect, mock, test } from "bun:test"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; - -mock.module("../src/profile-relations", () => ({ - ensureProfileDm: mock(() => - Promise.resolve({ kind: "ready", workbenchId: "chn_dm" }), - ), - loadSharedWorkbenches: mock(() => Promise.resolve([])), -})); - -const { BenchProvider } = await import("../src/bench-context"); -const { NavigationProvider } = await import("../src/navigation"); -const { CanvasColumn } = await import("../src/shell/canvas-column"); -const { TestQueryProvider } = await import("./test-query-provider"); - -const noop = () => undefined; -const realFetch = globalThis.fetch; - -const membership = { - principalId: "prn_1", - tenantId: "tnt_1", - tenantName: "Test Bench", - tenantSlug: "test-bench", - kind: "user", - status: "active", - roles: [], -}; - -function jsonResponse(body: unknown): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); -} - -function routeFetch(input: RequestInfo | URL): Promise { - const url = String(input); - if (url.includes("/api/me/principals")) { - return Promise.resolve( - jsonResponse({ data: [membership], nextCursor: null }), - ); - } - return Promise.reject( - new Error(`unrouted fetch in cursor overlay test: ${url}`), - ); -} - -const artifact = { - id: "art_1", - title: "Design doc", - rendererKind: "doc" as const, - content: "# hello", -}; - -describe("canvas artifact co-viewer cursor overlay", () => { - let container: HTMLDivElement; - let root: Root; - - function render(props: { - presenceCursors?: Parameters[0]["presenceCursors"]; - onCursorMove?: (x: number, y: number) => void; - }) { - globalThis.fetch = routeFetch as typeof fetch; - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - act(() => { - root.render( - - - - - - - , - ); - }); - } - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - globalThis.fetch = realFetch; - }); - - test("no overlay renders when there are no co-viewers", () => { - render({}); - expect(container.querySelector(".shell-artifact-cursor-layer")).toBeNull(); - }); - - test("one labeled cursor per co-viewer, positioned by fractional coordinates", () => { - render({ - presenceCursors: [ - { - principalId: "prn_alice", - displayName: "Alice", - color: "hsl(10 65% 45%)", - x: 0.25, - y: 0.5, - }, - ], - }); - - const cursor = container.querySelector( - ".shell-artifact-cursor", - ) as HTMLElement | null; - expect(cursor).not.toBeNull(); - expect(cursor?.style.left).toBe("25%"); - expect(cursor?.style.top).toBe("50%"); - expect( - container.querySelector(".shell-artifact-cursor-label")?.textContent, - ).toBe("Alice"); - }); - - test("pointer movement over the artifact body reports fractional coordinates", () => { - const moves: [number, number][] = []; - render({ onCursorMove: (x, y) => moves.push([x, y]) }); - - const body = container.querySelector( - ".shell-artifact-pane-body", - ) as HTMLElement; - expect(body).not.toBeNull(); - body.getBoundingClientRect = () => - ({ left: 0, top: 0, width: 200, height: 100 }) as DOMRect; - - act(() => { - body.dispatchEvent( - new MouseEvent("pointermove", { - clientX: 50, - clientY: 25, - bubbles: true, - }), - ); - }); - - expect(moves).toEqual([[0.25, 0.25]]); - }); -}); diff --git a/apps/web/test/canvas-column.test.tsx b/apps/web/test/canvas-column.test.tsx index 9a5a2541f1..29a260ccca 100644 --- a/apps/web/test/canvas-column.test.tsx +++ b/apps/web/test/canvas-column.test.tsx @@ -198,7 +198,7 @@ describe("canvas profile card Message action", () => { }); }); -describe("canvas artifact pane: co-editing (CL-5958 phase 2)", () => { +describe("canvas artifact pane: single-user text editing (CL-8189)", () => { let container: HTMLDivElement; let root: Root; @@ -224,8 +224,7 @@ describe("canvas artifact pane: co-editing (CL-5958 phase 2)", () => { readonly content: string; readonly canEdit?: boolean; }; - readonly artifactDoc?: import("yjs").Doc; - readonly presenceConnection?: "ok" | "degraded"; + readonly onSaveArtifact?: (content: string) => void; }): Promise { await act(async () => { root.render( @@ -241,11 +240,8 @@ describe("canvas artifact pane: co-editing (CL-5958 phase 2)", () => { onClose={noop} onToggleFocus={noop} onNavigate={noop} - {...(props.artifactDoc !== undefined - ? { artifactDoc: props.artifactDoc } - : {})} - {...(props.presenceConnection !== undefined - ? { presenceConnection: props.presenceConnection } + {...(props.onSaveArtifact !== undefined + ? { onSaveArtifact: props.onSaveArtifact } : {})} /> @@ -255,10 +251,7 @@ describe("canvas artifact pane: co-editing (CL-5958 phase 2)", () => { }); } - test("a non-'doc' kind never renders the text editor, even with canEdit and a doc", async () => { - const Y = await import("yjs"); - const doc = new Y.Doc(); - doc.getText("content").insert(0, "sheet content"); + test("a non-'doc' kind never renders the text editor, even with canEdit", async () => { await renderArtifact({ artifact: { id: "art_1", @@ -267,58 +260,47 @@ describe("canvas artifact pane: co-editing (CL-5958 phase 2)", () => { content: "a,b\n1,2", canEdit: true, }, - artifactDoc: doc, }); expect(container.querySelector("textarea")).toBeNull(); }); - test("a 'doc' artifact with canEdit and a synced doc renders an editable textarea bound to the Y.Text", async () => { - const Y = await import("yjs"); - const doc = new Y.Doc(); - doc.getText("content").insert(0, "shared draft"); + test("a 'doc' artifact with canEdit renders an editable textarea seeded with its content", async () => { await renderArtifact({ artifact: { id: "art_2", title: "Notes", rendererKind: "doc", - content: "stale fetch content", + content: "fetched draft", canEdit: true, }, - artifactDoc: doc, }); const textarea = container.querySelector("textarea"); expect(textarea).not.toBeNull(); - expect(textarea?.value).toBe("shared draft"); + expect(textarea?.value).toBe("fetched draft"); expect(textarea?.hasAttribute("readonly")).toBe(false); }); - test("a 'doc' artifact without canEdit renders the same textarea, but readonly and live-updating", async () => { - const Y = await import("yjs"); - const doc = new Y.Doc(); - doc.getText("content").insert(0, "live from co-editors"); + test("a 'doc' artifact without canEdit renders the same textarea, but readonly", async () => { await renderArtifact({ artifact: { id: "art_3", title: "Notes", rendererKind: "doc", - content: "stale fetch content", + content: "fetched content", canEdit: false, }, - artifactDoc: doc, }); const textarea = container.querySelector("textarea"); expect(textarea).not.toBeNull(); - expect(textarea?.value).toBe("live from co-editors"); + expect(textarea?.value).toBe("fetched content"); expect(textarea?.hasAttribute("readonly")).toBe(true); }); - test("typing in the editable textarea applies the diff to the shared Y.Text", async () => { - const Y = await import("yjs"); - const doc = new Y.Doc(); - doc.getText("content").insert(0, "hello"); + test("typing in the editable textarea eventually calls onSaveArtifact with the full text", async () => { + const saved: string[] = []; await renderArtifact({ artifact: { id: "art_4", @@ -327,7 +309,7 @@ describe("canvas artifact pane: co-editing (CL-5958 phase 2)", () => { content: "hello", canEdit: true, }, - artifactDoc: doc, + onSaveArtifact: (content) => saved.push(content), }); const textarea = container.querySelector("textarea"); @@ -342,49 +324,10 @@ describe("canvas artifact pane: co-editing (CL-5958 phase 2)", () => { textarea.dispatchEvent(new Event("input", { bubbles: true })); }); - expect(doc.getText("content").toString()).toBe("hello world"); - }); - - test("a 'doc' artifact with no synced doc yet falls back to the static read-only renderer", async () => { - await renderArtifact({ - artifact: { - id: "art_5", - title: "Notes", - rendererKind: "doc", - content: "fetched content", - canEdit: true, - }, - }); - - expect(container.querySelector("textarea")).toBeNull(); - expect(container.textContent).toContain("fetched content"); - }); - - test("a degraded presence connection shows a quiet reconnecting caption", async () => { - await renderArtifact({ - artifact: { - id: "art_6", - title: "Notes", - rendererKind: "doc", - content: "fetched content", - }, - presenceConnection: "degraded", - }); - - expect(container.textContent).toContain("Reconnecting…"); - }); - - test("a healthy presence connection shows no reconnecting caption", async () => { - await renderArtifact({ - artifact: { - id: "art_7", - title: "Notes", - rendererKind: "doc", - content: "fetched content", - }, - presenceConnection: "ok", + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1_100)); }); - expect(container.textContent).not.toContain("Reconnecting…"); + expect(saved).toEqual(["hello world"]); }); }); diff --git a/apps/web/test/use-presence-room.test.tsx b/apps/web/test/use-presence-room.test.tsx deleted file mode 100644 index 63cf66b5dc..0000000000 --- a/apps/web/test/use-presence-room.test.tsx +++ /dev/null @@ -1,257 +0,0 @@ -// `usePresenceRoom` (CL-5958): connects to `@corbits/presence/client` only -// while both tenant and surface are known, tears down and reconnects when -// either changes, and exposes publishCursor/publishTyping as stable -// callbacks. Mocks `@corbits/presence/client` directly rather than a real -// EventSource/fetch — that transport is `@corbits/presence`'s own test -// responsibility, not apps/web's. CL-7228: also wires `onError`/`onRecovered` -// so a degraded connection is visible after more than one retry. - -import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; -import { act, createElement, useRef } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; -import type { PresenceError } from "@corbits/presence/client"; -import type { UsePresenceRoomOptions } from "../src/presence/use-presence-room"; - -interface FakeHandle { - roomUrl: string; - disconnected: boolean; - listener: ((members: unknown[]) => void) | null; - errorListeners: Set<(error: PresenceError) => void>; - recoveredListeners: Set<() => void>; - cursorCalls: unknown[]; -} - -const created: FakeHandle[] = []; -const reportErrorCalls: { error: unknown; context: Record }[] = - []; - -mock.module("@corbits/error-sink", () => ({ - reportError: (error: unknown, context: Record) => { - reportErrorCalls.push({ error, context }); - return "presence-error"; - }, -})); - -mock.module("@corbits/presence/client", () => ({ - connectPresence: (options: { roomUrl: string }) => { - const handle: FakeHandle = { - roomUrl: options.roomUrl, - disconnected: false, - listener: null, - errorListeners: new Set(), - recoveredListeners: new Set(), - cursorCalls: [], - }; - created.push(handle); - return { - subscribe: (listener: (members: unknown[]) => void) => { - handle.listener = listener; - listener([]); - return () => { - handle.listener = null; - }; - }, - onError: (listener: (error: PresenceError) => void) => { - handle.errorListeners.add(listener); - return () => { - handle.errorListeners.delete(listener); - }; - }, - onRecovered: (listener: () => void) => { - handle.recoveredListeners.add(listener); - return () => { - handle.recoveredListeners.delete(listener); - }; - }, - publishCursor: (cursor: unknown) => handle.cursorCalls.push(cursor), - publishTyping: () => undefined, - disconnect: () => { - handle.disconnected = true; - }, - }; - }, -})); - -const { usePresenceRoom } = await import("../src/presence/use-presence-room"); - -afterEach(() => { - created.length = 0; - reportErrorCalls.length = 0; -}); - -afterAll(() => { - mock.restore(); -}); - -function fireError(error: PresenceError): void { - const handle = created[created.length - 1]; - if (handle === undefined) throw new Error("no presence handle"); - act(() => { - for (const listener of handle.errorListeners) listener(error); - }); -} - -function fireRecovered(): void { - const handle = created[created.length - 1]; - if (handle === undefined) throw new Error("no presence handle"); - act(() => { - for (const listener of handle.recoveredListeners) listener(); - }); -} - -function mountHook( - initialTenantId: string | null, - initialSurface: string | null, - options?: UsePresenceRoomOptions, -) { - const container = document.createElement("div"); - document.body.appendChild(container); - const root: Root = createRoot(container); - let latest: ReturnType | null = null; - let setArgs: ( - tenantId: string | null, - surface: string | null, - ) => void = () => {}; - - function Host({ - tenantId, - surface, - }: { - tenantId: string | null; - surface: string | null; - }) { - latest = usePresenceRoom(tenantId, surface, undefined, options); - return null; - } - - function Wrapper() { - const argsRef = useRef({ - tenantId: initialTenantId, - surface: initialSurface, - }); - setArgs = (tenantId, surface) => { - argsRef.current = { tenantId, surface }; - act(() => root.render(createElement(Host, argsRef.current))); - }; - return createElement(Host, argsRef.current); - } - - act(() => { - root.render(createElement(Wrapper)); - }); - - return { - setArgs, - result: () => latest, - unmount: () => act(() => root.unmount()), - }; -} - -describe("usePresenceRoom", () => { - test("does not connect when tenantId or surface is null", () => { - const harness = mountHook(null, "workbench:chn_1"); - expect(created).toHaveLength(0); - harness.unmount(); - }); - - test("connects once both tenantId and surface are present", () => { - const harness = mountHook("tnt_1", "workbench:chn_1"); - expect(created).toHaveLength(1); - expect(created[0]?.roomUrl).toBe( - "/api/tenants/tnt_1/presence/rooms/workbench:chn_1", - ); - harness.unmount(); - }); - - test("disconnects the old room and connects a new one when the surface changes", () => { - const harness = mountHook("tnt_1", "workbench:chn_1"); - harness.setArgs("tnt_1", "workbench:chn_2"); - - expect(created).toHaveLength(2); - expect(created[0]?.disconnected).toBe(true); - expect(created[1]?.roomUrl).toBe( - "/api/tenants/tnt_1/presence/rooms/workbench:chn_2", - ); - harness.unmount(); - }); - - test("publishCursor forwards to the connected handle", () => { - const harness = mountHook("tnt_1", "workbench:chn_1"); - harness.result()?.publishCursor(0.5, 0.5, 3); - - expect(created[0]?.cursorCalls).toEqual([ - { x: 0.5, y: 0.5, surfaceVersion: 3 }, - ]); - harness.unmount(); - }); - - test("disconnects on unmount", () => { - const harness = mountHook("tnt_1", "workbench:chn_1"); - harness.unmount(); - expect(created[0]?.disconnected).toBe(true); - }); - - test("stays ok through a single transient error", () => { - const harness = mountHook("tnt_1", "artifact:art_1", { - principalId: "prn_1", - }); - fireError({ operation: "join", status: 500 }); - - expect(harness.result()?.connection).toBe("ok"); - expect(reportErrorCalls).toHaveLength(1); - harness.unmount(); - }); - - test("marks the connection degraded after a second consecutive error", () => { - const harness = mountHook("tnt_1", "artifact:art_1", { - principalId: "prn_1", - }); - fireError({ operation: "join", status: 500 }); - fireError({ operation: "join" }); - - expect(harness.result()?.connection).toBe("degraded"); - expect(reportErrorCalls).toHaveLength(2); - expect(reportErrorCalls[1]?.context).toEqual({ - operation: "presence.join", - tenantId: "tnt_1", - roomId: "artifact:art_1", - extra: { principalId: "prn_1" }, - }); - harness.unmount(); - }); - - test("reports every error with room and principal context", () => { - const harness = mountHook("tnt_1", "artifact:art_1", { - principalId: "prn_1", - }); - fireError({ operation: "heartbeat", status: 404 }); - - expect(reportErrorCalls).toHaveLength(1); - const call = reportErrorCalls[0]; - expect(call?.error).toBeInstanceOf(Error); - expect((call?.error as Error).message).toBe( - "Presence heartbeat failed (404)", - ); - expect(call?.context).toEqual({ - operation: "presence.heartbeat", - tenantId: "tnt_1", - roomId: "artifact:art_1", - extra: { principalId: "prn_1", status: 404 }, - }); - harness.unmount(); - }); - - test("clears degraded state on successful rejoin", () => { - const harness = mountHook("tnt_1", "artifact:art_1", { - principalId: "prn_1", - }); - fireError({ operation: "join", status: 500 }); - fireError({ operation: "join", status: 500 }); - expect(harness.result()?.connection).toBe("degraded"); - - fireRecovered(); - expect(harness.result()?.connection).toBe("ok"); - harness.unmount(); - }); -}); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 30f169bfe8..5ead14341f 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -16,7 +16,6 @@ function manualChunks(id: string): string | undefined { } if (id.includes("@phosphor-icons/react")) return "icons"; if (id.includes("@tanstack")) return "query-vendor"; - if (id.includes("yjs")) return "collab-vendor"; return undefined; } diff --git a/bun.lock b/bun.lock index e933c940a5..e1b7e7505a 100644 --- a/bun.lock +++ b/bun.lock @@ -69,7 +69,6 @@ "@corbits/morning-brief-workflow": "workspace:*", "@corbits/notify": "workspace:*", "@corbits/pain-point-collateral-workflow": "workspace:*", - "@corbits/presence": "workspace:*", "@corbits/process-granola-call-workflow": "workspace:*", "@corbits/process-provisioner": "workspace:*", "@corbits/reddit-opportunity-scanner-workflow": "workspace:*", @@ -104,7 +103,6 @@ "devDependencies": { "@types/bun": "catalog:", "typescript": "catalog:", - "yjs": "^13.6.32", }, }, "apps/sidecar": { @@ -163,7 +161,6 @@ "@corbits/inbox": "workspace:*", "@corbits/inference-settings": "workspace:*", "@corbits/plugins-ui": "workspace:*", - "@corbits/presence": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", "@corbits/settings-ui": "workspace:*", "@corbits/shell-layout": "workspace:*", @@ -178,7 +175,6 @@ "arktype": "catalog:", "react": "^19.2.0", "react-dom": "^19.2.0", - "yjs": "^13.6.32", }, "devDependencies": { "@happy-dom/global-registrator": "^20.11.2", @@ -342,7 +338,6 @@ "@corbits/url-path": "workspace:*", "arktype": "catalog:", "react": "^19.2.0", - "yjs": "^13.6.32", }, "devDependencies": { "@types/bun": "catalog:", @@ -468,7 +463,6 @@ "@corbits/inference-settings": "workspace:*", "@corbits/jimmy-agent": "workspace:*", "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#bc964040fa42ffe4e5d5004f87edaab6486e5d60", - "@corbits/presence": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", "@tanstack/react-query": "catalog:", "arktype": "catalog:", @@ -1071,22 +1065,6 @@ "typescript": "catalog:", }, }, - "packages/presence": { - "name": "@corbits/presence", - "version": "0.0.1", - "dependencies": { - "@corbits/error-sink": "workspace:*", - "@intx/hub-api": "workspace:*", - "arktype": "catalog:", - "hono": "catalog:", - "y-protocols": "^1.0.7", - "yjs": "^13.6.32", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/process-provisioner": { "name": "@corbits/process-provisioner", "version": "0.0.1", @@ -2190,8 +2168,6 @@ "@corbits/plugins-ui": ["@corbits/plugins-ui@workspace:packages/plugins-ui"], - "@corbits/presence": ["@corbits/presence@workspace:packages/presence"], - "@corbits/process-granola-call-workflow": ["@corbits/process-granola-call-workflow@workspace:workflows/process-granola-call"], "@corbits/process-provisioner": ["@corbits/process-provisioner@workspace:packages/process-provisioner"], @@ -3040,8 +3016,6 @@ "isomorphic-textencoder": ["isomorphic-textencoder@1.0.1", "", { "dependencies": { "fast-text-encoding": "^1.0.0" } }, "sha512-676hESgHullDdHDsj469hr+7t3i/neBKU9J7q1T4RHaWwLAsaQnywC0D1dIUId0YZ+JtVrShzuBk1soo0+GVcQ=="], - "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], - "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], @@ -3074,8 +3048,6 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="], - "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -3552,8 +3524,6 @@ "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], - "y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="], - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], @@ -3562,8 +3532,6 @@ "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], - "yjs": ["yjs@13.6.32", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], @@ -3618,6 +3586,8 @@ "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], + "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], diff --git a/docs/presence.md b/docs/presence.md deleted file mode 100644 index 9b26e40d83..0000000000 --- a/docs/presence.md +++ /dev/null @@ -1,317 +0,0 @@ -# Presence - -Presence is the multiplayer-visual substrate: who's here, where their -cursor is, whether they're typing — for a workbench or a canvas artifact, -right now, with nothing kept once everyone leaves. `packages/presence` -(`@corbits/presence`) owns it end to end; `apps/hub` only mounts its routes, -and `apps/web` only composes its client into the workbench header and the -canvas artifact pane. This document covers what phase 1 built and the seam -phase 2 (co-editing) builds on top of it. - -## Rooms - -A room is keyed by `(tenantId, surface)`, where `surface` is a free-form -string the caller owns — `workbench:` for a workbench's who's-here stack, -`artifact:` for a canvas artifact's co-viewer cursors. Two tenants can -never share a room even if they pick the identical `surface` string: the -tenant id is part of the key, not a namespace prefix a caller could get -wrong. - -Rooms are in-process and ephemeral. `createPresenceRoomRegistry()` -(`packages/presence/src/room-registry.ts`) holds every live room in a -`Map`, keyed off `tenantId`/`surface`, and tears a room down the moment it -has no members and no SSE subscribers left — there is no persistence layer -to clean up, and a process restart simply loses presence, which is correct -for "who's here right now." - -**This assumes exactly one hub replica.** Every room lives in one -process's memory; there is no cross-replica broadcast, no shared pub/sub, -no external store a second replica could read the same room from. For -phase 1's plain awareness this is a low-stakes assumption — split traffic -across two replicas and the worst case is a "who's here" list that's -missing someone until they reconnect and land on the right replica. -**Phase 2's doc content makes the same assumption load-bearing in a way -that fails silently**: two hub replicas serving the same -`(tenantId, surface)` room would each hold an independent, diverging -`Y.Doc` with no way to ever reconcile — two editors landing on different -replicas would each see their own edits and never the other's, with no -error, no warning, just quietly diverging documents that both look -correct locally. Multi-replica support (a shared broadcast layer so every -replica's registry applies the same updates, or moving room state out of -process memory entirely) is a known phase-3 item, not something this -phase's design pretends to solve — running more than one hub replica -today is only safe for presence's awareness-only surfaces, never for a -doc-carrying (`artifact:`) one. - -## Why Yjs, given the wire format is JSON - -Each room keeps one `y-protocols` `Awareness` instance (backed by a -throwaway `Y.Doc`) as its state store, even though the HTTP contract -between browser and server is plain JSON, parsed with arktype -(`packages/presence/src/schema.ts`) — never a raw Yjs binary update. That's -deliberate: `Awareness` already gives every entry a clock and a -last-updated timestamp, and the registry's `join`/`heartbeat`/`leave` calls -write into it directly (see `writeAwarenessState` in `room-registry.ts`) -rather than requiring browsers to speak the binary awareness protocol -Yjs's own peer-to-peer providers use. Phase 1 needs none of Yjs's document -merge machinery — only its awareness bookkeeping — so the server is the -sole author of every client's state, and the JSON boundary stays exactly as -readable as `@corbits/chat`'s existing SSE payloads. - -## The HTTP surface - -Mounted by `apps/hub/src/index.ts` at `${TENANT_PREFIX}/presence`, inside -the hub's native tenant middleware — the same pattern `@corbits/chat`'s -routes use, and no new auth path: a handler always finds `c.get("tenant")` -and `c.get("principal")` already resolved. - -- `POST /rooms/:surface/join` — join with an optional `displayName`, - `cursor`, `typing`. The server assigns `principalId` (from the session) - and `color` (deterministic, see below); a client can never claim someone - else's identity or hand-pick a color. -- `POST /rooms/:surface/heartbeat` — keep-alive, with an optional - `cursor`/`typing` patch on top of the existing state. `404` if the - caller never joined (or was already dropped by a timeout) — the client's - signal to rejoin, not a silent no-op. -- `POST /rooms/:surface/leave` — explicit leave. -- `GET /rooms/:surface/stream` — an SSE stream of `presence.state` events, - each carrying the room's full member snapshot as JSON. Mirrors - `@corbits/chat`'s `bridgeWorkbenchStream`: a failed write unsubscribes - immediately rather than waiting for `onAbort`. - -There is no background sweep timer. Every join/heartbeat/leave call -opportunistically sweeps its own room for stale members first -(`registry.sweepStale`), so a client that goes quiet is caught within one -timeout window of the next request anyone makes to that room — the -heartbeat protocol itself guarantees that traffic exists. - -## Deterministic color - -`colorForPrincipal` (`packages/presence/src/color.ts`) hashes a principal -id to an HSL hue, nudging it away from the brand's orange accent band so a -presence dot never reads as chrome. Pure function, no storage: every -process computes the same color for the same principal without -coordination. - -## The browser client - -`@corbits/presence/client` (`packages/presence/src/client.ts`) is the one -browser-safe module in the package: no React dependency, a plain -subscribe/callback API (`connectPresence`), fetch/EventSource -dependency-injectable for testing. `apps/web/src/presence/use-presence-room.ts` -is the thin React hook that wraps it — the only place apps/web talks to the -presence package directly. Two composition sites use it today: - -- `apps/web/src/pages/chat-page.tsx` used to connect to `workbench:` - and hand the snapshot to `@corbits/chat-ui`'s `ChatWorkspace` as - `presenceMembers`. That roster now rides the workbench `/stream` - (`chat.presence` / `chat.presence.snapshot`, CL-6328); `@corbits/presence` - no longer backs the header stack. -- `apps/web/src/shell/app-shell.tsx` connects to `artifact:` - when the canvas has one open, publishes the pointer's fractional position - over the artifact pane as `cursor`, and hands the room's snapshot to - `CanvasColumn` as `presenceCursors` — colored, labeled dots overlaid on - the artifact renderer. - -The SSE stream opens and stays open regardless of whether join or a later -heartbeat actually succeeded, so a healthy-looking cursor overlay is not -proof the client is in the room. `PresenceHandle.onError` reports every -failed join/heartbeat/leave/update; `onRecovered` fires on a successful -join or heartbeat. `usePresenceRoom` subscribes to both: errors go through -`reportError` with tenant, room (`surface`), and principal context, and the -canvas pane shows a quiet "Reconnecting…" caption only after a second -consecutive failure. A single transient retry stays silent; a successful -rejoin clears the caption. - -Neither `@corbits/chat-ui` nor `apps/web/src/shell/canvas-column.tsx` -imports `@corbits/presence` itself — both only take plain data -(`PresenceMember`, `PresenceCursor`, and a `PresenceConnection` flag) as -props, the same way they take any other host-supplied data. - -## Phase 2: co-editing - -Phase 1 stopped at awareness — no shared document content, no version -history. Phase 2 adds real convergent editing on top of the same rooms, -without changing anything phase 1 already shipped: tenant isolation, the -join/leave/heartbeat surface, and the color assignment scheme all carry -over unchanged. - -### Doc sync - -Each room's `Y.Doc` (`room-registry.ts`) — which phase 1 only ever used to -back its `Awareness` instance — now carries real content in a -`Y.Text` field named `content`. The registry adds: - -- `applyDocUpdate(key, update, authorPrincipalId)` — applies a decoded Yjs - update to the room's doc and fans it out to that room's SSE subscribers - and to a registry-wide `onDocChange` hook (persistence's trigger). -- `docStateAsUpdate(key)` — the room's full doc state encoded as one Yjs - update, for a new joiner to catch up. -- `seedDocText`/`docText` — seeding and reading the `Y.Text` content - directly, used by persistence's join-time seed and snapshot read. -- `onEmpty`/`notifySnapshot`/`subscribeSnapshots` — the hooks persistence - (below) uses to flush on last-client-leave and to announce a confirmed - write back out over SSE. - -(Applies in one process's memory only — see the single-hub-replica -assumption under Rooms, above; phase 2's doc content is exactly where that -assumption stops being low-stakes.) - -The wire format stays plain JSON, as phase 1's own doc already argued for: -a Yjs update is base64-encoded inside a JSON body/event -(`base64.ts` — a small hand-rolled codec, not `Buffer`/`btoa`, since this -module is shared between the Node/Bun server and the browser client). -`POST /rooms/:surface/update` accepts `{ update: string }`, arktype-checked -for shape, then size-capped at `MAX_DOC_UPDATE_BYTES` (256 KiB decoded) — -checked twice, deliberately: first as a bound on the base64 STRING's -length (`maxBase64LengthFor`, the exact ceiling that could possibly decode -to the byte limit), rejecting an oversize payload before it's ever -decoded, then again on the actual decoded byte count as a belt-and-suspenders -check once decoding has happened. Comfortably above a large paste, well -below "megabyte update" territory. A malformed update (bad base64, or -valid base64 that isn't a real Yjs update) is a 400, never silently -dropped or partially applied. `POST /rooms/:surface/join`'s response grew -a `docUpdate` field — the room's current full state, so a late joiner -converges immediately instead of starting from an empty doc. The SSE -stream carries two new event types alongside `presence.state`: -`doc.update` (every applied update, relayed to every other subscriber) and -`doc.saved` (see Persistence, below). - -**Every doc-carrying surface is grant-gated on read as well as write.** -`CreatePresenceRoutesDeps.requireGrant` is REQUIRED, not optional — a room's -join response and its SSE stream both carry the room's actual document -text, so a principal Library's own read route would 403 must be refused -here too, not just on the write path. `POST /rooms/:surface/join` and -`GET /rooms/:surface/stream` check `("asset:*", "read")`, and -`POST /rooms/:surface/update` checks `("asset:*", "write")`, the same -grants Library's own artifact routes check — but only for a surface -`isDocCarryingSurface` (default: the `artifact:` convention -`artifact-persistence.ts` owns) says actually carries doc content. A -presence-only surface (a workbench's who's-here stack, never -`artifact:...`) stays exactly as ungated on all four routes as phase 1 -left it: waving a cursor was never a write or a read of anything sensitive, -and gating it would require an unrelated grant a workbench viewer has no -reason to hold. `apps/hub/src/index.ts` wires the required `requireGrant` -with the same `chatGrantStore`/`chatConditionRegistry` every other -extension route uses. - -### Persistence - -`artifact-persistence.ts`'s `createArtifactDocPersistence` is a new, -separate module layered on top of the registry — the registry itself still -never touches storage, matching phase 1's explicit intent. It is -constructed with two injected ports (`loadArtifactContent`, -`writeArtifactSnapshot`) so the package never imports `@corbits/artifacts` -directly; `apps/hub/src/index.ts`'s composition root wires those ports to -the engine's own `getArtifact`/`writeArtifactVersion` — the same -versioned-row seam a workflow's own artifact revision goes through, so a -co-edited document's history reads identically to any other revision. - -- **Convention**: a surface of the form `artifact:` is a - canvas artifact; `artifactIdForSurface` is the one place that convention - lives. Any other surface (e.g. `workbench:`) is invisible to - persistence — `onDocChange`/`onEmpty` fire for it same as any room, but - `artifactIdForSurface` returns `null` and nothing is scheduled. -- **Seed on join**: `seedOnJoin`, called from the join route's `onJoin` - hook, loads the artifact's stored content into a still-empty room's - `Y.Text` — `registry.seedDocText` itself refuses to overwrite non-empty - content, so a race against a real edit can never lose data. -- **Debounce**: every doc change reschedules a per-room timer; a write - only happens after `debounceMs` (default 2000ms, never lower — "never - persist mid-typing chaos" is a product requirement, not a tuning knob) - of quiet. -- **Flush on empty**: `registry.onEmpty` triggers an immediate flush, - bypassing the debounce — the last person leaving a room is a natural - save point, not something worth waiting 2 more seconds for. The doc's - content is read synchronously at the moment of flushing (not deferred), - since `onEmpty` fires just before the registry tears the room's doc - down — reading it a tick later would silently "snapshot" an empty - string instead of the room's real last content. -- **Write serialization**: every room's writes are chained onto a - per-room promise (`enqueueSnapshot`), never left to race independently. - Without this, a slow write and a faster later write (their own debounce - windows can overlap when edits keep landing) could resolve out of - order, and the slower-but-earlier write's `notifySnapshot` would fire - _after_ the faster-but-later one already had — a "Saved v1" regression - rendered right after "Saved v2." Chaining guarantees writes execute, - and therefore notify, strictly in the order they were scheduled. -- **Saved notification**: a successful write calls - `registry.notifySnapshot(key, { version, savedAt })`, which the SSE - route relays as a `doc.saved` event. This is the only source the UI - trusts for "Saved · v12" — the client cannot otherwise know a debounced - server-side write landed, and never claims one did without it. -- A write failure (e.g. the artifact was deleted, or archived, mid-edit) - reports through `onSnapshotError` rather than throwing into the - registry's synchronous event dispatch; `apps/hub` logs it. - -Text artifacts only: the `Y.Doc` holds one `Y.Text` (`content`) mirroring -the artifact's `content` column verbatim. Non-text kinds never get a doc -at all — `artifact-text-editor.tsx` (below) only ever mounts for a "doc" -`rendererKind`. - -### UI: `@corbits/artifact-ui`'s `ArtifactTextEditor` - -A plain `