From bba5ffbbf8e38bc8c94355b0000caae8f89415c1 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 11:45:17 -0700 Subject: [PATCH 1/3] Add tests for single-user artifact save; delete presence/co-edit tests (CL-8189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presence (packages/presence) is removed from Workbench and preserved upstream at corbitsdev/corbits-presence. Deleted tests: every test that exercised @corbits/presence, Y.Doc co-editing, or the cursor overlay — they encode behavior that no longer exists, not behavior to keep passing. --- .../hub/test/artifact-doc-persistence.test.ts | 170 ------------ apps/hub/test/presence-mount.test.ts | 67 ----- .../web/test/artifact-cursor-overlay.test.tsx | 160 ----------- apps/web/test/canvas-column.test.tsx | 91 ++----- apps/web/test/use-presence-room.test.tsx | 257 ------------------ packages/artifact-ui/src/save-state.test.ts | 18 +- packages/artifact-ui/src/y-text-diff.test.ts | 171 ------------ .../test/artifact-text-editor.test.tsx | 78 ------ packages/artifacts-hub/src/routes.test.ts | 51 ++++ 9 files changed, 70 insertions(+), 993 deletions(-) delete mode 100644 apps/hub/test/artifact-doc-persistence.test.ts delete mode 100644 apps/hub/test/presence-mount.test.ts delete mode 100644 apps/web/test/artifact-cursor-overlay.test.tsx delete mode 100644 apps/web/test/use-presence-room.test.tsx delete mode 100644 packages/artifact-ui/src/y-text-diff.test.ts delete mode 100644 packages/artifact-ui/test/artifact-text-editor.test.tsx 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/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/packages/artifact-ui/src/save-state.test.ts b/packages/artifact-ui/src/save-state.test.ts index 177f479ecd..8dedd7761c 100644 --- a/packages/artifact-ui/src/save-state.test.ts +++ b/packages/artifact-ui/src/save-state.test.ts @@ -24,22 +24,8 @@ describe("formatSaveStateLine", () => { expect(formatSaveStateLine({ kind: "read-only" }, 0)).toBe(""); }); - test("editing with no named co-editors is a generic ellipsis", () => { - expect(formatSaveStateLine({ kind: "editing", by: [] }, 0)).toBe( - "Editing…", - ); - }); - - test("editing names a single co-editor", () => { - expect(formatSaveStateLine({ kind: "editing", by: ["Priya"] }, 0)).toBe( - "Priya is editing…", - ); - }); - - test("editing with multiple co-editors gives a count, never a fabricated list", () => { - expect( - formatSaveStateLine({ kind: "editing", by: ["Priya", "Sam"] }, 0), - ).toBe("2 people are editing…"); + test("saving reads a plain in-flight ellipsis", () => { + expect(formatSaveStateLine({ kind: "saving" }, 0)).toBe("Saving…"); }); test("saved combines the relative-time label with the version, never claiming a version that wasn't confirmed", () => { diff --git a/packages/artifact-ui/src/y-text-diff.test.ts b/packages/artifact-ui/src/y-text-diff.test.ts deleted file mode 100644 index 5e59921857..0000000000 --- a/packages/artifact-ui/src/y-text-diff.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import * as Y from "yjs"; -import { applyTextDiffToYText, diffText } from "./y-text-diff"; - -describe("diffText", () => { - test("a pure append", () => { - expect(diffText("hello", "hello world")).toEqual({ - index: 5, - deleteCount: 0, - insertText: " world", - }); - }); - - test("a pure prepend", () => { - expect(diffText("world", "hello world")).toEqual({ - index: 0, - deleteCount: 0, - insertText: "hello ", - }); - }); - - test("a deletion in the middle", () => { - expect(diffText("hello brave world", "hello world")).toEqual({ - index: 6, - deleteCount: 6, - insertText: "", - }); - }); - - test("a replacement in the middle", () => { - expect(diffText("hello brave world", "hello cruel world")).toEqual({ - index: 6, - deleteCount: 5, - insertText: "cruel", - }); - }); - - test("no change", () => { - expect(diffText("same", "same")).toEqual({ - index: 4, - deleteCount: 0, - insertText: "", - }); - }); - - test("a full replacement (nothing shared)", () => { - expect(diffText("abc", "xyz")).toEqual({ - index: 0, - deleteCount: 3, - insertText: "xyz", - }); - }); - - test("emptying the field entirely", () => { - expect(diffText("gone", "")).toEqual({ - index: 0, - deleteCount: 4, - insertText: "", - }); - }); - - test("typing into an empty field", () => { - expect(diffText("", "new")).toEqual({ - index: 0, - deleteCount: 0, - insertText: "new", - }); - }); - - test("repeated characters don't confuse the prefix/suffix scan", () => { - // "aaaa" -> "aaaaa": every position matches on both ends, the diff - // must still land on a single well-formed insert, not double-count - // the shared run as both prefix and suffix. - const op = diffText("aaaa", "aaaaa"); - expect(op.deleteCount).toBe(0); - expect(op.insertText).toBe("a"); - const rebuilt = - "aaaa".slice(0, op.index) + op.insertText + "aaaa".slice(op.index); - expect(rebuilt).toBe("aaaaa"); - }); -}); - -describe("applyTextDiffToYText", () => { - test("applying a diff to a Y.Text produces the target string", () => { - const doc = new Y.Doc(); - const yText = doc.getText("content"); - yText.insert(0, "hello brave world"); - - applyTextDiffToYText(yText, "hello cruel world"); - - expect(yText.toString()).toBe("hello cruel world"); - }); - - test("two Y.Docs converge after each applies the other's diff-derived update", () => { - const alice = new Y.Doc(); - const bob = new Y.Doc(); - const aliceText = alice.getText("content"); - const bobText = bob.getText("content"); - aliceText.insert(0, "shared start"); - Y.applyUpdate(bob, Y.encodeStateAsUpdate(alice)); - - applyTextDiffToYText(aliceText, "shared start + alice"); - applyTextDiffToYText(bobText, "bob + shared start"); - - Y.applyUpdate(bob, Y.encodeStateAsUpdate(alice)); - Y.applyUpdate(alice, Y.encodeStateAsUpdate(bob)); - - expect(aliceText.toString()).toBe(bobText.toString()); - expect(aliceText.toString()).toContain("alice"); - expect(aliceText.toString()).toContain("bob"); - }); - - test("a no-op diff never calls delete/insert", () => { - const doc = new Y.Doc(); - const yText = doc.getText("content"); - yText.insert(0, "unchanged"); - let changed = false; - yText.observe(() => { - changed = true; - }); - - applyTextDiffToYText(yText, "unchanged"); - - expect(changed).toBe(false); - }); - - // Promoted from the reviewer's - // tmp/critique-tests/stale-diff-corruption.test.ts repro (a diff - // computed against a caller-captured `before` corrupted the doc — - // "XX hello wo abcrld" — once a remote update mutated `yText` between - // the caller reading `before` and the diff actually being applied). - // Inverted here to assert the fix: `applyTextDiffToYText` reads - // `yText`'s own live content at apply time, so a race can never - // corrupt it — it always converges exactly to `after`. - test("a concurrent remote update racing in before apply never corrupts the doc", () => { - const doc = new Y.Doc(); - const yText = doc.getText("content"); - yText.insert(0, "hello world"); - - // What the user intended: append " abc" onto the "hello world" they - // last saw. - const localAfter = "hello world abc"; - - // A remote peer's update lands on this doc AFTER the user's edit was - // conceptually formed but BEFORE `applyTextDiffToYText` runs — the - // exact race a stale `before` baseline can't survive. - const remoteDoc = new Y.Doc(); - Y.applyUpdate(remoteDoc, Y.encodeStateAsUpdate(doc)); - remoteDoc.getText("content").insert(0, "XX "); - Y.applyUpdate(doc, Y.encodeStateAsUpdate(remoteDoc), "presence-remote"); - expect(yText.toString()).toBe("XX hello world"); - - applyTextDiffToYText(yText, localAfter); - - expect(yText.toString()).toBe(localAfter); - }); - - test("a local edit is applied atomically: an observer never sees a half-applied delete-without-insert", () => { - const doc = new Y.Doc(); - const yText = doc.getText("content"); - yText.insert(0, "hello brave world"); - const observedStates: string[] = []; - yText.observe(() => observedStates.push(yText.toString())); - - applyTextDiffToYText(yText, "hello cruel world"); - - // Exactly one observed change — delete+insert landed as one - // transaction, not two separately-observable steps. - expect(observedStates).toEqual(["hello cruel world"]); - }); -}); diff --git a/packages/artifact-ui/test/artifact-text-editor.test.tsx b/packages/artifact-ui/test/artifact-text-editor.test.tsx deleted file mode 100644 index d4c6471588..0000000000 --- a/packages/artifact-ui/test/artifact-text-editor.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -// Static-render smoke tests: `ArtifactTextEditor` renders the doc's live -// text and the honest save-state line for the three states a viewer can -// land on — read-only, mid-edit, and confirmed-saved. Interactive -// behavior (typing diffing into the Y.Text, remote updates re-rendering -// the textarea) is covered by `y-text-diff.test.ts`'s pure diff/apply -// tests plus this component's own thin `onChange` wiring, which -// `renderToStaticMarkup` can't exercise (no event loop) — this file only -// proves the initial render is honest for each state. -import { describe, expect, test } from "bun:test"; -import { renderToStaticMarkup } from "react-dom/server"; -import * as Y from "yjs"; - -import { ArtifactTextEditor } from "../src/artifact-text-editor"; - -function docWithText(text: string): Y.Doc { - const doc = new Y.Doc(); - doc.getText("content").insert(0, text); - return doc; -} - -describe("ArtifactTextEditor", () => { - test("renders the doc's current text and marks a read-only viewer's textarea readonly", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("shared content"); - expect(markup).toContain('readOnly=""'); - expect(markup).toContain('aria-readonly="true"'); - }); - - test("an editable viewer's textarea is not marked readonly", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).not.toContain('readOnly=""'); - expect(markup).toContain('aria-readonly="false"'); - expect(markup).toContain("Unsaved changes"); - }); - - test("renders an editing indicator naming the co-editor", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("Priya is editing"); - }); - - test("renders a confirmed save with its real version number, never a placeholder", () => { - const now = Date.now(); - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("v12"); - }); -}); diff --git a/packages/artifacts-hub/src/routes.test.ts b/packages/artifacts-hub/src/routes.test.ts index 18ebbfce17..7f9f1e68cc 100644 --- a/packages/artifacts-hub/src/routes.test.ts +++ b/packages/artifacts-hub/src/routes.test.ts @@ -119,6 +119,15 @@ function memoryStore(): ArtifactRoutesStore & { rows: Row[] } { if (row.mimeType !== "text/html") return { status: "unsupported" }; return { status: "ok", html: row.content }; }, + async update(tenantId, _principalId, artifactId, content) { + const row = rows.find( + (r) => r.id === artifactId && r._tenantId === tenantId, + ); + if (row === undefined) return null; + row.content = content; + row.version += 1; + return stripTenant(row); + }, }; } @@ -190,6 +199,39 @@ describe("artifact routes", () => { expect(body.content).toBe("hello"); }); + test("PUT /:id saves a new version and returns the updated row", async () => { + store.rows.push(sampleRow("a1", TENANT.id, "hello")); + const res = await app.request("/artifacts/a1", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "edited" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { content: string; version: number }; + expect(body.content).toBe("edited"); + expect(body.version).toBe(2); + }); + + test("PUT /:id returns 404 for a foreign tenant row", async () => { + store.rows.push(sampleRow("b1", OTHER.id, "secret")); + const res = await app.request("/artifacts/b1", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "edited" }), + }); + expect(res.status).toBe(404); + }); + + test("PUT /:id returns 400 without a string content field", async () => { + store.rows.push(sampleRow("a1", TENANT.id, "hello")); + const res = await app.request("/artifacts/a1", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + test("GET /:id/preview serves HTML with a strict sandbox CSP", async () => { const row = sampleRow("html1", TENANT.id, "hi"); row.mimeType = "text/html"; @@ -349,6 +391,9 @@ describe("GET /counts pagination", () => { async preview() { return { status: "not_found" }; }, + async update() { + return null; + }, }; } @@ -404,6 +449,9 @@ describe("GET /counts pagination", () => { async preview() { return { status: "not_found" }; }, + async update() { + return null; + }, }; const app = mount(neverEndingStore); @@ -428,6 +476,9 @@ describe("GET /counts pagination", () => { async preview() { return { status: "not_found" }; }, + async update() { + return null; + }, }; const app = mount(stuckCursorStore); From ee81ccd49e175d3b03e4c051f6a02bc3d315c87f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 11:45:26 -0700 Subject: [PATCH 2/3] feat!: remove presence; artifact editing is single-user (CL-8189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presence (who's-here, cursors, typing, Y.Doc co-editing) is dropped from Workbench entirely for now; the library is preserved at corbitsdev/corbits-presence for whenever it returns. It was only used by the artifact co-edit surface. The artifact editor becomes a plain controlled textarea, debounced-saved through a new PUT /api/tenants/:id/artifacts/:artifactId route (added to packages/artifacts-hub, using @corbits/artifacts' existing writeArtifactVersion) — the only save path before this was presence's Y.Doc snapshot persistence, which never had an HTTP equivalent. Removes the hub's presence room registry/routes mount, apps/web's presence client and cursor overlay, the yjs/y-protocols dependency, and @corbits/presence from every consuming package.json. --- apps/hub/package.json | 4 +- apps/hub/src/index.ts | 17 - apps/web/package.json | 4 +- apps/web/src/presence/use-presence-room.ts | 160 ---- apps/web/src/shell/app-shell.tsx | 94 +-- apps/web/src/shell/canvas-column.tsx | 161 +--- apps/web/src/shell/library-artifacts.ts | 38 + apps/web/vite.config.ts | 1 - bun.lock | 34 +- packages/artifact-ui/package.json | 3 +- .../artifact-ui/src/artifact-text-editor.tsx | 94 +-- packages/artifact-ui/src/index.ts | 2 - packages/artifact-ui/src/save-state.ts | 21 +- packages/artifact-ui/src/y-text-diff.ts | 87 -- packages/artifacts-hub/src/routes.ts | 60 ++ packages/chat-ui/package.json | 1 - packages/presence/LICENSE | 176 ---- packages/presence/README.md | 57 -- packages/presence/package.json | 29 - .../presence/src/artifact-persistence.test.ts | 379 --------- packages/presence/src/artifact-persistence.ts | 237 ------ packages/presence/src/base64.test.ts | 24 - packages/presence/src/base64.ts | 54 -- packages/presence/src/client.test.ts | 758 ----------------- packages/presence/src/client.ts | 495 ----------- packages/presence/src/color.test.ts | 26 - packages/presence/src/color.ts | 32 - packages/presence/src/index.ts | 43 - packages/presence/src/room-registry.test.ts | 470 ----------- packages/presence/src/room-registry.ts | 484 ----------- packages/presence/src/routes.ts | 312 ------- packages/presence/src/schema.ts | 60 -- packages/presence/src/sse-stream.test.ts | 186 ----- packages/presence/src/sse-stream.ts | 147 ---- packages/presence/test/routes.test.ts | 786 ------------------ packages/presence/test/test-support.ts | 62 -- packages/presence/tsconfig.json | 23 - packages/presence/tsconfig.src.json | 20 - scripts/checks/browser-safe-subpaths.ts | 6 - 39 files changed, 203 insertions(+), 5444 deletions(-) delete mode 100644 apps/web/src/presence/use-presence-room.ts delete mode 100644 packages/artifact-ui/src/y-text-diff.ts delete mode 100644 packages/presence/LICENSE delete mode 100644 packages/presence/README.md delete mode 100644 packages/presence/package.json delete mode 100644 packages/presence/src/artifact-persistence.test.ts delete mode 100644 packages/presence/src/artifact-persistence.ts delete mode 100644 packages/presence/src/base64.test.ts delete mode 100644 packages/presence/src/base64.ts delete mode 100644 packages/presence/src/client.test.ts delete mode 100644 packages/presence/src/client.ts delete mode 100644 packages/presence/src/color.test.ts delete mode 100644 packages/presence/src/color.ts delete mode 100644 packages/presence/src/index.ts delete mode 100644 packages/presence/src/room-registry.test.ts delete mode 100644 packages/presence/src/room-registry.ts delete mode 100644 packages/presence/src/routes.ts delete mode 100644 packages/presence/src/schema.ts delete mode 100644 packages/presence/src/sse-stream.test.ts delete mode 100644 packages/presence/src/sse-stream.ts delete mode 100644 packages/presence/test/routes.test.ts delete mode 100644 packages/presence/test/test-support.ts delete mode 100644 packages/presence/tsconfig.json delete mode 100644 packages/presence/tsconfig.src.json 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/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/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/packages/artifact-ui/package.json b/packages/artifact-ui/package.json index 017c5aaab0..42322ccac1 100644 --- a/packages/artifact-ui/package.json +++ b/packages/artifact-ui/package.json @@ -20,8 +20,7 @@ "arktype": "catalog:", "@corbits/icons": "workspace:*", "@corbits/url-path": "workspace:*", - "react": "^19.2.0", - "yjs": "^13.6.32" + "react": "^19.2.0" }, "devDependencies": { "@types/bun": "catalog:", diff --git a/packages/artifact-ui/src/artifact-text-editor.tsx b/packages/artifact-ui/src/artifact-text-editor.tsx index e7df0013ff..89b8527e40 100644 --- a/packages/artifact-ui/src/artifact-text-editor.tsx +++ b/packages/artifact-ui/src/artifact-text-editor.tsx @@ -1,78 +1,57 @@ -// The editable half of a text-kind artifact's canvas pane (CL-5958 phase -// 2), built on top of `ArtifactRenderer`'s read-only phase-1 surface -// rather than replacing it: `ArtifactRenderer` still owns every other -// kind, and a viewer with no write access still renders through it -// read-only. This component only ever mounts for a "doc"-kind artifact -// once the host has already decided the viewer can edit — it never makes -// that call itself. +// The editable half of a text-kind artifact's canvas pane. Built on top of +// `ArtifactRenderer`'s read-only surface rather than replacing it: +// `ArtifactRenderer` still owns every other kind, and a viewer with no +// write access still renders through it read-only. This component only +// ever mounts for a "doc"-kind artifact once the host has already decided +// the viewer can edit — it never makes that call itself. // -// The `Y.Doc` is a shared, already-synced document the host connects -// over `@corbits/presence/client` (a plain textarea bound to `Y.Text` via -// `y-text-diff.ts`'s diff-and-replay, not a rich-text editor dependency) -// — this component never imports `@corbits/presence` itself, the same -// "take plain data as props" boundary `canvas-column.tsx` already holds -// with `PresenceCursor`. +// CL-8189: co-edit presence (`@corbits/presence`, Y.Doc) is gone — this is +// a plain controlled textarea, debounced-saved through `onSave` (the host +// wires that to the artifacts HTTP route's PUT). Single-user editing only; +// no live co-viewer cursors, no shared doc, no awareness. That capability +// returns, if it does, on top of the preserved `@corbits/presence` +// library, not this component. import { useEffect, useRef, useState } from "react"; -import type * as Y from "yjs"; -import { applyTextDiffToYText } from "./y-text-diff"; import { formatSaveStateLine, type ArtifactSaveState } from "./save-state"; -const TEXT_FIELD = "content"; -const TYPING_IDLE_MS = 2_000; +const SAVE_DEBOUNCE_MS = 1_000; export interface ArtifactTextEditorProps { - /** The room's shared doc — already synced via `@corbits/presence/client`'s `doc` option. */ - readonly doc: Y.Doc; + readonly content: string; readonly title: string; - /** No write access: renders the live-updating text but ignores keystrokes. */ + /** No write access: renders the text but ignores keystrokes. */ readonly readOnly: boolean; readonly saveState: ArtifactSaveState; - /** Fired on typing start/stop (debounced at `TYPING_IDLE_MS`) — the host publishes this through presence's existing `typing` awareness field. */ - readonly onLocalTyping?: (typing: boolean) => void; + /** Fired at most once per `SAVE_DEBOUNCE_MS` of typing idle, with the + * full current text — the host's one seam to the artifacts PUT route. */ + readonly onSave: (content: string) => void; } +/** + * The host must remount this component (`key={artifact.id}`) when the open + * artifact changes — the same convention `ArtifactRenderer` doesn't need + * because it's stateless, but this component's local `value` buffer is not: + * without a remount, switching artifacts while one has unsaved keystrokes + * would carry them onto the new artifact's content. + */ export function ArtifactTextEditor({ - doc, + content, title, readOnly, saveState, - onLocalTyping, + onSave, }: ArtifactTextEditorProps) { - const yText = doc.getText(TEXT_FIELD); - const [value, setValue] = useState(() => yText.toString()); - const typingTimerRef = useRef | null>(null); - const isTypingRef = useRef(false); - - useEffect(() => { - const syncFromDoc = () => { - const next = yText.toString(); - setValue((current) => (current === next ? current : next)); - }; - syncFromDoc(); - yText.observe(syncFromDoc); - return () => yText.unobserve(syncFromDoc); - }, [yText]); + const [value, setValue] = useState(content); + const saveTimerRef = useRef | null>(null); useEffect( () => () => { - if (typingTimerRef.current !== null) clearTimeout(typingTimerRef.current); + if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current); }, [], ); - const markTyping = () => { - if (!isTypingRef.current) { - isTypingRef.current = true; - onLocalTyping?.(true); - } - if (typingTimerRef.current !== null) clearTimeout(typingTimerRef.current); - typingTimerRef.current = setTimeout(() => { - isTypingRef.current = false; - onLocalTyping?.(false); - }, TYPING_IDLE_MS); - }; - return (