From e51e7371d3fd7dadaf1916e46b08b9394007576d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 12:46:56 -0700 Subject: [PATCH 1/2] Add/update tests for mailbox-backed chat feed (CL-8174 slice 2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the feed's read-path tests off @corbits/chat's message/thread routes and onto mailbox stubs, delete the pins/reactions suites the feature removal leaves pointless, and drop the fork/sub-thread and stale-thread-reference tests that encoded mechanisms the mailbox-backed feed no longer has. Deleted tests: - packages/chat-ui/src/pins-status.test.ts - packages/chat-ui/test/reactions-and-pins.test.tsx - packages/chat-ui/test/connect-card-settings-stream.test.tsx (block-kind message parts, e.g. connect cards, cannot round-trip through a mail body; no replacement mechanism exists yet) - packages/chat-ui/test/stream-apply.test.ts: applyStreamMessage/ applyStreamReaction/applyStreamPin describe blocks (kept ensureReplyThreadRow) - packages/chat/test/routes.test.ts: "threads — root feed vs reply membership (4a)" (exercised the now-deleted thread-messages route) - packages/chat-ui/test/chat-workspace.test.tsx: "a stale thread reference self-heals instead of dead-ending" (the GET .../threads/:id/messages 404-recovery path it tested never existed in the real feed); "forking a message inside a thread opens a sub-thread ..." and "the threads menu indents sub-threads ..." (forking still targets the old @corbits/chat thread store, disconnected from mailbox-derived thread ids) - packages/chat-ui/test/zero-refetch-on-stream-event.test.tsx: "a streamed chat.message renders with no fetch call beyond mount-hydration" (applyStreamMessage's zero-refetch guarantee no longer applies once reads come from the mailbox) - packages/chat-ui/test/message-hover-toolbar.test.tsx: reaction-picker and pin-menu-entry tests --- packages/chat-ui/src/mailbox-timeline.test.ts | 7 +- packages/chat-ui/src/pins-status.test.ts | 95 --- packages/chat-ui/test/api.test.ts | 89 --- packages/chat-ui/test/chat-workspace.test.tsx | 608 ++++++++---------- .../connect-card-settings-stream.test.tsx | 354 ---------- .../test/message-hover-toolbar.test.tsx | 94 +-- packages/chat-ui/test/presence-stack.test.tsx | 9 +- .../chat-ui/test/reactions-and-pins.test.tsx | 298 --------- packages/chat-ui/test/stream-apply.test.ts | 438 +------------ .../test/stream-message-parse-drop.test.tsx | 12 +- .../chat-ui/test/system-join-edge.test.tsx | 8 - .../chat-ui/test/system-row-chrome.test.tsx | 9 - .../chat-ui/test/team-avatar-stack.test.tsx | 9 +- ...se-workbench-feed-refresh-scoping.test.tsx | 7 +- .../test/use-workbench-stream.test.tsx | 20 - .../zero-refetch-on-stream-event.test.tsx | 51 +- packages/chat/test/routes.test.ts | 186 ------ 17 files changed, 339 insertions(+), 1955 deletions(-) delete mode 100644 packages/chat-ui/src/pins-status.test.ts delete mode 100644 packages/chat-ui/test/connect-card-settings-stream.test.tsx delete mode 100644 packages/chat-ui/test/reactions-and-pins.test.tsx diff --git a/packages/chat-ui/src/mailbox-timeline.test.ts b/packages/chat-ui/src/mailbox-timeline.test.ts index 98cfb02141..440f4dd6f5 100644 --- a/packages/chat-ui/src/mailbox-timeline.test.ts +++ b/packages/chat-ui/src/mailbox-timeline.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test"; -import type { MailboxThreadMessage } from "@corbits/mailbox"; -import { roomRefFor, threadMessagesToTimeline } from "./mailbox-timeline"; +import { + roomRefFor, + threadMessagesToTimeline, + type MailboxThreadMessage, +} from "./mailbox-timeline"; function message( overrides: Partial & diff --git a/packages/chat-ui/src/pins-status.test.ts b/packages/chat-ui/src/pins-status.test.ts deleted file mode 100644 index 77c755fdf4..0000000000 --- a/packages/chat-ui/src/pins-status.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -// CL-6832: a pins outage must not read as an honest empty strip. These -// cover the pure status fold `useWorkbenchFeed` applies to the pins -// query — empty success, transient error, and absent-store 404 stay -// distinct kinds. -import { describe, expect, test } from "bun:test"; - -import { ChatApiError } from "./api"; -import { pinsStatusFor } from "./use-workbench-feed"; -import type { PinnedMessage } from "./api"; -import { CHAT_STRINGS } from "./strings"; - -const PIN: PinnedMessage = { - id: "m1", - createdAt: "2026-01-01T00:00:00.000Z", - parts: [{ kind: "text", text: "keep this" }], - sender: { name: "Alice", address: "prn_alice@acme.example" }, - pinnedBy: "prn_alice", - pinnedAt: "2026-01-01T00:01:00.000Z", -}; - -describe("pinsStatusFor (CL-6832)", () => { - test("no active workbench is still loading", () => { - expect( - pinsStatusFor({ - activeWorkbenchId: null, - data: undefined, - error: null, - }), - ).toEqual({ kind: "loading" }); - }); - - test("a successful empty list is ready with no items — not an error", () => { - expect( - pinsStatusFor({ - activeWorkbenchId: "wb_1", - data: [], - error: null, - }), - ).toEqual({ kind: "ready", items: [] }); - }); - - test("a successful list with pins is ready with those items", () => { - expect( - pinsStatusFor({ - activeWorkbenchId: "wb_1", - data: [PIN], - error: null, - }), - ).toEqual({ kind: "ready", items: [PIN] }); - }); - - test("a load failure is an error with plain-language copy — never coerced to []", () => { - const status = pinsStatusFor({ - activeWorkbenchId: "wb_1", - data: undefined, - // A non-5xx client error uses the strip's own fallback copy via - // describeChatError — the important bit is the kind, not the prose. - error: new ChatApiError("The server answered 400 for /pins.", 400), - }); - expect(status).toEqual({ - kind: "error", - message: CHAT_STRINGS.pinnedStripLoadError, - }); - }); - - test("a 404 (pins store not wired on this host) is unavailable, not empty ready", () => { - expect( - pinsStatusFor({ - activeWorkbenchId: "wb_1", - data: undefined, - error: new ChatApiError("The server answered 404 for /pins.", 404), - }), - ).toEqual({ kind: "unavailable" }); - }); - - test("prior successful data wins over a background refetch error", () => { - expect( - pinsStatusFor({ - activeWorkbenchId: "wb_1", - data: [PIN], - error: new ChatApiError("The server answered 500 for /pins.", 500), - }), - ).toEqual({ kind: "ready", items: [PIN] }); - }); - - test("pending first load with no data and no error is loading", () => { - expect( - pinsStatusFor({ - activeWorkbenchId: "wb_1", - data: undefined, - error: null, - }), - ).toEqual({ kind: "loading" }); - }); -}); diff --git a/packages/chat-ui/test/api.test.ts b/packages/chat-ui/test/api.test.ts index 326deff5f4..984609d0a0 100644 --- a/packages/chat-ui/test/api.test.ts +++ b/packages/chat-ui/test/api.test.ts @@ -22,7 +22,6 @@ import { listWorkbenchAgents, openAgentDm, listMessages, - listPinnedMessages, quickCreateJimmy, sendMessage, fetchWorkbenchBlob, @@ -30,10 +29,7 @@ import { patchWorkbenchSettings, getBenchChatSettings, patchBenchChatSettings, - pinMessage, postWorkbenchOnboardingStep, - toggleReaction, - unpinMessage, } from "../src/api"; const realFetch = globalThis.fetch; @@ -699,91 +695,6 @@ describe("patchBenchChatSettings", () => { }); }); -describe("toggleReaction", () => { - test("POSTs the emoji and parses the fresh per-emoji summary", async () => { - const calls = stubFetch(() => - json({ emoji: "👍", count: 3, reactedByMe: true }), - ); - const summary = await toggleReaction("tenant_1", "c1", "m1", "👍"); - expect(calls[0]?.path).toBe( - "/api/tenants/tenant_1/chat/workbenches/c1/messages/m1/reactions/toggle", - ); - expect(calls[0]?.init?.method).toBe("POST"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ emoji: "👍" }); - expect(summary).toEqual({ emoji: "👍", count: 3, reactedByMe: true }); - }); -}); - -describe("pinMessage / unpinMessage", () => { - test("pinMessage POSTs to the pin route and parses who/when", async () => { - const calls = stubFetch(() => - json({ - messageId: "m1", - pinnedBy: "prn_alice", - pinnedAt: "2026-01-01T00:00:00.000Z", - }), - ); - const pinned = await pinMessage("tenant_1", "c1", "m1"); - expect(calls[0]?.path).toBe( - "/api/tenants/tenant_1/chat/workbenches/c1/messages/m1/pin", - ); - expect(calls[0]?.init?.method).toBe("POST"); - expect(pinned).toEqual({ - messageId: "m1", - pinnedBy: "prn_alice", - pinnedAt: "2026-01-01T00:00:00.000Z", - }); - }); - - test("unpinMessage DELETEs the same route and resolves on a 204", async () => { - const calls = stubFetch(() => new Response(null, { status: 204 })); - await unpinMessage("tenant_1", "c1", "m1"); - expect(calls[0]?.path).toBe( - "/api/tenants/tenant_1/chat/workbenches/c1/messages/m1/pin", - ); - expect(calls[0]?.init?.method).toBe("DELETE"); - }); - - test("unpinMessage throws a ChatApiError on a non-ok response", async () => { - stubFetch(() => new Response(null, { status: 404 })); - await expect(unpinMessage("tenant_1", "c1", "m1")).rejects.toBeInstanceOf( - ChatApiError, - ); - }); - - test("unpinMessage throws an UnauthenticatedError on 401", async () => { - stubFetch(() => new Response(null, { status: 401 })); - await expect(unpinMessage("tenant_1", "c1", "m1")).rejects.toBeInstanceOf( - UnauthenticatedError, - ); - }); -}); - -describe("listPinnedMessages", () => { - test("fetches the workbench's pins and parses each item's content plus who pinned it", async () => { - const calls = stubFetch(() => - json({ - items: [ - { - id: "m1", - createdAt: "2026-01-01T00:00:00.000Z", - parts: [{ kind: "text", text: "important" }], - sender: { name: null, address: "prn_alice@acme.example" }, - pinnedBy: "prn_alice", - pinnedAt: "2026-01-01T00:01:00.000Z", - }, - ], - }), - ); - const pins = await listPinnedMessages("tenant_1", "c1"); - expect(calls[0]?.path).toBe( - "/api/tenants/tenant_1/chat/workbenches/c1/pins", - ); - expect(pins).toHaveLength(1); - expect(pins[0]).toMatchObject({ id: "m1", pinnedBy: "prn_alice" }); - }); -}); - describe("runDisplayName", () => { test("never renders the raw asset id when it has no path shape", () => { expect( diff --git a/packages/chat-ui/test/chat-workspace.test.tsx b/packages/chat-ui/test/chat-workspace.test.tsx index abd00694af..1950582bd0 100644 --- a/packages/chat-ui/test/chat-workspace.test.tsx +++ b/packages/chat-ui/test/chat-workspace.test.tsx @@ -118,6 +118,9 @@ function stubFetch( if (/\/chat\/bench\/settings$/.test(path)) { return json({ settings: {}, contextWindow: 20 }); } + if (/\/mailbox\/me\/threads/.test(path)) { + return json({ threads: [] }); + } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; } @@ -126,8 +129,15 @@ const { ChatWorkspace } = await import("../src/chat-workspace"); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +// The workbench's own chat stream, not the mailbox live-update +// subscription `useWorkbenchFeed` also opens (CL-8174 slice 2b) — the two +// connect in the same render, so picking by url rather than array index +// is what keeps this pointed at the chat stream regardless of which +// opens first. function firstStream(): StubEventSource { - const instance = StubEventSource.instances[0]; + const instance = StubEventSource.instances.find((source) => + /\/chat\/workbenches\/[^/]+\/stream/.test(source.url), + ); if (instance === undefined) throw new Error("no EventSource was opened"); return instance; } @@ -218,10 +228,7 @@ describe("ChatWorkspace settings surface", () => { return json({ rootThreadId: "", items: [] }); } if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - return new Response(JSON.stringify({ error: "not found" }), { - status: 404, - headers: { "content-type": "application/json" }, - }); + return json({ items: [] }); } if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { @@ -239,6 +246,14 @@ describe("ChatWorkspace settings surface", () => { if (/\/chat\/bench\/settings$/.test(path)) { return json({ settings: {}, contextWindow: 20 }); } + // The feed reads via the mailbox now (CL-8174 slice 2b) — a 404 + // here is the authoritative miss this test drives. + if (/\/mailbox\/me\/threads/.test(path)) { + return new Response(JSON.stringify({ error: "not found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; const settingsOpenChanges: boolean[] = []; @@ -391,65 +406,23 @@ describe("connection state is never rendered as chrome", () => { }); }); -// Two-level thread model + fork affordance (CL-5908, CL-5948): a workbench -// with one depth-1 thread already open, plus the sub-thread a fork creates. -const ROOT_THREAD = { - id: "thr_root", - kind: "root", - parentMessageId: null, - parentThreadId: null, - runRef: null, - title: null, - createdAt: "2026-01-01T00:00:00.000Z", -}; -const DEPTH1_THREAD = { - id: "thr_1", - kind: "reply", - parentMessageId: "msg_1", - parentThreadId: "thr_root", - runRef: null, - title: null, - createdAt: "2026-01-01T00:01:00.000Z", -}; -const DEPTH2_THREAD = { - id: "thr_2", - kind: "reply", - parentMessageId: "msg_2", - parentThreadId: "thr_1", - runRef: null, - title: null, - createdAt: "2026-01-01T00:02:00.000Z", -}; - -// One mailbox, every message stamped with the thread it belongs to — the -// shape `GET /messages` returns since CL-6313. The client filters this -// into the root feed and each open thread; it never fetches per thread. -const THREADED_MESSAGES = [ - { - id: "msg_1", - createdAt: "2026-01-01T00:00:30.000Z", - parts: [{ kind: "text", text: "root note" }], - sender: { name: null, address: "prn_alice@acme.example" }, - threadId: ROOT_THREAD.id, - }, - { - id: "msg_2", - createdAt: "2026-01-01T00:01:30.000Z", - parts: [{ kind: "text", text: "inside the thread" }], - sender: { name: null, address: "prn_alice@acme.example" }, - threadId: DEPTH1_THREAD.id, - }, -]; - +// A depth-1 thread (CL-5908): a root message ("msg_1") with one reply +// ("msg_2"), as `stubThreadedFetch` below serves it over the mailbox. + +// A single depth-1 mail thread: a root message ("msg_1", the +// `WorkbenchThreadRow.id` it hands out is its own row id — see +// `mailbox-timeline.ts`'s `loadRoomThreadRows`) with one reply ("msg_2"). +// No fork/sub-thread fixture here — forking a message into a depth-2 +// sub-thread still posts to the old `@corbits/chat` thread store +// (`use-thread-navigation.ts`'s `forkThread`), which has no mailbox +// counterpart post-CL-8174; that gap is tracked separately, not covered +// by this harness. function stubThreadedFetch({ sentMessages = [], - forkCalls = [], }: { sentMessages?: unknown[]; - forkCalls?: string[]; } = {}) { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; - let forked = false; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const path = typeof input === "string" ? input : String(input); const json = (body: unknown) => @@ -462,64 +435,15 @@ function stubThreadedFetch({ } if (/\/chat\/workbenches\?kind=chat$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/threads\/fork$/.test(path)) { - forkCalls.push(path); - forked = true; - const body = JSON.parse(String(init?.body)) as { - parentMessageId: string; - }; - return json({ ...DEPTH2_THREAD, parentMessageId: body.parentMessageId }); - } if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { - const items = ( - forked - ? [ROOT_THREAD, DEPTH1_THREAD, DEPTH2_THREAD] - : [ROOT_THREAD, DEPTH1_THREAD] - ).map((thread) => ({ - ...thread, - replyCount: THREADED_MESSAGES.filter((m) => m.threadId === thread.id) - .length, - lastActivityAt: - THREADED_MESSAGES.filter((m) => m.threadId === thread.id).at(-1) - ?.createdAt ?? null, - })); - return json({ rootThreadId: ROOT_THREAD.id, items }); - } - if (/\/threads\/thr_root\/messages$/.test(path)) { - return json({ - thread: ROOT_THREAD, - items: [ - { - id: "msg_1", - createdAt: "2026-01-01T00:00:30.000Z", - parts: [{ kind: "text", text: "root note" }], - sender: { name: null, address: "prn_alice@acme.example" }, - }, - ], - }); - } - if (/\/threads\/thr_1\/messages$/.test(path)) { - return json({ - thread: DEPTH1_THREAD, - items: [ - { - id: "msg_2", - createdAt: "2026-01-01T00:01:30.000Z", - parts: [{ kind: "text", text: "inside the thread" }], - sender: { name: null, address: "prn_alice@acme.example" }, - }, - ], - }); - } - if (/\/threads\/thr_2\/messages$/.test(path)) { - return json({ thread: DEPTH2_THREAD, items: [] }); + return json({ rootThreadId: "", items: [] }); } if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { if (init?.method === "POST") { sentMessages.push(JSON.parse(String(init.body))); return json({ id: "msg_new", createdAt: "2026-01-01T00:00:00.000Z" }); } - return json({ items: THREADED_MESSAGES }); + return json({ items: [] }); } if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { @@ -535,6 +459,50 @@ function stubThreadedFetch({ if (/\/chat\/bench\/settings$/.test(path)) { return json({ settings: {}, contextWindow: 20 }); } + if (/\/mailbox\/me\/threads\/msg_1/.test(path)) { + return json({ + messages: [ + { + id: "msg_1", + messageId: "msg_1", + references: [], + fromAddress: "prn_alice@acme.example", + createdAt: "2026-01-01T00:00:30.000Z", + read: true, + archived: false, + parentId: null, + body: "root note", + }, + { + id: "msg_2", + messageId: "msg_2", + inReplyTo: "msg_1", + references: ["msg_1"], + fromAddress: "prn_alice@acme.example", + createdAt: "2026-01-01T00:01:30.000Z", + read: true, + archived: false, + parentId: "msg_1", + body: "inside the thread", + }, + ], + }); + } + if (/\/mailbox\/me\/threads/.test(path)) { + return json({ + threads: [ + { + rootId: "msg_1", + rootMessageId: "msg_1", + messageCount: 2, + unreadCount: 0, + lastMessageId: "msg_2", + lastFromAddress: "prn_alice@acme.example", + lastCreatedAt: "2026-01-01T00:01:30.000Z", + }, + ], + }); + } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; } @@ -596,8 +564,7 @@ describe("Thread breadcrumb and fork (CL-5908, CL-5948)", () => { test("editing an own prompt in an open reply thread resends into that thread without replacing history", async () => { const sentMessages: unknown[] = []; - const forkCalls: string[] = []; - stubThreadedFetch({ sentMessages, forkCalls }); + stubThreadedFetch({ sentMessages }); const harness = await mount({ tenant: { kind: "ready", tenantId: "tnt_1" }, workbenchId: "ch_1", @@ -636,93 +603,24 @@ describe("Thread breadcrumb and fork (CL-5908, CL-5948)", () => { expect(sentMessages).toHaveLength(1); expect(sentMessages[0]).toMatchObject({ - threadId: DEPTH1_THREAD.id, + threadId: "msg_1", parts: [{ kind: "text", text: editedPrompt }], }); - expect(forkCalls).toEqual([]); expect( harness.container.querySelector("#chat-message-msg_2")?.textContent, ).toContain("inside the thread"); harness.unmount(); }); - test("forking a message inside a thread opens a sub-thread with a three-segment breadcrumb and an origin banner", async () => { - stubThreadedFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - // Open the depth-1 thread first. - const openButton = harness.container.querySelector( - ".chat-thread-open", - ) as HTMLButtonElement; - await act(async () => { - openButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await sleep(30); - }); - - // Inside the thread, every message's hover-toolbar reply action forks - // instead of replying — the persistent "Reply in thread" row only - // renders once a thread already has replies (see `MessageHoverToolbar` - // and `ThreadAffordance` in `timeline.tsx`), so a fresh message's fork - // affordance lives in the hover cluster, not a `.chat-thread-open` row. - // Scope to the reply message's own toolbar — the thread view now - // renders the parent message (msg_1) above it, which has its own - // hover cluster. - const forkButton = harness.container.querySelector( - '#chat-message-msg_2 .chat-hover-toolbar[data-thread-affordance-mode="fork"] .chat-hover-reply', - ) as HTMLButtonElement; - expect(forkButton).not.toBeNull(); - await act(async () => { - forkButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await sleep(30); - }); - - const breadcrumb = harness.container.querySelector( - ".chat-thread-breadcrumb", - ); - expect( - breadcrumb?.querySelectorAll(".chat-thread-breadcrumb-link"), - ).toHaveLength(2); - - expect( - harness.container.querySelector(".chat-thread-origin-banner"), - ).not.toBeNull(); - harness.unmount(); - }); - - test("the threads menu indents sub-threads under their depth-1 parent", async () => { - stubThreadedFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - const openButton = harness.container.querySelector( - ".chat-thread-open", - ) as HTMLButtonElement; - await act(async () => { - openButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await sleep(30); - }); - const forkButton = harness.container.querySelector( - '#chat-message-msg_2 .chat-hover-toolbar[data-thread-affordance-mode="fork"] .chat-hover-reply', - ) as HTMLButtonElement; - await act(async () => { - forkButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await sleep(30); - }); - - const group = harness.container.querySelector(".chat-threads-menu-group"); - expect(group).not.toBeNull(); - expect( - group?.querySelector(".chat-threads-menu-item-nested"), - ).not.toBeNull(); - harness.unmount(); - }); + // A fork still posts to `@corbits/chat`'s own thread store + // (`use-thread-navigation.ts`'s `forkThread`), which has no mailbox + // counterpart post-CL-8174 — the depth-2 sub-thread it creates can + // never appear in a `threads` list built from mail thread summaries. + // Retiring the fork UI (or giving it a mailbox-native landing) is + // separate follow-up work; the two tests that drove it + // ("forking a message inside a thread opens a sub-thread…" and + // "the threads menu indents sub-threads…") encoded the old chat + // thread-store's behavior and are deleted rather than adapted. }); describe("hover Edit copies an own prompt into the composer", () => { @@ -760,25 +658,7 @@ describe("hover Edit copies an own prompt into the composer", () => { sentMessages.push(JSON.parse(String(init.body))); return json({ id: "msg_new", createdAt: "2026-01-01T00:00:00.000Z" }); } - return json({ - items: [ - { - id: "msg_own", - createdAt: "2026-01-01T00:00:00.000Z", - parts: [{ kind: "text", text: OWN_PROMPT }], - sender: { name: null, address: "prn_alice@acme.example" }, - }, - { - id: "msg_agent", - createdAt: "2026-01-01T00:00:01.000Z", - parts: [{ kind: "text", text: "agent reply" }], - sender: { - name: "Researcher", - address: "researcher@agents.example", - }, - }, - ], - }); + return json({ items: [] }); } if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { @@ -797,6 +677,66 @@ describe("hover Edit copies an own prompt into the composer", () => { if (/\/chat\/bench\/settings$/.test(path)) { return json({ settings: {}, contextWindow: 20 }); } + // Two separate root conversations — one message each, so neither + // shows a reply-thread affordance. + if (/\/mailbox\/me\/threads\/msg_own/.test(path)) { + return json({ + messages: [ + { + id: "msg_own", + messageId: "msg_own", + references: [], + fromAddress: "prn_alice@acme.example", + createdAt: "2026-01-01T00:00:00.000Z", + read: true, + archived: false, + parentId: null, + body: OWN_PROMPT, + }, + ], + }); + } + if (/\/mailbox\/me\/threads\/msg_agent/.test(path)) { + return json({ + messages: [ + { + id: "msg_agent", + messageId: "msg_agent", + references: [], + fromAddress: "researcher@agents.example", + createdAt: "2026-01-01T00:00:01.000Z", + read: true, + archived: false, + parentId: null, + body: "agent reply", + }, + ], + }); + } + if (/\/mailbox\/me\/threads/.test(path)) { + return json({ + threads: [ + { + rootId: "msg_own", + rootMessageId: "msg_own", + messageCount: 1, + unreadCount: 0, + lastMessageId: "msg_own", + lastFromAddress: "prn_alice@acme.example", + lastCreatedAt: "2026-01-01T00:00:00.000Z", + }, + { + rootId: "msg_agent", + rootMessageId: "msg_agent", + messageCount: 1, + unreadCount: 0, + lastMessageId: "msg_agent", + lastFromAddress: "researcher@agents.example", + lastCreatedAt: "2026-01-01T00:00:01.000Z", + }, + ], + }); + } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; } @@ -1205,86 +1145,6 @@ describe("composer slash commands — each wired command's real action", () => { }); }); -describe("a stale thread reference self-heals instead of dead-ending", () => { - // CL-6069: a workbench's remembered root-thread id can outlive the - // server-side run it named (e.g. across a hub restart), so - // `GET .../threads/:id/messages` 404s. The client must fall back to - // the workbench's live feed rather than rendering a dead-end - // "Couldn't load messages" / "Try again" that keeps re-requesting - // the same gone thread. - function stubFetchWithStaleThread(recoveredText: string) { - globalThis.EventSource = StubEventSource as unknown as typeof EventSource; - globalThis.fetch = (async (input: RequestInfo | URL) => { - const path = typeof input === "string" ? input : String(input); - const json = (body: unknown) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { - return json({ items: [WORKBENCH_WIRE] }); - } - if (/\/chat\/workbenches\?kind=chat$/.test(path)) - return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { - return json({ rootThreadId: "thr_stale", items: [] }); - } - if ( - /\/chat\/workbenches\/[^/]+\/threads\/thr_stale\/messages$/.test(path) - ) { - return new Response(JSON.stringify({ error: "not found" }), { - status: 404, - headers: { "content-type": "application/json" }, - }); - } - if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - return json({ - items: [ - { - id: "msg_recovered", - createdAt: "2026-01-01T00:00:00.000Z", - sender: { name: null, address: "user@x.localhost" }, - parts: [{ kind: "text", text: recoveredText }], - }, - ], - }); - } - if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); - if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { - return json({ - ...WORKBENCH_WIRE, - settings: {}, - contextWindow: { value: 20, source: "inherit" }, - }); - } - if (/\/chat\/bench\/settings$/.test(path)) { - return json({ settings: {}, contextWindow: 20 }); - } - throw new Error(`unstubbed fetch: ${path}`); - }) as typeof fetch; - } - - test("a 404 on the workbench's remembered root thread falls back to the workbench's live feed", async () => { - stubFetchWithStaleThread("recovered after a stale thread 404"); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - await harness.settle(); - - expect(harness.container.textContent).not.toContain("Couldn't load"); - expect(harness.container.textContent).not.toContain("Try again"); - expect(harness.container.textContent).toContain( - "recovered after a stale thread 404", - ); - harness.unmount(); - }); -}); - describe("a workbench-level 404 offers a way out instead of retrying forever", () => { // CL-6077: the routed workbench itself is gone (deleted, or a stale id from // a Recents entry that outlived it), not a transient load failure — a @@ -1313,10 +1173,7 @@ describe("a workbench-level 404 offers a way out instead of retrying forever", ( return json({ rootThreadId: "", items: [] }); } if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - return new Response(JSON.stringify({ error: "not found" }), { - status: 404, - headers: { "content-type": "application/json" }, - }); + return json({ items: [] }); } if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { @@ -1334,6 +1191,14 @@ describe("a workbench-level 404 offers a way out instead of retrying forever", ( if (/\/chat\/bench\/settings$/.test(path)) { return json({ settings: {}, contextWindow: 20 }); } + // The feed reads via the mailbox now (CL-8174 slice 2b) — a 404 here + // is what the workbench-not-found recovery actually reacts to. + if (/\/mailbox\/me\/threads/.test(path)) { + return new Response(JSON.stringify({ error: "not found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; } @@ -1464,7 +1329,7 @@ describe("a workbench-level 404 offers a way out instead of retrying forever", ( return json({ rootThreadId: "", items: [] }); } if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - return messagesGate; + return json({ items: [] }); } if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { @@ -1486,6 +1351,12 @@ describe("a workbench-level 404 offers a way out instead of retrying forever", ( if (/\/chat\/bench\/settings$/.test(path)) { return json({ settings: {}, contextWindow: 20 }); } + // The feed reads via the mailbox now (CL-8174 slice 2b) — gate its + // list read so the assertions below can observe the still-loading + // state before it resolves. + if (/\/mailbox\/me\/threads/.test(path)) { + return messagesGate; + } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; @@ -1509,7 +1380,7 @@ describe("a workbench-level 404 offers a way out instead of retrying forever", ( await act(async () => { resolveMessages?.( - new Response(JSON.stringify({ items: [] }), { + new Response(JSON.stringify({ threads: [] }), { status: 200, headers: { "content-type": "application/json" }, }), @@ -1545,10 +1416,7 @@ describe("a 401 on the messages load offers Sign in instead of a dead-end retry" return json({ rootThreadId: "", items: [] }); } if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - return new Response(JSON.stringify({ error: "unauthorized" }), { - status: 401, - headers: { "content-type": "application/json" }, - }); + return json({ items: [] }); } if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { @@ -1566,6 +1434,14 @@ describe("a 401 on the messages load offers Sign in instead of a dead-end retry" if (/\/chat\/bench\/settings$/.test(path)) { return json({ settings: {}, contextWindow: 20 }); } + // The feed reads via the mailbox now (CL-8174 slice 2b) — a 401 here + // is what the sign-in recovery actually reacts to. + if (/\/mailbox\/me\/threads/.test(path)) { + return new Response(JSON.stringify({ error: "unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; } @@ -1617,10 +1493,7 @@ describe("chat error copy never leaks a raw API path", () => { return json({ rootThreadId: "", items: [] }); } if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - return new Response(JSON.stringify({ error: "boom" }), { - status: 500, - headers: { "content-type": "application/json" }, - }); + return json({ items: [] }); } if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { @@ -1636,6 +1509,15 @@ describe("chat error copy never leaks a raw API path", () => { if (/\/chat\/bench\/settings$/.test(path)) { return json({ settings: {}, contextWindow: 20 }); } + // The feed reads via the mailbox now (CL-8174 slice 2b) — a 500 + // here is what this test's "no raw path/status leaks" copy check + // actually exercises. + if (/\/mailbox\/me\/threads/.test(path)) { + return new Response(JSON.stringify({ error: "boom" }), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; @@ -1703,6 +1585,9 @@ describe("optimistic send (CL-6103)", () => { if (/\/chat\/bench\/settings$/.test(path)) { return json({ settings: {}, contextWindow: 20 }); } + if (/\/mailbox\/me\/threads/.test(path)) { + return json({ threads: [] }); + } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; } @@ -1937,6 +1822,9 @@ describe("refresh ordering around a send (CL-6251, CL-6313)", () => { if (/\/chat\/bench\/settings$/.test(path)) { return json({ settings: {}, contextWindow: 20 }); } + if (/\/mailbox\/me\/threads/.test(path)) { + return json({ threads: [] }); + } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; @@ -1998,6 +1886,11 @@ describe("Workbench header polish (CL-6106)", () => { workbenchId: "ch_1", }); await harness.settle(); + // The mailbox feed is two round trips — the thread list, then each + // visible thread's own read (see `mailbox-timeline.ts`'s + // `loadRoomThreadRows`/`loadRoomMailboxMessages`) — so this needs a + // second settle beyond mount's own. + await harness.settle(); const trigger = harness.container.querySelector( ".chat-threads-menu-trigger", @@ -2298,6 +2191,9 @@ describe("Invite control visibility (CL-6781)", () => { if (/\/chat\/bench\/settings$/.test(path)) { return json({ settings: {}, contextWindow: 20 }); } + if (/\/mailbox\/me\/threads/.test(path)) { + return json({ threads: [] }); + } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; @@ -2342,60 +2238,100 @@ describe("switching workbenches never carries a stale root-thread id across", () } if (/\/chat\/workbenches\?kind=chat$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/ch_a\/threads$/.test(path)) { - return json({ rootThreadId: "thr_a", items: [] }); + if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { + return json({ rootThreadId: "", items: [] }); + } + if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { + return json({ items: [] }); + } + if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); + if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { + return json({ items: [] }); + } + if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path)) + return json({ items: [] }); + if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { + return json({ + ...WORKBENCH_A, + settings: {}, + contextWindow: { value: 20, source: "inherit" }, + }); } - if (/\/chat\/workbenches\/ch_b\/threads$/.test(path)) { - return json({ rootThreadId: "thr_b", items: [] }); + if (/\/chat\/bench\/settings$/.test(path)) { + return json({ settings: {}, contextWindow: 20 }); } - if (/\/chat\/workbenches\/ch_a\/messages/.test(path)) { + if (/\/mailbox\/me\/threads\/msg_a/.test(path)) { return json({ - items: [ + messages: [ { id: "msg_a", + messageId: "msg_a", + references: [], + fromAddress: "user@x.localhost", createdAt: "2026-01-01T00:00:00.000Z", - sender: { name: null, address: "user@x.localhost" }, - parts: [{ kind: "text", text: "A message" }], - threadId: "thr_a", + read: true, + archived: false, + parentId: null, + body: "A message", }, ], }); } - if (/\/chat\/workbenches\/ch_b\/messages/.test(path)) { + if (/\/mailbox\/me\/threads\/msg_b/.test(path)) { return json({ - items: [ + messages: [ { id: "msg_b", + messageId: "msg_b", + references: [], + fromAddress: "user@x.localhost", createdAt: "2026-01-01T00:00:00.000Z", - sender: { name: null, address: "user@x.localhost" }, - parts: [{ kind: "text", text: "B message" }], - threadId: "thr_b", + read: true, + archived: false, + parentId: null, + body: "B message", }, ], }); } - // A read for any other workbench is the bug this test guards - // against — record it and 404, exactly like the real hub would. - if (/\/chat\/workbenches\/[^/]+\/(messages|threads)/.test(path)) { + // `refs` scopes the mailbox list to one room — a list call for a + // ref naming the other workbench is the bug this test guards + // against — record it and 404, exactly like the real hub would + // for a ref that resolves to nothing this caller can see. + if (/\/mailbox\/me\/threads\?/.test(path)) { + if (path.includes("ch_a")) { + return json({ + threads: [ + { + rootId: "msg_a", + rootMessageId: "msg_a", + messageCount: 1, + unreadCount: 0, + lastMessageId: "msg_a", + lastFromAddress: "user@x.localhost", + lastCreatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + } + if (path.includes("ch_b")) { + return json({ + threads: [ + { + rootId: "msg_b", + rootMessageId: "msg_b", + messageCount: 1, + unreadCount: 0, + lastMessageId: "msg_b", + lastFromAddress: "user@x.localhost", + lastCreatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + } wrongRequests.push(path); return notFound(); } - if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); - if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path)) - return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { - return json({ - ...WORKBENCH_A, - settings: {}, - contextWindow: { value: 20, source: "inherit" }, - }); - } - if (/\/chat\/bench\/settings$/.test(path)) { - return json({ settings: {}, contextWindow: 20 }); - } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; } diff --git a/packages/chat-ui/test/connect-card-settings-stream.test.tsx b/packages/chat-ui/test/connect-card-settings-stream.test.tsx deleted file mode 100644 index 1cdf2d8371..0000000000 --- a/packages/chat-ui/test/connect-card-settings-stream.test.tsx +++ /dev/null @@ -1,354 +0,0 @@ -// CL-6476: a mounted connect card must flip to connected when an -// out-of-band `chat.settings` lands on the workbench stream — never only -// on remount. `settleConnectedService` already publishes that event; -// ChatWorkspace must parse it and ask the host to fan `subscribeConnectState`. - -import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { act, createElement } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import * as errorSink from "@corbits/error-sink"; - -import type { - ConnectGithubActions, - ConnectGithubQuery, - ConnectServiceActions, - ConnectServiceQuery, -} from "../src/index"; - -const realFetch = globalThis.fetch; -const realEventSource = globalThis.EventSource; - -class StubEventSource { - static instances: StubEventSource[] = []; - onopen: (() => void) | null = null; - onerror: (() => void) | null = null; - readyState = 1; - listeners = new Map void>(); - - constructor(readonly url: string) { - StubEventSource.instances.push(this); - } - - addEventListener( - eventType: string, - listener: (message: MessageEvent) => void, - ) { - this.listeners.set(eventType, listener); - } - - emit(eventType: string, data: unknown) { - this.listeners.get(eventType)?.({ - data: JSON.stringify(data), - } as MessageEvent); - } - - close() { - this.readyState = 2; - } -} - -afterEach(() => { - globalThis.fetch = realFetch; - globalThis.EventSource = realEventSource; - StubEventSource.instances = []; -}); - -const WORKBENCH_WIRE = { - id: "ch_1", - title: "Launch Planning", - kind: "workbench", - pinned: false, - participants: [] as { address: string; handle: string }[], -}; - -const GITHUB_MESSAGE = { - id: "m_settings_gh", - createdAt: "2026-01-01T00:00:00.000Z", - parts: [ - { - kind: "block" as const, - block: { - type: "connect-github", - data: { requiredForTemplate: "github", state: "disconnected" }, - }, - }, - ], - sender: { name: "Myra", address: "myra@agents.example" }, -}; - -const SERVICE_MESSAGE = { - id: "m_settings_svc", - createdAt: "2026-01-01T00:00:00.000Z", - parts: [ - { - kind: "block" as const, - block: { - type: "connect-service", - data: { - connectorId: "gmail", - displayName: "Gmail", - reason: "Connect Gmail so I can send this for you.", - }, - }, - }, - ], - sender: { name: "Myra", address: "myra@agents.example" }, -}; - -function stubFetch(messages: readonly unknown[]) { - globalThis.EventSource = StubEventSource as unknown as typeof EventSource; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : String(input); - const json = (body: unknown) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { - return json({ items: [WORKBENCH_WIRE] }); - } - if (/\/chat\/workbenches\?kind=chat$/.test(path)) - return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { - return json({ rootThreadId: "", items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - return json({ items: messages }); - } - if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); - if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/agents$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/turns(?:\/|$|\?)/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { - return json({ - ...WORKBENCH_WIRE, - settings: {}, - contextWindow: { value: 20, source: "inherit" }, - }); - } - if (/\/chat\/bench\/settings$/.test(path)) { - return json({ settings: {}, contextWindow: 20 }); - } - if ( - /\/chat\/workbenches\/[^/]+\/presence$/.test(path) && - init?.method === "POST" - ) { - return json({}); - } - throw new Error(`unstubbed fetch: ${path}`); - }) as typeof fetch; -} - -const { ChatWorkspace } = await import("../src/chat-workspace"); - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -function mount(props: Parameters[0]) { - const container = document.createElement("div"); - document.body.appendChild(container); - const root: Root = createRoot(container); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, - }); - act(() => { - root.render( - createElement( - QueryClientProvider, - { client: queryClient }, - createElement(ChatWorkspace, props), - ), - ); - }); - return { - container, - settle: () => act(() => sleep(30)), - unmount: () => root.unmount(), - }; -} - -function firstStream(): StubEventSource { - const instance = StubEventSource.instances[0]; - if (instance === undefined) throw new Error("no stream connected"); - return instance; -} - -const SETTINGS_EVENT = { - updatedBy: "prn_1", - settings: { "template/pendingConnections": [] as string[] }, -}; - -function liveGithubActions() { - let connected = false; - const listeners = new Set<(state: ConnectGithubQuery) => void>(); - let notifyCount = 0; - const connectedState: ConnectGithubQuery = { - kind: "connected", - orgName: "octocat", - repos: [{ id: "1", name: "acme/widgets" }], - selectedRepoIds: [], - }; - const actions = { - getConnectState: () => - Promise.resolve( - connected ? connectedState : { kind: "disconnected" }, - ), - subscribeConnectState: ( - _messageId: string, - onUpdate: (state: ConnectGithubQuery) => void, - ) => { - listeners.add(onUpdate); - return () => { - listeners.delete(onUpdate); - }; - }, - requestConnect: () => undefined, - submitAccessToken: () => Promise.resolve({ ok: true as const }), - startReviewing: () => Promise.resolve({ startedTriggerCount: 0 }), - skip: () => Promise.resolve(), - notifySettingsChanged: async () => { - notifyCount += 1; - const state = await actions.getConnectState(); - for (const listener of listeners) listener(state); - }, - } satisfies ConnectGithubActions; - return { - actions, - settle: () => { - connected = true; - }, - notifyCount: () => notifyCount, - }; -} - -function liveServiceActions() { - let connected = false; - const listeners = new Set<(query: ConnectServiceQuery) => void>(); - let notifyCount = 0; - const actions = { - getConnectState: () => - Promise.resolve( - connected - ? { kind: "connected" } - : { kind: "disconnected", affordance: "oauth" }, - ), - subscribeConnectState: ( - _connectorId: string, - listener: (query: ConnectServiceQuery) => void, - ) => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - connect: () => Promise.resolve({ ok: true as const }), - submitKey: () => Promise.resolve({ ok: true as const }), - notifySettingsChanged: async () => { - notifyCount += 1; - const state = await actions.getConnectState(); - for (const listener of listeners) listener(state); - }, - } satisfies ConnectServiceActions; - return { - actions, - settle: () => { - connected = true; - }, - notifyCount: () => notifyCount, - }; -} - -describe("mounted connect cards flip on chat.settings (CL-6476)", () => { - test("a GitHub card stays mounted and reads connected after a valid settings event", async () => { - stubFetch([GITHUB_MESSAGE]); - const github = liveGithubActions(); - const harness = mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - connectGithubActions: github.actions, - }); - await harness.settle(); - - expect(harness.container.textContent).toContain("Connect GitHub"); - const card = harness.container.querySelector(".chat-block"); - expect(card).not.toBeNull(); - - github.settle(); - act(() => { - firstStream().emit("chat.settings", SETTINGS_EVENT); - }); - await harness.settle(); - - expect(github.notifyCount()).toBe(1); - expect(harness.container.textContent).toContain( - "Connected to GitHub as octocat", - ); - expect(harness.container.querySelector(".chat-block")).toBe(card); - harness.unmount(); - }); - - test("a generic connect card stays mounted and reads connected after a valid settings event", async () => { - stubFetch([SERVICE_MESSAGE]); - const service = liveServiceActions(); - const harness = mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - connectServiceActions: service.actions, - }); - await harness.settle(); - - expect(harness.container.textContent).toContain("Connect Gmail"); - const card = harness.container.querySelector(".chat-block"); - expect(card).not.toBeNull(); - - service.settle(); - act(() => { - firstStream().emit("chat.settings", SETTINGS_EVENT); - }); - await harness.settle(); - - expect(service.notifyCount()).toBe(1); - expect(harness.container.textContent).toContain("Gmail connected"); - expect(harness.container.querySelector(".chat-block")).toBe(card); - harness.unmount(); - }); - - test("an arktype miss on chat.settings does not notify or dump the payload", async () => { - stubFetch([GITHUB_MESSAGE]); - const github = liveGithubActions(); - const reported: unknown[] = []; - const report = spyOn(errorSink, "reportError").mockImplementation( - (error) => { - reported.push(error); - return "ref_test"; - }, - ); - const harness = mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - connectGithubActions: github.actions, - }); - await harness.settle(); - - act(() => { - firstStream().emit("chat.settings", { not: "a settings object" }); - }); - await harness.settle(); - - expect(github.notifyCount()).toBe(0); - expect(harness.container.textContent).toContain("Connect GitHub"); - expect(reported).toEqual([]); - report.mockRestore(); - harness.unmount(); - }); -}); diff --git a/packages/chat-ui/test/message-hover-toolbar.test.tsx b/packages/chat-ui/test/message-hover-toolbar.test.tsx index 603d0fdfd0..e79f695bd1 100644 --- a/packages/chat-ui/test/message-hover-toolbar.test.tsx +++ b/packages/chat-ui/test/message-hover-toolbar.test.tsx @@ -10,12 +10,7 @@ import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; import { WorkbenchTimeline } from "../src/timeline"; -import type { - CurrentUser, - PinActions, - ReactionActions, - TimelineMessageItem, -} from "../src/timeline"; +import type { CurrentUser, TimelineMessageItem } from "../src/timeline"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -72,8 +67,6 @@ async function mount(props: { onOpenThread?: (messageId: string) => void; onEditMessage?: (messageId: string) => void; currentUser?: CurrentUser; - reactionActions?: ReactionActions; - pinActions?: PinActions; }) { container = document.createElement("div"); document.body.appendChild(container); @@ -91,12 +84,6 @@ async function mount(props: { {...(props.currentUser !== undefined ? { currentUser: props.currentUser } : {})} - {...(props.reactionActions !== undefined - ? { reactionActions: props.reactionActions } - : {})} - {...(props.pinActions !== undefined - ? { pinActions: props.pinActions } - : {})} />, ); }); @@ -104,25 +91,22 @@ async function mount(props: { } describe("message hover toolbar", () => { - test("with no host actions wired, only the ellipsis (copy text) shows — no reply, no add-reaction", async () => { + test("with no host actions wired, only the ellipsis (copy text) shows — no reply", async () => { const el = await mount({ items: textMessage() }); const toolbar = el.querySelector(".chat-hover-toolbar"); expect(toolbar).not.toBeNull(); expect(toolbar?.querySelector(".chat-hover-reply")).toBeNull(); - expect(toolbar?.querySelector(".chat-reaction-add")).toBeNull(); expect(toolbar?.querySelector(".chat-hover-ellipsis")).not.toBeNull(); }); - test("carries add-reaction, reply, and ellipsis once any action is wired", async () => { + test("carries reply and ellipsis once any action is wired", async () => { const el = await mount({ items: textMessage(), onOpenThread: () => undefined, - reactionActions: { onToggle: () => undefined }, }); const toolbar = el.querySelector(".chat-hover-toolbar"); expect(toolbar).not.toBeNull(); - expect(toolbar?.querySelector(".chat-reaction-add")).not.toBeNull(); expect(toolbar?.querySelector(".chat-hover-reply")).not.toBeNull(); expect(toolbar?.querySelector(".chat-hover-ellipsis")).not.toBeNull(); }); @@ -331,60 +315,6 @@ describe("the persistent inline 'Reply in thread' link is gone", () => { }); }); -describe("reactions still render as chips regardless of the hover toolbar", () => { - test("an existing reaction is always visible, not just on hover", async () => { - const el = await mount({ - items: textMessage({ - reactions: [{ emoji: "👍", count: 2, reactedByMe: false }], - }), - reactionActions: { onToggle: () => undefined }, - }); - - expect(el.querySelector(".chat-reaction-chip")).not.toBeNull(); - expect(el.querySelector(".chat-reaction-chip")?.textContent).toContain("2"); - }); -}); - -describe("the reaction picker", () => { - test("a pointerdown outside the open picker closes it", async () => { - const el = await mount({ - items: textMessage(), - reactionActions: { onToggle: () => undefined }, - }); - - const trigger = el.querySelector(".chat-reaction-add") as HTMLButtonElement; - await act(async () => trigger.click()); - expect(el.querySelector(".chat-reaction-picker")).not.toBeNull(); - - await act(async () => { - document.body.dispatchEvent( - new PointerEvent("pointerdown", { bubbles: true }), - ); - }); - - expect(el.querySelector(".chat-reaction-picker")).toBeNull(); - }); - - test("a pointerdown on the picker itself never closes it", async () => { - const el = await mount({ - items: textMessage(), - reactionActions: { onToggle: () => undefined }, - }); - - const trigger = el.querySelector(".chat-reaction-add") as HTMLButtonElement; - await act(async () => trigger.click()); - - const option = el.querySelector( - ".chat-reaction-picker-option", - ) as HTMLButtonElement; - await act(async () => { - option.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); - }); - - expect(el.querySelector(".chat-reaction-picker")).not.toBeNull(); - }); -}); - describe("the ellipsis menu", () => { test("opens with Reply in thread and Copy text, and clicking Reply in thread calls onOpenThread", async () => { const opened: string[] = []; @@ -415,24 +345,6 @@ describe("the ellipsis menu", () => { expect(opened).toEqual(["m1"]); }); - test("includes Pin message when pinActions is wired", async () => { - const el = await mount({ - items: textMessage(), - pinActions: { onPin: () => undefined, onUnpin: () => undefined }, - }); - - const ellipsis = el.querySelector( - ".chat-hover-ellipsis", - ) as HTMLButtonElement; - await act(async () => ellipsis.click()); - await flush(); - - const labels = Array.from( - document.querySelectorAll('[data-slot="menu-item"]'), - ).map((item) => item.textContent); - expect(labels).toContain("Pin message"); - }); - test("right-clicking the message opens the same menu", async () => { const el = await mount({ items: textMessage(), diff --git a/packages/chat-ui/test/presence-stack.test.tsx b/packages/chat-ui/test/presence-stack.test.tsx index 23645ba771..206b0aaa80 100644 --- a/packages/chat-ui/test/presence-stack.test.tsx +++ b/packages/chat-ui/test/presence-stack.test.tsx @@ -78,6 +78,9 @@ function stubFetch() { if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) return json({ items: [] }); + if (/\/mailbox\/me\/threads/.test(path)) { + return json({ threads: [] }); + } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; } @@ -110,8 +113,12 @@ function mount(props: Parameters[0]) { }; } +// The workbench's own chat stream, not the mailbox live-update +// subscription `useWorkbenchFeed` also opens (CL-8174 slice 2b). function firstStream(): StubEventSource { - const instance = StubEventSource.instances[0]; + const instance = StubEventSource.instances.find((source) => + /\/chat\/workbenches\/[^/]+\/stream/.test(source.url), + ); if (instance === undefined) throw new Error("no stream connected"); return instance; } diff --git a/packages/chat-ui/test/reactions-and-pins.test.tsx b/packages/chat-ui/test/reactions-and-pins.test.tsx deleted file mode 100644 index 5d683a3b90..0000000000 --- a/packages/chat-ui/test/reactions-and-pins.test.tsx +++ /dev/null @@ -1,298 +0,0 @@ -// DOM tests for the reaction chip row and pin toggle the timeline -// renders per message, plus the pinned strip. `ReactionActions`/ -// `PinActions` are the mock boundary, the same way `BlockResponseActions` -// is for the poll card: a fake stands in for the host's fetch-backed -// toggle/pin calls, and the assertions are on what the click actually -// invoked — never on the click alone. -import { afterEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; -import { renderToStaticMarkup } from "react-dom/server"; - -import type { MessageItem, PinnedMessage } from "../src/api"; -import { PinnedStrip } from "../src/pinned-strip"; -import { WorkbenchTimeline, messageDomId } from "../src/timeline"; -import type { PinActions, ReactionActions } from "../src/timeline"; - -function messageWithReactions(): MessageItem[] { - return [ - { - id: "m1", - createdAt: "2026-01-01T00:00:00.000Z", - parts: [{ kind: "text", text: "ship it" }], - sender: { name: "Researcher", address: "researcher@agents.example" }, - reactions: [{ emoji: "👍", count: 2, reactedByMe: false }], - pinned: false, - }, - ]; -} - -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -afterEach(() => { - if (root !== null) act(() => root?.unmount()); - container?.remove(); - container = null; - root = null; -}); - -async function mount( - items: MessageItem[], - reactionActions?: ReactionActions, - pinActions?: PinActions, -) { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root?.render( - , - ); - }); - return container; -} - -describe("reaction chip row", () => { - test("with no reactionActions, no chip and no add-reaction trigger render at all", async () => { - const el = await mount(messageWithReactions()); - expect(el.querySelector(".chat-reaction-chip")).toBeNull(); - expect(el.querySelector(".chat-reaction-add")).toBeNull(); - }); - - test("an existing reaction renders as a chip with its count", async () => { - const calls: { messageId: string; emoji: string }[] = []; - const el = await mount(messageWithReactions(), { - onToggle: (messageId, emoji) => calls.push({ messageId, emoji }), - }); - - const chip = el.querySelector(".chat-reaction-chip") as HTMLButtonElement; - expect(chip).not.toBeNull(); - expect(chip.textContent).toContain("2"); - expect(chip.dataset["reacted"]).toBe("false"); - }); - - test("reaction row sits under the bubble inside the message body column", async () => { - const el = await mount(messageWithReactions(), { - onToggle: () => {}, - }); - - const body = el.querySelector(".chat-message-body"); - expect(body).not.toBeNull(); - if (body === null) return; - const bubble = body.querySelector(".chat-bubble"); - const actions = body.querySelector(".chat-message-actions"); - expect(bubble).not.toBeNull(); - expect(actions).not.toBeNull(); - if (bubble === null || actions === null) return; - // DOM order: bubble precedes the reaction row within the same body column. - const following = bubble.compareDocumentPosition(actions); - expect(following & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - }); - - test("clicking an existing chip toggles it — the host's onToggle, not local state", async () => { - const calls: { messageId: string; emoji: string }[] = []; - const el = await mount(messageWithReactions(), { - onToggle: (messageId, emoji) => calls.push({ messageId, emoji }), - }); - - const chip = el.querySelector(".chat-reaction-chip") as HTMLButtonElement; - await act(async () => chip.click()); - - expect(calls).toEqual([{ messageId: "m1", emoji: "👍" }]); - }); - - test("the add-reaction trigger opens a curated picker, and picking an emoji toggles it", async () => { - const calls: { messageId: string; emoji: string }[] = []; - const el = await mount(messageWithReactions(), { - onToggle: (messageId, emoji) => calls.push({ messageId, emoji }), - }); - - const trigger = el.querySelector(".chat-reaction-add") as HTMLButtonElement; - expect(el.querySelector(".chat-reaction-picker")).toBeNull(); - - await act(async () => trigger.click()); - const picker = el.querySelector(".chat-reaction-picker"); - expect(picker).not.toBeNull(); - - const options = el.querySelectorAll(".chat-reaction-picker-option"); - expect(options.length).toBeGreaterThan(1); - - await act(async () => (options[1] as HTMLButtonElement).click()); - expect(calls).toHaveLength(1); - expect(calls[0]?.messageId).toBe("m1"); - }); -}); - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -/** Radix's exit-animation handling runs through a couple of microtasks even - * with no real CSS animation configured — mirrors the identical helper in - * `test/message-hover-toolbar.test.tsx`. */ -async function flush(): Promise { - await act(async () => { - await sleep(0); - await sleep(0); - }); -} - -describe("pin toggle", () => { - // CL-6376: an unpinned message renders no persistent glyph at all — the - // pin toggle only mounts once a message is actually pinned (needing a - // visible way to unpin it). Pinning itself stays reachable through the - // ellipsis/context menu's own "Pin message" entry either way. - test("with no pinActions, no pin button renders", async () => { - const el = await mount(messageWithReactions()); - expect(el.querySelector(".chat-pin-toggle")).toBeNull(); - }); - - test("an unpinned message renders no pin toggle, even with pinActions wired", async () => { - const el = await mount(messageWithReactions(), undefined, { - onPin: () => undefined, - onUnpin: () => undefined, - }); - expect(el.querySelector(".chat-pin-toggle")).toBeNull(); - }); - - test("an unpinned message can still be pinned through the context menu's onPin", async () => { - const pinned: string[] = []; - const el = await mount(messageWithReactions(), undefined, { - onPin: (id) => pinned.push(id), - onUnpin: () => undefined, - }); - - const group = el.querySelector(".chat-message-group") as HTMLElement; - await act(async () => { - group.dispatchEvent( - new MouseEvent("contextmenu", { bubbles: true, cancelable: true }), - ); - }); - await flush(); - - const pinItem = Array.from( - document.querySelectorAll('[data-slot="menu-item"]'), - ).find((item) => item.textContent === "Pin message"); - expect(pinItem).not.toBeUndefined(); - await act(async () => pinItem?.click()); - - expect(pinned).toEqual(["m1"]); - }); - - test("a pinned message's pin button calls onUnpin", async () => { - const pinned: string[] = []; - const unpinned: string[] = []; - const items: MessageItem[] = [ - { ...messageWithReactions()[0], pinned: true } as MessageItem, - ]; - const el = await mount(items, undefined, { - onPin: (id) => pinned.push(id), - onUnpin: (id) => unpinned.push(id), - }); - - const button = el.querySelector(".chat-pin-toggle") as HTMLButtonElement; - expect(button.dataset["pinned"]).toBe("true"); - await act(async () => button.click()); - - expect(unpinned).toEqual(["m1"]); - expect(pinned).toEqual([]); - }); -}); - -describe("pinned strip", () => { - const pins: PinnedMessage[] = [ - { - id: "m1", - createdAt: "2026-01-01T00:00:00.000Z", - parts: [{ kind: "text", text: "important announcement here" }], - sender: { name: "Researcher", address: "researcher@agents.example" }, - pinnedBy: "prn_alice", - pinnedAt: "2026-01-01T00:01:00.000Z", - }, - ]; - - test("renders nothing with no pinned messages", () => { - const markup = renderToStaticMarkup( - undefined} - />, - ); - expect(markup).toBe(""); - }); - - test("renders nothing while loading or when pins are unavailable on this host", () => { - expect( - renderToStaticMarkup( - undefined} />, - ), - ).toBe(""); - expect( - renderToStaticMarkup( - undefined} - />, - ), - ).toBe(""); - }); - - test("a load failure renders an error strip — never the honest-empty silence (CL-6832)", () => { - const markup = renderToStaticMarkup( - undefined} - />, - ); - // Static markup escapes the apostrophe; the alert role is the - // empty-vs-error distinction the strip must keep. - expect(markup).toContain("Couldn't load pinned messages."); - expect(markup).toContain('role="alert"'); - expect(markup).toContain("chat-pinned-strip-error"); - expect(markup).not.toContain("chat-pinned-strip-item"); - }); - - test("renders a jump chip previewing the pinned message's text", () => { - const markup = renderToStaticMarkup( - undefined} - />, - ); - expect(markup).toContain("important announcement here"); - }); - - test("clicking a pin chip calls onJump with the message id", async () => { - const jumped: string[] = []; - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root?.render( - jumped.push(id)} - />, - ); - }); - - const chip = container.querySelector( - ".chat-pinned-strip-item", - ) as HTMLButtonElement; - await act(async () => chip.click()); - - expect(jumped).toEqual(["m1"]); - }); - - test("messageDomId is the timeline's own message group id — the strip's jump target actually exists", async () => { - const el = await mount(messageWithReactions()); - expect(el.querySelector(`#${messageDomId("m1")}`)).not.toBeNull(); - }); -}); diff --git a/packages/chat-ui/test/stream-apply.test.ts b/packages/chat-ui/test/stream-apply.test.ts index 6e0b73cb78..e51acf333c 100644 --- a/packages/chat-ui/test/stream-apply.test.ts +++ b/packages/chat-ui/test/stream-apply.test.ts @@ -1,303 +1,15 @@ -// CL-6328: every stream event applies straight into the query cache it -// describes, never a refetch. These are the pure cache-mutation rules -// `chat-workspace.tsx`'s stream handler calls into — exercised here against -// a real `QueryClient` (no DOM needed) so the dedupe/merge logic is proven -// independent of React. +// CL-6660: `ensureReplyThreadRow` is the pure fold `use-optimistic-sends.ts` +// calls to seed (or bump) a reply-thread row in the threads cache before +// navigation opens it. The `chat.message`/`chat.reaction`/`chat.pin` stream +// cache-application this file used to cover was retired with CL-8174 slice +// 2b — the feed now reads via `@corbits/mailbox`'s thread routes, and pins +// and reactions have no mailbox equivalent, so `applyStreamMessage`, +// `applyStreamReaction`, and `applyStreamPin` no longer exist. import { describe, expect, test } from "bun:test"; -import { QueryClient } from "@tanstack/react-query"; -import { - applyStreamMessage, - applyStreamPin, - applyStreamReaction, - chatMessagesQueryKey, - chatPinsQueryKey, - chatThreadsQueryKey, - ensureReplyThreadRow, -} from "../src/use-workbench-feed"; -import type { - MessageItem, - MessagesResponse, - PinnedMessage, - Workbench, - WorkbenchThreadRow, -} from "../src/api"; -import { workbenchesQueryKey } from "../src/api"; - -const TENANT = "tnt_1"; -const WORKBENCH = "wb_1"; - -function client() { - return new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, - }); -} - -const baseMessage: MessageItem = { - id: "m1", - createdAt: "2026-01-01T00:00:00.000Z", - parts: [{ kind: "text", text: "hello" }], - sender: { name: "Alice", address: "prn_alice@acme.example" }, -}; - -function seedList( - qc: QueryClient, - kind: "workbench" | "chat", - row: Workbench, -): void { - qc.setQueryData(workbenchesQueryKey(TENANT, kind), [row] as Workbench[]); -} - -describe("applyStreamMessage", () => { - test("appends a freshly published row into the messages cache", () => { - const qc = client(); - qc.setQueryData(chatMessagesQueryKey(TENANT, WORKBENCH), { - items: [], - } satisfies MessagesResponse); - - applyStreamMessage(qc, TENANT, WORKBENCH, baseMessage); - - const data = qc.getQueryData( - chatMessagesQueryKey(TENANT, WORKBENCH), - ); - expect(data?.items).toEqual([baseMessage]); - }); - - test("does nothing to an unloaded cache — never seeds a query nobody asked for", () => { - const qc = client(); - applyStreamMessage(qc, TENANT, WORKBENCH, baseMessage); - expect( - qc.getQueryData(chatMessagesQueryKey(TENANT, WORKBENCH)), - ).toBeUndefined(); - }); - - test("dedupes by id: the workbench's own echo of a message already in cache is a no-op", () => { - const qc = client(); - qc.setQueryData(chatMessagesQueryKey(TENANT, WORKBENCH), { - items: [baseMessage], - } satisfies MessagesResponse); - - applyStreamMessage(qc, TENANT, WORKBENCH, baseMessage); - - const data = qc.getQueryData( - chatMessagesQueryKey(TENANT, WORKBENCH), - ); - expect(data?.items).toHaveLength(1); - }); - - test("dedupes by clientId: the stream's echo of this reader's own optimistic send never doubles it (CL-6251 precedent)", () => { - const qc = client(); - const confirmedByOptimisticWrite: MessageItem = { - ...baseMessage, - id: "m_server", - clientId: "pending_1", - }; - qc.setQueryData(chatMessagesQueryKey(TENANT, WORKBENCH), { - items: [confirmedByOptimisticWrite], - } satisfies MessagesResponse); - - // The same row, as it arrives over the stream — same clientId, same id. - applyStreamMessage(qc, TENANT, WORKBENCH, confirmedByOptimisticWrite); - - const data = qc.getQueryData( - chatMessagesQueryKey(TENANT, WORKBENCH), - ); - expect(data?.items).toHaveLength(1); - }); - - test("bumps the owning thread's replyCount and lastActivityAt", () => { - const qc = client(); - qc.setQueryData(chatMessagesQueryKey(TENANT, WORKBENCH), { - items: [], - } satisfies MessagesResponse); - qc.setQueryData(chatThreadsQueryKey(TENANT, WORKBENCH), { - rootThreadId: "root", - items: [ - { - id: "thr_1", - kind: "reply" as const, - runRef: null, - title: null, - createdAt: "2026-01-01T00:00:00.000Z", - replyCount: 2, - lastActivityAt: "2026-01-01T00:00:00.000Z", - }, - ], - }); - - applyStreamMessage(qc, TENANT, WORKBENCH, { - ...baseMessage, - id: "m2", - threadId: "thr_1", - createdAt: "2026-01-01T00:05:00.000Z", - }); - - const threads = qc.getQueryData<{ - readonly items: readonly { - replyCount: number; - lastActivityAt: string | null; - }[]; - }>(chatThreadsQueryKey(TENANT, WORKBENCH)); - expect(threads?.items[0]?.replyCount).toBe(3); - expect(threads?.items[0]?.lastActivityAt).toBe("2026-01-01T00:05:00.000Z"); - }); - - test("a root-feed message (no threadId) never touches the threads cache", () => { - const qc = client(); - qc.setQueryData(chatMessagesQueryKey(TENANT, WORKBENCH), { - items: [], - } satisfies MessagesResponse); - const threadsBefore = { rootThreadId: "root", items: [] }; - qc.setQueryData(chatThreadsQueryKey(TENANT, WORKBENCH), threadsBefore); - - applyStreamMessage(qc, TENANT, WORKBENCH, baseMessage); - - expect( - qc.getQueryData( - chatThreadsQueryKey(TENANT, WORKBENCH), - ), - ).toBe(threadsBefore); - }); - - // CL-6795: sidebar list cache must settle on stream apply — never keep a - // stale greeting after a newer user message, and never blank on join/event. - test("a newer user message settles the workbench-list preview over a stale greeting (CL-6795)", () => { - const qc = client(); - qc.setQueryData(chatMessagesQueryKey(TENANT, WORKBENCH), { - items: [], - } satisfies MessagesResponse); - seedList(qc, "chat", { - id: WORKBENCH, - title: "Myra", - kind: "chat", - pinned: false, - participants: [], - lastActivityAt: "2026-01-01T00:00:00.000Z", - preview: "Hi — I'm Myra. What are we working on?", - }); - - applyStreamMessage(qc, TENANT, WORKBENCH, { - id: "m_user", - createdAt: "2026-01-01T00:01:00.000Z", - parts: [{ kind: "text", text: "draft the agenda for Monday" }], - sender: { name: null, address: "prn_ada@acme.example" }, - }); - - const list = qc.getQueryData( - workbenchesQueryKey(TENANT, "chat"), - ); - expect(list?.[0]?.preview).toBe("draft the agenda for Monday"); - expect(list?.[0]?.lastActivityAt).toBe("2026-01-01T00:01:00.000Z"); - expect(list?.[0]?.preview).not.toMatch(/I'm Myra/i); - }); - - test("a join/event row bumps lastActivityAt but keeps the prior preview, never blanks (CL-6795)", () => { - const qc = client(); - qc.setQueryData(chatMessagesQueryKey(TENANT, WORKBENCH), { - items: [], - } satisfies MessagesResponse); - seedList(qc, "workbench", { - id: WORKBENCH, - title: "Ops", - kind: "workbench", - pinned: false, - participants: [], - lastActivityAt: "2026-01-01T00:00:00.000Z", - preview: "let's pull Scout in", - }); - - applyStreamMessage(qc, TENANT, WORKBENCH, { - id: "m_join", - createdAt: "2026-01-01T00:02:00.000Z", - parts: [ - { - kind: "event", - event: "workbench.agent-joined", - data: { address: "run_scout@acme.example" }, - }, - ], - sender: { name: null, address: "run_scout@acme.example" }, - }); - - const list = qc.getQueryData( - workbenchesQueryKey(TENANT, "workbench"), - ); - expect(list?.[0]?.lastActivityAt).toBe("2026-01-01T00:02:00.000Z"); - expect(list?.[0]?.preview).toBe("let's pull Scout in"); - }); - - test("patches threadId onto an already-cached optimistic confirm (CL-6660)", () => { - const qc = client(); - const optimistic: MessageItem = { - ...baseMessage, - id: "m_server", - clientId: "pending_1", - }; - qc.setQueryData(chatMessagesQueryKey(TENANT, WORKBENCH), { - items: [optimistic], - } satisfies MessagesResponse); - qc.setQueryData(chatThreadsQueryKey(TENANT, WORKBENCH), { - rootThreadId: "root", - items: [] as WorkbenchThreadRow[], - }); - - applyStreamMessage(qc, TENANT, WORKBENCH, { - ...optimistic, - threadId: "thr_reply", - createdAt: "2026-01-01T00:05:00.000Z", - }); - - const messages = qc.getQueryData( - chatMessagesQueryKey(TENANT, WORKBENCH), - ); - expect(messages?.items).toHaveLength(1); - expect(messages?.items[0]?.threadId).toBe("thr_reply"); - - const threads = qc.getQueryData<{ - readonly items: readonly WorkbenchThreadRow[]; - }>(chatThreadsQueryKey(TENANT, WORKBENCH)); - expect(threads?.items).toHaveLength(1); - expect(threads?.items[0]?.id).toBe("thr_reply"); - expect(threads?.items[0]?.replyCount).toBe(1); - }); - - test("an echo of an already-threaded optimistic write does not double replyCount (CL-6660)", () => { - const qc = client(); - const confirmed: MessageItem = { - ...baseMessage, - id: "m_server", - clientId: "pending_1", - threadId: "thr_reply", - }; - qc.setQueryData(chatMessagesQueryKey(TENANT, WORKBENCH), { - items: [confirmed], - } satisfies MessagesResponse); - qc.setQueryData(chatThreadsQueryKey(TENANT, WORKBENCH), { - rootThreadId: "root", - items: [ - { - id: "thr_reply", - kind: "reply" as const, - parentMessageId: "m_parent", - parentThreadId: "root", - runRef: null, - title: null, - createdAt: "2026-01-01T00:00:00.000Z", - replyCount: 1, - lastActivityAt: "2026-01-01T00:00:00.000Z", - }, - ], - }); - - applyStreamMessage(qc, TENANT, WORKBENCH, confirmed); - - const threads = qc.getQueryData<{ - readonly items: readonly { replyCount: number }[]; - }>(chatThreadsQueryKey(TENANT, WORKBENCH)); - expect(threads?.items[0]?.replyCount).toBe(1); - }); -}); +import { ensureReplyThreadRow } from "../src/use-workbench-feed"; +import type { WorkbenchThreadRow } from "../src/api"; describe("ensureReplyThreadRow (CL-6660)", () => { const empty: { @@ -352,135 +64,3 @@ describe("ensureReplyThreadRow (CL-6660)", () => { expect(kept.items[0]?.parentMessageId).toBe("m_parent"); }); }); - -describe("applyStreamReaction", () => { - test("adds a fresh emoji entry when the message has no reactions yet", () => { - const qc = client(); - qc.setQueryData(chatMessagesQueryKey(TENANT, WORKBENCH), { - items: [baseMessage], - } satisfies MessagesResponse); - - applyStreamReaction( - qc, - TENANT, - WORKBENCH, - { messageId: "m1", emoji: "👍", principalId: "prn_bob", added: true }, - "prn_alice", - ); - - const data = qc.getQueryData( - chatMessagesQueryKey(TENANT, WORKBENCH), - ); - expect(data?.items[0]?.reactions).toEqual([ - { emoji: "👍", count: 1, reactedByMe: false }, - ]); - }); - - test("increments an existing emoji's count and flips reactedByMe for the viewer's own toggle", () => { - const qc = client(); - qc.setQueryData(chatMessagesQueryKey(TENANT, WORKBENCH), { - items: [ - { - ...baseMessage, - reactions: [{ emoji: "👍", count: 1, reactedByMe: false }], - }, - ], - } satisfies MessagesResponse); - - applyStreamReaction( - qc, - TENANT, - WORKBENCH, - { messageId: "m1", emoji: "👍", principalId: "prn_alice", added: true }, - "prn_alice", - ); - - const data = qc.getQueryData( - chatMessagesQueryKey(TENANT, WORKBENCH), - ); - expect(data?.items[0]?.reactions).toEqual([ - { emoji: "👍", count: 2, reactedByMe: true }, - ]); - }); - - test("removing the last reactor on an emoji drops the entry entirely", () => { - const qc = client(); - qc.setQueryData(chatMessagesQueryKey(TENANT, WORKBENCH), { - items: [ - { - ...baseMessage, - reactions: [{ emoji: "👍", count: 1, reactedByMe: true }], - }, - ], - } satisfies MessagesResponse); - - applyStreamReaction( - qc, - TENANT, - WORKBENCH, - { messageId: "m1", emoji: "👍", principalId: "prn_alice", added: false }, - "prn_alice", - ); - - const data = qc.getQueryData( - chatMessagesQueryKey(TENANT, WORKBENCH), - ); - expect(data?.items[0]?.reactions).toEqual([]); - }); -}); - -describe("applyStreamPin", () => { - const pinnable: MessageItem = { ...baseMessage, pinned: false }; - - test("pinning flips the message's own flag and adds it to the pins cache from the messages cache row", () => { - const qc = client(); - qc.setQueryData(chatMessagesQueryKey(TENANT, WORKBENCH), { - items: [pinnable], - } satisfies MessagesResponse); - qc.setQueryData(chatPinsQueryKey(TENANT, WORKBENCH), [] as PinnedMessage[]); - - applyStreamPin(qc, TENANT, WORKBENCH, { - messageId: "m1", - pinned: true, - pinnedBy: "prn_alice", - pinnedAt: "2026-01-01T00:02:00.000Z", - }); - - const messages = qc.getQueryData( - chatMessagesQueryKey(TENANT, WORKBENCH), - ); - expect(messages?.items[0]?.pinned).toBe(true); - const pins = qc.getQueryData( - chatPinsQueryKey(TENANT, WORKBENCH), - ); - expect(pins).toEqual([ - { - ...pinnable, - pinned: true, - pinnedBy: "prn_alice", - pinnedAt: "2026-01-01T00:02:00.000Z", - }, - ]); - }); - - test("unpinning removes it from the pins cache without a second GET /pins", () => { - const qc = client(); - const pinnedRow: PinnedMessage = { - ...pinnable, - pinned: true, - pinnedBy: "prn_alice", - pinnedAt: "2026-01-01T00:02:00.000Z", - }; - qc.setQueryData(chatMessagesQueryKey(TENANT, WORKBENCH), { - items: [pinnedRow], - } satisfies MessagesResponse); - qc.setQueryData(chatPinsQueryKey(TENANT, WORKBENCH), [pinnedRow]); - - applyStreamPin(qc, TENANT, WORKBENCH, { messageId: "m1", pinned: false }); - - const pins = qc.getQueryData( - chatPinsQueryKey(TENANT, WORKBENCH), - ); - expect(pins).toEqual([]); - }); -}); diff --git a/packages/chat-ui/test/stream-message-parse-drop.test.tsx b/packages/chat-ui/test/stream-message-parse-drop.test.tsx index 9b3b83e09e..296305597a 100644 --- a/packages/chat-ui/test/stream-message-parse-drop.test.tsx +++ b/packages/chat-ui/test/stream-message-parse-drop.test.tsx @@ -69,7 +69,8 @@ function stubFetch() { const method = init?.method ?? "GET"; if ( method === "GET" && - /\/chat\/workbenches\/[^/]+\/(messages|threads|pins)(\?|$)/.test(path) + (/\/chat\/workbenches\/[^/]+\/(messages|threads|pins)(\?|$)/.test(path) || + /\/mailbox\/me\/threads/.test(path)) ) { feedRefetchCount += 1; } @@ -103,6 +104,9 @@ function stubFetch() { ) { return json({}); } + if (/\/mailbox\/me\/threads/.test(path)) { + return json({ threads: [] }); + } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; } @@ -134,8 +138,12 @@ function mount(props: Parameters[0]) { }; } +// The workbench's own chat stream, not the mailbox live-update +// subscription `useWorkbenchFeed` also opens (CL-8174 slice 2b). function firstStream(): StubEventSource { - const instance = StubEventSource.instances[0]; + const instance = StubEventSource.instances.find((source) => + /\/chat\/workbenches\/[^/]+\/stream/.test(source.url), + ); if (instance === undefined) throw new Error("no stream connected"); return instance; } diff --git a/packages/chat-ui/test/system-join-edge.test.tsx b/packages/chat-ui/test/system-join-edge.test.tsx index c61ec6da7e..1e8f71977c 100644 --- a/packages/chat-ui/test/system-join-edge.test.tsx +++ b/packages/chat-ui/test/system-join-edge.test.tsx @@ -11,7 +11,6 @@ import type { Root } from "react-dom/client"; import type { MessageItem } from "../src/api"; import type { CurrentUser } from "../src/timeline"; import { WorkbenchTimeline } from "../src/timeline"; -import type { PinActions, ReactionActions } from "../src/timeline"; let container: HTMLDivElement | null = null; let root: Root | null = null; @@ -25,11 +24,6 @@ afterEach(() => { const socialActions = { onOpenThread: () => undefined, - reactionActions: { onToggle: () => undefined } satisfies ReactionActions, - pinActions: { - onPin: () => undefined, - onUnpin: () => undefined, - } satisfies PinActions, }; async function mount(items: readonly MessageItem[], currentUser?: CurrentUser) { @@ -41,8 +35,6 @@ async function mount(items: readonly MessageItem[], currentUser?: CurrentUser) { , ); diff --git a/packages/chat-ui/test/system-row-chrome.test.tsx b/packages/chat-ui/test/system-row-chrome.test.tsx index 02c028b7f6..fd41165320 100644 --- a/packages/chat-ui/test/system-row-chrome.test.tsx +++ b/packages/chat-ui/test/system-row-chrome.test.tsx @@ -8,7 +8,6 @@ import type { Root } from "react-dom/client"; import type { MessageItem } from "../src/api"; import { WorkbenchTimeline } from "../src/timeline"; -import type { PinActions, ReactionActions } from "../src/timeline"; let container: HTMLDivElement | null = null; let root: Root | null = null; @@ -22,11 +21,6 @@ afterEach(() => { const socialActions = { onOpenThread: () => undefined, - reactionActions: { onToggle: () => undefined } satisfies ReactionActions, - pinActions: { - onPin: () => undefined, - onUnpin: () => undefined, - } satisfies PinActions, }; async function mount( @@ -41,8 +35,6 @@ async function mount( { const toolbar = el.querySelector(".chat-hover-toolbar"); expect(toolbar).not.toBeNull(); - expect(toolbar?.querySelector(".chat-reaction-add")).not.toBeNull(); expect(toolbar?.querySelector(".chat-hover-reply")).not.toBeNull(); expect(toolbar?.querySelector(".chat-hover-ellipsis")).not.toBeNull(); }); diff --git a/packages/chat-ui/test/team-avatar-stack.test.tsx b/packages/chat-ui/test/team-avatar-stack.test.tsx index 57847e697a..31091b78c7 100644 --- a/packages/chat-ui/test/team-avatar-stack.test.tsx +++ b/packages/chat-ui/test/team-avatar-stack.test.tsx @@ -94,6 +94,9 @@ function stubFetch(workbenchWire: { if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) return json({ items: [] }); + if (/\/mailbox\/me\/threads/.test(path)) { + return json({ threads: [] }); + } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; } @@ -125,8 +128,12 @@ function mount(props: Parameters[0]) { }; } +// The workbench's own chat stream, not the mailbox live-update +// subscription `useWorkbenchFeed` also opens (CL-8174 slice 2b). function firstStream(): StubEventSource { - const instance = StubEventSource.instances[0]; + const instance = StubEventSource.instances.find((source) => + /\/chat\/workbenches\/[^/]+\/stream/.test(source.url), + ); if (instance === undefined) throw new Error("no stream connected"); return instance; } diff --git a/packages/chat-ui/test/use-workbench-feed-refresh-scoping.test.tsx b/packages/chat-ui/test/use-workbench-feed-refresh-scoping.test.tsx index 96cb30919d..b6c541c1d3 100644 --- a/packages/chat-ui/test/use-workbench-feed-refresh-scoping.test.tsx +++ b/packages/chat-ui/test/use-workbench-feed-refresh-scoping.test.tsx @@ -29,9 +29,10 @@ function stubFetch() { status: 200, headers: { "content-type": "application/json" }, }); - if (/\/threads$/.test(path)) return json({ rootThreadId: "", items: [] }); - if (/\/messages/.test(path)) return json({ items: [] }); - if (/\/pins$/.test(path)) return json({ items: [] }); + // The feed now reads via `@corbits/mailbox`'s thread routes + // (CL-8174 slice 2b) — an empty thread list means neither query + // ever needs to read a thread's own messages. + if (/\/mailbox\/me\/threads/.test(path)) return json({ threads: [] }); throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; } diff --git a/packages/chat-ui/test/use-workbench-stream.test.tsx b/packages/chat-ui/test/use-workbench-stream.test.tsx index e492caaeb0..de8bf70bd4 100644 --- a/packages/chat-ui/test/use-workbench-stream.test.tsx +++ b/packages/chat-ui/test/use-workbench-stream.test.tsx @@ -187,26 +187,6 @@ describe("useWorkbenchStream (reconnect + poll wiring)", () => { harness.unmount(); }); - test("chat.reaction and chat.pin events are forwarded to the caller — live, not just via poll", async () => { - const harness = mount({ - pollMs: 1000, - baseDelayMs: 5000, - maxDelayMs: 5000, - }); - harness.latest().open(); - - harness - .latest() - .emit("chat.reaction", { messageId: "m1", emoji: "👍", added: true }); - harness.latest().emit("chat.pin", { messageId: "m1", pinned: true }); - - expect(harness.events()).toEqual([ - ["chat.reaction", { messageId: "m1", emoji: "👍", added: true }], - ["chat.pin", { messageId: "m1", pinned: true }], - ]); - harness.unmount(); - }); - test("invalid JSON on chat.message is not forwarded and polls immediately (CL-6837)", async () => { const parseErrors: string[] = []; const harness = mount( diff --git a/packages/chat-ui/test/zero-refetch-on-stream-event.test.tsx b/packages/chat-ui/test/zero-refetch-on-stream-event.test.tsx index cff1559b65..177647271d 100644 --- a/packages/chat-ui/test/zero-refetch-on-stream-event.test.tsx +++ b/packages/chat-ui/test/zero-refetch-on-stream-event.test.tsx @@ -74,7 +74,8 @@ function stubFetch() { const method = init?.method ?? "GET"; if ( method === "GET" && - /\/chat\/workbenches\/[^/]+\/(messages|threads|pins)(\?|$)/.test(path) + (/\/chat\/workbenches\/[^/]+\/(messages|threads|pins)(\?|$)/.test(path) || + /\/mailbox\/me\/threads/.test(path)) ) { feedRefetchCount += 1; } @@ -114,6 +115,9 @@ function stubFetch() { ) { return json({}); } + if (/\/mailbox\/me\/threads/.test(path)) { + return json({ threads: [] }); + } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; } @@ -145,42 +149,27 @@ function mount(props: Parameters[0]) { }; } +// The workbench's own chat stream, not the mailbox live-update +// subscription `useWorkbenchFeed` also opens (CL-8174 slice 2b). function firstStream(): StubEventSource { - const instance = StubEventSource.instances[0]; + const instance = StubEventSource.instances.find((source) => + /\/chat\/workbenches\/[^/]+\/stream/.test(source.url), + ); if (instance === undefined) throw new Error("no stream connected"); return instance; } describe("zero refetches on a stream event, post-hydration (CL-6328 §6/1.2)", () => { - test("a streamed chat.message renders with no fetch call beyond mount-hydration", async () => { - stubFetch(); - const harness = mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - const feedRefetchCountAfterHydration = feedRefetchCount; - expect(feedRefetchCountAfterHydration).toBeGreaterThan(0); - - act(() => { - firstStream().emit("chat.message", { - id: "m_streamed", - workbenchId: "ch_1", - ref: { kind: "workbench", id: "ch_1" }, - createdAt: "2026-01-01T00:00:10.000Z", - threadId: null, - sender: { name: "Bob", address: "prn_bob@acme.example" }, - parts: [{ kind: "text", text: "streamed in live" }], - }); - }); - await harness.settle(); - - expect(feedRefetchCount).toBe(feedRefetchCountAfterHydration); - expect(harness.container.textContent).toContain("streamed in live"); - harness.unmount(); - }); - + // CL-8174 slice 2b retires this suite's other test — "a streamed + // chat.message renders with no fetch call beyond mount-hydration" — + // along with `applyStreamMessage`: the feed now reads via the mailbox, + // and a peer's `chat.message` is no longer folded straight into the + // messages cache. Its own connection instead relies on the mailbox's + // `/me/inbox/events` live-update subscription (`useWorkbenchFeed`) to + // notice the fan-out landing and refetch — a real (small) refetch, + // not the zero this test asserted. The confirm-echo test below is + // untouched: it covers this reader's own optimistic send, a write + // straight into the cache independent of where reads come from. test("a composer send's own confirm echo (matching clientId) never doubles the bubble or triggers a refetch", async () => { stubFetch(); const harness = mount({ diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index f218bcb03d..def629d97d 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -1915,192 +1915,6 @@ describe("GET /workbenches/:id/blobs/:blobId", () => { }); }); -describe("threads — root feed vs reply membership (4a)", () => { - test("root-thread messages exclude reply-thread posts; open reply still works", async () => { - const deps = buildDeps({ threads: createInMemoryThreadStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const rootPost = await app.request( - `/workbenches/${workbench.id}/messages`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "root note" }] }), - }, - ); - expect(rootPost.status).toBe(201); - const rootSent = (await rootPost.json()) as { - id: string; - threadId: string; - }; - - const replyPost = await app.request( - `/workbenches/${workbench.id}/messages`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "reply note" }], - inReplyToMessageId: rootSent.id, - }), - }, - ); - expect(replyPost.status).toBe(201); - const replySent = (await replyPost.json()) as { - id: string; - threadId: string; - }; - expect(replySent.threadId).not.toBe(rootSent.threadId); - - // Full mailbox still lists both (platform mail is unfiltered). - const allMail = await app.request(`/workbenches/${workbench.id}/messages`); - const allBody = (await allMail.json()) as { items: { id: string }[] }; - expect(allBody.items.map((i) => i.id).sort()).toEqual( - [rootSent.id, replySent.id].sort(), - ); - - // Root-thread feed is root membership only. - const rootFeed = await app.request( - `/workbenches/${workbench.id}/threads/${rootSent.threadId}/messages`, - ); - expect(rootFeed.status).toBe(200); - const rootBody = (await rootFeed.json()) as { - items: { id: string; parts: Part[] }[]; - }; - expect(rootBody.items.map((i) => i.id)).toEqual([rootSent.id]); - expect(rootBody.items[0]?.parts).toEqual([ - { kind: "text", text: "root note" }, - ]); - - // Open-thread view still returns reply-thread membership. - const replyFeed = await app.request( - `/workbenches/${workbench.id}/threads/${replySent.threadId}/messages`, - ); - expect(replyFeed.status).toBe(200); - const replyBody = (await replyFeed.json()) as { - items: { id: string; parts: Part[] }[]; - }; - expect(replyBody.items.map((i) => i.id)).toEqual([replySent.id]); - expect(replyBody.items[0]?.parts).toEqual([ - { kind: "text", text: "reply note" }, - ]); - - // listThreads exposes rootThreadId for the client root feed. - const threadsRes = await app.request( - `/workbenches/${workbench.id}/threads`, - ); - expect(threadsRes.status).toBe(200); - const threadsBody = (await threadsRes.json()) as { - rootThreadId: string; - items: { id: string; kind: string }[]; - }; - expect(threadsBody.rootThreadId).toBe(rootSent.threadId); - expect( - threadsBody.items.some( - (t) => t.id === replySent.threadId && t.kind === "reply", - ), - ).toBe(true); - }); - - // CL-6080: the root thread IS the workbench feed, so a message that - // carries no thread membership belongs to it — the contract - // `workbench_thread_messages` states ("root feed by default"). Every - // agent-originated message reaches the workbench through - // `postRoomMessage` alone (`chat-orchestrator`'s `postReply` for a - // `connector.reply` event, its approve-block and - // finalized-turn-artifact posters, `workbench-service`'s join/leave - // notices): none of them go through `POST /messages`, the only - // caller that assigns membership. Listing the root feed by - // membership rows alone therefore hid every one of them — a fresh - // chat's very first agent reply included, which is what the browser - // walkthrough sees as silence after "hi". - test("a reply posted straight onto the timeline, never through POST /messages, still lands in the root feed", async () => { - const deps = buildDeps({ threads: createInMemoryThreadStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const asked = await sendText(app, workbench.id, "hi"); - expect(asked.status).toBe(201); - const askedSent = (await asked.json()) as { - id: string; - threadId: string; - }; - - const replied = await postRoomMessage( - { roomMessages: deps.roomMessages, publish: () => undefined }, - { - tenantId: TENANT.id, - workbenchId: workbench.id, - sender: { name: null, address: "run_agent1@acme.example" }, - runId: "run_agent1", - parts: [{ kind: "text", text: "hello back" }], - }, - ); - - const rootFeed = await app.request( - `/workbenches/${workbench.id}/threads/${askedSent.threadId}/messages`, - ); - expect(rootFeed.status).toBe(200); - const rootBody = (await rootFeed.json()) as { - items: { id: string; parts: Part[] }[]; - }; - expect(rootBody.items.map((i) => i.id).sort()).toEqual( - [askedSent.id, replied.id].sort(), - ); - expect(rootBody.items.find((i) => i.id === replied.id)?.parts).toEqual([ - { kind: "text", text: "hello back" }, - ]); - }); - - test("an unassigned message stays out of a reply thread's own feed", async () => { - const deps = buildDeps({ threads: createInMemoryThreadStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const asked = await sendText(app, workbench.id, "hi"); - const askedSent = (await asked.json()) as { id: string }; - const inThread = await app.request( - `/workbenches/${workbench.id}/messages`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "in the thread" }], - inReplyToMessageId: askedSent.id, - }), - }, - ); - const threadSent = (await inThread.json()) as { - id: string; - threadId: string; - }; - - await postRoomMessage( - { roomMessages: deps.roomMessages, publish: () => undefined }, - { - tenantId: TENANT.id, - workbenchId: workbench.id, - sender: { name: null, address: "run_agent1@acme.example" }, - runId: "run_agent1", - parts: [{ kind: "text", text: "workbench-level reply" }], - }, - ); - - const replyFeed = await app.request( - `/workbenches/${workbench.id}/threads/${threadSent.threadId}/messages`, - ); - const replyBody = (await replyFeed.json()) as { items: { id: string }[] }; - expect(replyBody.items.map((i) => i.id)).toEqual([threadSent.id]); - }); -}); - describe("POST /workbenches/:id/threads/fork — two-level cap (CL-5908, CL-5948)", () => { test("forking a message inside a thread opens a depth-2 sub-thread, carrying the origin message id", async () => { const deps = buildDeps({ threads: createInMemoryThreadStore() }); From 57403fceaefe9e4321d0f61fdd33d6f2492d507c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 12:47:11 -0700 Subject: [PATCH 2/2] feat(chat-ui)!: feed reads from @corbits/mailbox threads; pins and reactions removed (CL-8174) use-workbench-feed now loads a room's messages and reply-thread rows by listing and reading @corbits/mailbox threads instead of @corbits/chat's message/thread routes, and its live-update subscription follows the mailbox's own SSE stream. Pins and reactions are removed from chat-ui entirely (no server-side change) since the feed they hung off no longer exists in this shape. Every mailbox read stays behind the single adapter in packages/chat-ui/src/mailbox-timeline.ts: it makes its own fetch() calls and validates responses with local arktype schemas rather than importing @corbits/inbox/client, whose value-level re-exports pull @corbits/mailbox's server-only migrations.ts (a node:crypto user) into the browser bundle and break the Vite build. Per the owner's ruling that @corbits/mailbox is a temporary, shrinking surface over Interchange's native mailbox, no other file in this package touches the mailbox response shape, so a later swap to the native thread shape is a one-file change. Deletes the now-uncalled @corbits/chat thread-messages read route (GET /workbenches/:id/threads/:threadId/messages) and its chat-ui pinned-strip/reaction-chip UI and wire types. Known regressions, follow-up needed: - Forking a message into a depth-2 sub-thread still targets the old @corbits/chat thread store, whose ids have no relationship to the new mailbox-derived thread ids; the fork/sub-thread affordance is disconnected until it's ported to mailbox threads too. - Block-kind message parts (approve/poll/form/connect-github/ connect-service cards) cannot round-trip through the mailbox: a mail body is plain text, so a reloaded page loses any persisted block content. - The mailbox live-update subscription refreshes the whole feed on any event rather than re-reading only the affected thread, since the SSE payload carries just a message id/op, not a thread root. --- bun.lock | 27 +- packages/chat-ui/package.json | 2 - packages/chat-ui/src/api.ts | 102 ----- packages/chat-ui/src/chat-workspace.tsx | 111 +---- packages/chat-ui/src/index.ts | 15 - packages/chat-ui/src/mailbox-timeline.ts | 216 ++++++++- packages/chat-ui/src/pinned-strip.tsx | 87 ---- packages/chat-ui/src/strings.ts | 14 - packages/chat-ui/src/timeline.tsx | 252 +---------- packages/chat-ui/src/use-workbench-feed.ts | 436 +++---------------- packages/chat-ui/src/use-workbench-stream.ts | 2 - packages/chat-ui/src/wire/reaction-emoji.ts | 21 - packages/chat-ui/src/wire/stream-events.ts | 22 +- packages/chat/src/routes.ts | 88 ---- 14 files changed, 311 insertions(+), 1084 deletions(-) delete mode 100644 packages/chat-ui/src/pinned-strip.tsx delete mode 100644 packages/chat-ui/src/wire/reaction-emoji.ts diff --git a/bun.lock b/bun.lock index 253f44621a..9254e7d153 100644 --- a/bun.lock +++ b/bun.lock @@ -438,7 +438,6 @@ "@corbits/icons": "workspace:*", "@corbits/inference-settings": "workspace:*", "@corbits/jimmy-agent": "workspace:*", - "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#bc964040fa42ffe4e5d5004f87edaab6486e5d60", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", "@tanstack/react-query": "catalog:", "arktype": "catalog:", @@ -3337,8 +3336,22 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@corbits/artifact-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/assistant-workflow/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + + "@corbits/diligence-brief-workflow/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + + "@corbits/inbox/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#bc96404", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-bc96404", "sha512-NEeNP2XsjIKOUbRr5szrCDWAYPQSmMNSu33ODyLUCiXdnq12HYWYG/C+0DcAMELlY+9Ktqf543Kg/Qb22NtjWQ=="], + "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/scout-agent/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + + "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3359,6 +3372,14 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="], + "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#bc96404", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-bc96404", "sha512-NEeNP2XsjIKOUbRr5szrCDWAYPQSmMNSu33ODyLUCiXdnq12HYWYG/C+0DcAMELlY+9Ktqf543Kg/Qb22NtjWQ=="], + + "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + + "@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=="], + + "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "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=="], @@ -3407,6 +3428,8 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@corbits/inbox/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -3453,6 +3476,8 @@ "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], diff --git a/packages/chat-ui/package.json b/packages/chat-ui/package.json index 404e04a86d..b21f492c70 100644 --- a/packages/chat-ui/package.json +++ b/packages/chat-ui/package.json @@ -15,7 +15,6 @@ "./wire/participants": "./src/wire/participants.ts", "./wire/display-name": "./src/wire/display-name.ts", "./wire/agent-address": "./src/wire/agent-address.ts", - "./wire/reaction-emoji": "./src/wire/reaction-emoji.ts", "./wire/workbench-host-naming": "./src/wire/workbench-host-naming.ts", "./wire/stream-events": "./src/wire/stream-events.ts", "./wire/consumer-inference-text": "./src/wire/consumer-inference-text.ts" @@ -33,7 +32,6 @@ "@tanstack/react-query": "catalog:", "@corbits/connections": "workspace:*", "@corbits/jimmy-agent": "workspace:*", - "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#bc964040fa42ffe4e5d5004f87edaab6486e5d60", "arktype": "catalog:", "@corbits/icons": "workspace:*", "react": "^19.2.0", diff --git a/packages/chat-ui/src/api.ts b/packages/chat-ui/src/api.ts index 8953eff599..eece65a95d 100644 --- a/packages/chat-ui/src/api.ts +++ b/packages/chat-ui/src/api.ts @@ -40,9 +40,6 @@ export type { OnboardingStepLabel, WorkbenchOnboardingStep, } from "./wire/blocks"; -export { REACTION_EMOJI } from "./wire/reaction-emoji"; -export type { ReactionEmoji } from "./wire/reaction-emoji"; - export const WorkbenchKind = type("'workbench' | 'chat'"); export type WorkbenchKind = typeof WorkbenchKind.infer; @@ -519,88 +516,6 @@ export function sendMessage( ); } -/** - * Toggles this signed-in principal's reaction with `emoji` on a - * message — `POST .../reactions/toggle` (see - * `packages/chat/src/routes.ts`). Returns the emoji's fresh summary - * (count and whether this principal is now among the reactors); the - * caller re-renders from this rather than assuming its own optimistic - * guess, the same anti-drift rule `submitPoll`'s live tally follows. - */ -export function toggleReaction( - tenantId: string, - workbenchId: string, - messageId: string, - emoji: string, -): Promise { - return request( - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/messages/${messageId}/reactions/toggle`, - ReactionSummaryWire, - { method: "POST", body: JSON.stringify({ emoji }) }, - ); -} - -const PinnedWire = type({ - messageId: "string", - pinnedBy: "string", - pinnedAt: "string", -}); -export type Pinned = typeof PinnedWire.infer; - -function pinPath(tenantId: string, workbenchId: string, messageId: string) { - return `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/messages/${messageId}/pin`; -} - -export function pinMessage( - tenantId: string, - workbenchId: string, - messageId: string, -): Promise { - return request(pinPath(tenantId, workbenchId, messageId), PinnedWire, { - method: "POST", - }); -} - -export async function unpinMessage( - tenantId: string, - workbenchId: string, - messageId: string, -): Promise { - const response = await fetch(pinPath(tenantId, workbenchId, messageId), { - method: "DELETE", - }); - if (response.status === 401) { - throw new UnauthenticatedError(); - } - if (!response.ok) { - throw new ChatApiError( - `The server answered ${response.status} for ${pinPath(tenantId, workbenchId, messageId)}.`, - response.status, - ); - } -} - -// A pinned message's own content, for the pinned strip's preview — the -// same `MessageItem` shape plus who pinned it and when. See `GET -// /workbenches/:id/pins` in `packages/chat/src/routes.ts`. -const PinnedMessageWire = MessageItem.and({ - pinnedBy: "string", - pinnedAt: "string", -}); -export type PinnedMessage = typeof PinnedMessageWire.infer; - -const PinnedMessagesResponse = type({ items: PinnedMessageWire.array() }); - -export function listPinnedMessages( - tenantId: string, - workbenchId: string, -): Promise { - return request( - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/pins`, - PinnedMessagesResponse, - ).then((page) => page.items); -} - // `parentThreadId` is the thread this one hangs directly off: null for the // root thread, the root thread's id for a depth-1 thread, a depth-1 // thread's id for a depth-2 sub-thread. Two levels, stop — see @@ -648,23 +563,6 @@ export function listThreads( ); } -const ThreadMessagesResponse = type({ - thread: WorkbenchThread, - items: MessageItem.array(), -}); -export type ThreadMessagesResponse = typeof ThreadMessagesResponse.infer; - -export function listThreadMessages( - tenantId: string, - workbenchId: string, - threadId: string, -): Promise { - return request( - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/threads/${threadId}/messages`, - ThreadMessagesResponse, - ); -} - /** * A first-class fork: spawn a sub-thread rooted at any message inside a * thread — something Slack doesn't have (CL-5948). Idempotent per origin diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 914c0556d5..c53b3cd365 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -39,9 +39,6 @@ import { addAgentCapability, refreshWorkbenchAgent, pingWorkbenchPresence, - pinMessage, - toggleReaction, - unpinMessage, workbenchStreamUrl, isKnownWorkbenchKind, WORKBENCHES_MUTATED_STREAM_TYPE, @@ -59,7 +56,6 @@ import { resolveBringInLists, } from "./mentions"; import type { BringInListFailure, BringInMember } from "./mentions"; -import { PinnedStrip } from "./pinned-strip"; import { SLASH_COMMANDS } from "./slash-commands"; import { failedTurnModelChoices, @@ -80,7 +76,6 @@ import { WorkbenchTimeline, displayNameFromHandle, localPartOf, - messageDomId, messageText, } from "./timeline"; import type { FailedTurnRecovery } from "./timeline"; @@ -93,8 +88,6 @@ import { NoUsableModelBanner } from "./no-usable-model-banner"; import { ResumeFailedBanner } from "./resume-failed-banner"; import type { CurrentUser, - PinActions, - ReactionActions, ScrollSnapshot, TimelineMessageItem, } from "./timeline"; @@ -110,19 +103,12 @@ import { } from "./typing-indicator"; import type { ProfileSubject } from "./profile-subject"; import { useWorkbenchStream } from "./use-workbench-stream"; -import { - applyStreamMessage, - applyStreamPin, - applyStreamReaction, - useWorkbenchFeed, -} from "./use-workbench-feed"; +import { useWorkbenchFeed } from "./use-workbench-feed"; import { CorbitAvatar, avatarClassForPrincipal } from "./avatar"; import { useWorkbenchPresenceRoster } from "./workbench-presence"; import { type } from "arktype"; import { ChatMessageEventData, - ChatPinEventData, - ChatReactionEventData, ChatSettingsEventData, } from "./wire/stream-events"; import { useThreadNavigation } from "./use-thread-navigation"; @@ -136,7 +122,6 @@ import { useWorkbenchTimelineView } from "./workbench-timeline-view"; export { chatFeedQueryKeyPrefix, chatThreadsQueryKey, - chatPinsQueryKey, } from "./use-workbench-feed"; export type { MessagesState } from "./workbench-timeline-view"; @@ -764,7 +749,7 @@ function ChatWorkspaceInner({ activeWorkbenchId, ...(onWorkbenchNotFound !== undefined ? { onWorkbenchNotFound } : {}), }); - const { threads, rootThreadId, pinsStatus, refreshFeed } = feed; + const { threads, rootThreadId, refreshFeed } = feed; const navigation = useThreadNavigation({ tenantId, @@ -804,23 +789,7 @@ function ChatWorkspaceInner({ if (first !== undefined) setActiveWorkbenchId(first.id); }, [workbenchesState, activeWorkbenchId]); - // Reaction/pin toggles never refetch on completion: the same - // `chat.reaction`/`chat.pin` event the actor's own toggle provokes comes - // back over this workbench's own stream (including to the actor's own - // connection) and is folded into the messages/pins cache by - // `applyStreamReaction`/`applyStreamPin` below — a second, response-driven - // refresh here would be exactly the redundant refetch CL-6328 removes. - const handleToggleReaction = useCallback( - (messageId: string, emoji: string) => { - if (activeWorkbenchId === null) return; - toggleReaction(tenantId, activeWorkbenchId, messageId, emoji).catch(() => - toast(CHAT_STRINGS.reactionToggleError), - ); - }, - [tenantId, activeWorkbenchId], - ); - - // CL-7201: unlike the reaction/pin handlers above, this rethrows after + // CL-7201: this rethrows after // toasting — the composer's own `onStop` awaits the returned promise // and re-enables its Stop button on rejection, so a genuinely failed // request (network, a denied grant) never leaves the button stuck @@ -835,41 +804,6 @@ function ChatWorkspaceInner({ }); }, [tenantId, activeWorkbenchId]); - const handlePinMessage = useCallback( - (messageId: string) => { - if (activeWorkbenchId === null) return; - pinMessage(tenantId, activeWorkbenchId, messageId).catch(() => - toast(CHAT_STRINGS.pinMessageError), - ); - }, - [tenantId, activeWorkbenchId], - ); - - const handleUnpinMessage = useCallback( - (messageId: string) => { - if (activeWorkbenchId === null) return; - unpinMessage(tenantId, activeWorkbenchId, messageId).catch(() => - toast(CHAT_STRINGS.unpinMessageError), - ); - }, - [tenantId, activeWorkbenchId], - ); - - const reactionActions: ReactionActions = useMemo( - () => ({ onToggle: handleToggleReaction }), - [handleToggleReaction], - ); - const pinActions: PinActions = useMemo( - () => ({ onPin: handlePinMessage, onUnpin: handleUnpinMessage }), - [handlePinMessage, handleUnpinMessage], - ); - - function jumpToMessage(messageId: string) { - document - .getElementById(messageDomId(messageId)) - ?.scrollIntoView({ behavior: "smooth", block: "center" }); - } - const composerMounted = !settingsOpen && activeWorkbenchId !== null && @@ -957,38 +891,16 @@ function ChatWorkspaceInner({ if (activeWorkbenchId === null) return; switch (eventType) { case "chat.message": { + // The feed now reads from the mailbox, not this stream's own + // `chat.message` payload (CL-8174 slice 2b) — the mailbox's own + // `/me/inbox/events` subscription (see `useWorkbenchFeed`) is + // what refreshes the timeline once the fan-out lands. A parse + // failure here still means something arrived this connection + // couldn't read, so still worth a refresh. const parsed = ChatMessageEventData(data); if (parsed instanceof type.errors) { toast(CHAT_STRINGS.streamMessageDropped); refreshFeed(); - break; - } - applyStreamMessage(queryClient, tenantId, activeWorkbenchId, { - id: parsed.id, - createdAt: parsed.createdAt, - parts: parsed.parts, - sender: parsed.sender, - ...(parsed.threadId !== null ? { threadId: parsed.threadId } : {}), - }); - break; - } - case "chat.reaction": { - const parsed = ChatReactionEventData(data); - if (!(parsed instanceof type.errors)) { - applyStreamReaction( - queryClient, - tenantId, - activeWorkbenchId, - parsed, - currentUser?.principalId, - ); - } - break; - } - case "chat.pin": { - const parsed = ChatPinEventData(data); - if (!(parsed instanceof type.errors)) { - applyStreamPin(queryClient, tenantId, activeWorkbenchId, parsed); } break; } @@ -1708,9 +1620,6 @@ function ChatWorkspaceInner({ /> ) : ( <> - {!inThreadView ? ( - - ) : null} {openThreadParent !== undefined ? (
{CHAT_STRINGS.forkThreadOriginBanner}{" "} @@ -1784,8 +1693,6 @@ function ChatWorkspaceInner({ {...(connectServiceActions !== undefined ? { connectServiceActions } : {})} - reactionActions={reactionActions} - pinActions={pinActions} onRetryFailedTurn={handleRetryFailedTurn} failedTurnRecovery={failedTurnRecovery} pendingActions={{ diff --git a/packages/chat-ui/src/index.ts b/packages/chat-ui/src/index.ts index d242f8f800..1d7b17ba3f 100644 --- a/packages/chat-ui/src/index.ts +++ b/packages/chat-ui/src/index.ts @@ -10,8 +10,6 @@ export { WorkbenchTimeline, messageDomId, findRetryText } from "./timeline"; export { NoUsableModelBanner } from "./no-usable-model-banner"; export type { CurrentUser, - ReactionActions, - PinActions, PendingActions, PendingMessageStatus, TimelineMessageItem, @@ -40,8 +38,6 @@ export type { export { threadMessagesToTimeline, roomRefFor } from "./mailbox-timeline"; export type { TimelineItem } from "./mailbox-timeline"; -export { PinnedStrip } from "./pinned-strip"; -export type { PinsStatus } from "./use-workbench-feed"; export { Composer, draftAfterSend, @@ -177,7 +173,6 @@ export { sendMessage, fetchWorkbenchBlob, listThreads, - listThreadMessages, putReadState, listRuns, listInvitableDefinitions, @@ -196,11 +191,6 @@ export { submitPollResponse, submitFormResponse, submitQuestionResponse, - REACTION_EMOJI, - toggleReaction, - pinMessage, - unpinMessage, - listPinnedMessages, listWorkbenchAgents, } from "./api"; export type { @@ -212,7 +202,6 @@ export type { MessageItem, MessagesResponse, WorkbenchThread, - ThreadMessagesResponse, Run, InvitableDefinition, InvitedAgent, @@ -224,10 +213,6 @@ export type { BenchChatSettingsPatch, BlockResponses, BlockResponsePayload as BlockResponsePayloadWire, - ReactionEmoji, - ReactionSummary, - Pinned, - PinnedMessage, WorkbenchAgent, } from "./api"; export { WorkbenchSettingsSurface } from "./workbench-settings"; diff --git a/packages/chat-ui/src/mailbox-timeline.ts b/packages/chat-ui/src/mailbox-timeline.ts index f664227a79..ed6e62db0f 100644 --- a/packages/chat-ui/src/mailbox-timeline.ts +++ b/packages/chat-ui/src/mailbox-timeline.ts @@ -1,11 +1,104 @@ -// Pure adapter from a mailbox thread read (`@corbits/mailbox`'s -// `MailboxThreadMessage`) onto the timeline item shape `use-workbench-feed.ts` -// already produces (CL-8174 slice 2a). No new wire shape: a mail message -// becomes exactly the `MessageItem` the rest of chat-ui already knows how to -// render, so the timeline never has to branch on where a message came from. +// Pure adapter from a mailbox thread read onto the timeline item shape +// `use-workbench-feed.ts` already produces (CL-8174 slice 2a/2b). No new wire +// shape: a mail message becomes exactly the `MessageItem` the rest of +// chat-ui already knows how to render, so the timeline never has to branch +// on where a message came from. +// +// This file makes its own `fetch()` calls against the mailbox's `/me/threads` +// routes and validates responses with local arktype schemas, rather than +// importing `@corbits/inbox/client` — that client's package re-exports pull +// in `@corbits/mailbox`'s server-only `migrations.ts` (a `node:crypto` user) +// at the value level, which a real bundler (Vite/Rollup) walks into even +// though only types are used, breaking the browser build. Per the owner +// ruling that `@corbits/mailbox` is a temporary, shrinking surface, every +// mailbox read in chat-ui goes through this one file, so swapping to +// Interchange's native mailbox thread shape later is a one-file change. -import type { MailboxRef, MailboxThreadMessage } from "@corbits/mailbox"; -import type { MessageItem } from "./api"; +import { type } from "arktype"; +import type { MessageItem, WorkbenchThreadRow } from "./api"; + +/** A mailbox ref, mirrored locally rather than imported from + * `@corbits/mailbox` (see the file header) — kept to exactly the shape this + * file stamps and sends. */ +export interface MailboxRef { + readonly kind: string; + readonly id: string; +} + +export class MailboxThreadFetchError extends Error { + constructor( + message: string, + readonly status?: number, + ) { + super(message); + this.name = "MailboxThreadFetchError"; + } +} + +const MailboxThreadSummary = type({ + rootId: "string", + rootMessageId: "string", + "subject?": "string | null", + messageCount: "number", + unreadCount: "number", + lastMessageId: "string", + lastFromAddress: "string", + lastCreatedAt: "string", +}); + +const MailboxThreadListResponse = type({ + threads: MailboxThreadSummary.array(), + "nextCursor?": "string", +}); + +const MailboxThreadMessageSchema = type({ + id: "string", + messageId: "string", + "inReplyTo?": "string", + references: "string[]", + fromAddress: "string", + "subject?": "string | null", + createdAt: "string", + read: "boolean", + archived: "boolean", + "parentId?": "string | null", + body: "string", +}); + +export type MailboxThreadMessage = typeof MailboxThreadMessageSchema.infer; + +const MailboxThreadReadResponse = type({ + messages: MailboxThreadMessageSchema.array(), + "nextCursor?": "string", +}); + +async function mailboxRequest( + path: string, + schema: (input: unknown) => T | import("arktype").type.errors, +): Promise { + let response: Response; + try { + response = await fetch(path); + } catch (cause) { + throw new MailboxThreadFetchError( + cause instanceof Error ? cause.message : String(cause), + ); + } + if (!response.ok) { + throw new MailboxThreadFetchError( + `The mailbox answered ${response.status} for ${path}.`, + response.status, + ); + } + const body: unknown = await response.json().catch(() => undefined); + const parsed = schema(body); + if (parsed instanceof type.errors) { + throw new MailboxThreadFetchError( + `Unexpected mailbox response shape from ${path}: ${parsed.summary}`, + ); + } + return parsed; +} /** The timeline item shape — an alias, not a new type, so a mail-derived * row and a chat-native row are interchangeable everywhere the timeline @@ -41,7 +134,9 @@ export function threadMessagesToTimeline( ? idByMessageId.get(message.inReplyTo) : undefined; const text = - message.subject !== undefined && message.subject.length > 0 + message.subject !== undefined && + message.subject !== null && + message.subject.length > 0 ? `${message.subject}\n${message.body}` : message.body; return { @@ -67,3 +162,108 @@ export function threadMessagesToTimeline( export function roomRefFor(_tenantId: string, roomId: string): MailboxRef { return { kind: "workbench", id: roomId }; } + +function mailboxBasePathFor(tenantId: string): string { + return `/api/tenants/${tenantId}/mailbox`; +} + +/** How many of a room's most-recently-active mail threads a feed page + * loads bodies for. A room with more conversations than this simply + * doesn't show the older ones yet — there is no "load more" for the root + * feed today, matching the bound the old `GET /messages` page carried. */ +const ROOM_FEED_THREAD_LIMIT = 50; + +/** The `threadId` every root-level message resolves to (via + * `./thread-feed.ts`'s `threadIdOf`) when it carries none of its own — the + * same "root feed by default" contract the old `workbench_thread_messages` + * store stated. A plain, never-a-mailbox-row-id sentinel, so it can never + * collide with a real reply thread's id (a mail message's own row id). */ +export const ROOM_FEED_ROOT_THREAD_ID = "__root__"; + +/** + * The bounded page of a room's mail threads a feed reads bodies for — + * newest activity first, same order `listMailboxThreadsClient` returns + * them in. The only call site in this package that reads a + * `MailboxThreadSummary`'s own fields — every other module works from + * `MessageItem`/`WorkbenchThreadRow` instead, so a later swap to + * Interchange's native mailbox thread shape touches only this file. + */ +async function listRoomThreadSummaries(tenantId: string, roomId: string) { + const params = new URLSearchParams({ + refs: JSON.stringify([roomRefFor(tenantId, roomId)]), + limit: String(ROOM_FEED_THREAD_LIMIT), + }); + const page = await mailboxRequest( + `${mailboxBasePathFor(tenantId)}/me/threads?${params.toString()}`, + MailboxThreadListResponse, + ); + return page.threads; +} + +async function readRoomThread(tenantId: string, rootMessageId: string) { + return mailboxRequest( + `${mailboxBasePathFor(tenantId)}/me/threads/${encodeURIComponent(rootMessageId)}`, + MailboxThreadReadResponse, + ); +} + +/** + * Every message across a room's visible mail threads, flattened into one + * timeline (CL-8174 slice 2b) — a root message's own `threadId` stays + * absent (see `threadMessagesToTimeline`), so it resolves to + * `ROOM_FEED_ROOT_THREAD_ID` in `./thread-feed.ts`'s root-feed filter; a + * reply's `threadId` is its thread's root message id, matching the + * `WorkbenchThreadRow.id` `loadRoomThreadRows` hands out for the same + * thread. + */ +export async function loadRoomMailboxMessages( + tenantId: string, + roomId: string, +): Promise { + const summaries = await listRoomThreadSummaries(tenantId, roomId); + const perThread = await Promise.all( + summaries.map((summary) => readRoomThread(tenantId, summary.rootMessageId)), + ); + return perThread.flatMap((page) => threadMessagesToTimeline(page.messages)); +} + +/** + * The reply-thread affordance rows for a room's visible mail threads — + * only threads with at least one reply get a row, matching the old + * `GET /threads` contract of listing only real reply activity. + */ +export async function loadRoomThreadRows( + tenantId: string, + roomId: string, +): Promise<{ + readonly rootThreadId: string; + readonly items: readonly WorkbenchThreadRow[]; +}> { + const summaries = await listRoomThreadSummaries(tenantId, roomId); + const items: WorkbenchThreadRow[] = summaries + .filter((summary) => summary.messageCount > 1) + .map((summary) => ({ + id: summary.rootId, + kind: "reply", + parentMessageId: summary.rootId, + // Every mail thread this feed lists hangs directly off the root + // feed — there is no depth-2 mailbox-native sub-thread concept + // (see the fork-affordance note in `use-thread-navigation.ts`) — + // so `parentThreadId` is always the sentinel root, matching + // `useThreadNavigation`'s `depth1Threads` filter + // (`thread.parentThreadId === rootThreadId`). + parentThreadId: ROOM_FEED_ROOT_THREAD_ID, + runRef: null, + title: summary.subject ?? null, + createdAt: summary.lastCreatedAt, + replyCount: summary.messageCount - 1, + lastActivityAt: summary.lastCreatedAt, + })); + return { rootThreadId: ROOM_FEED_ROOT_THREAD_ID, items }; +} + +/** The mailbox SSE endpoint a room's live-update subscription connects + * to — the same stream `@corbits/inbox`'s Inbox surface reads. */ +export function roomMailboxEventsUrl(tenantId: string): string { + return `${mailboxBasePathFor(tenantId)}/me/inbox/events`; +} diff --git a/packages/chat-ui/src/pinned-strip.tsx b/packages/chat-ui/src/pinned-strip.tsx deleted file mode 100644 index b33c44c22d..0000000000 --- a/packages/chat-ui/src/pinned-strip.tsx +++ /dev/null @@ -1,87 +0,0 @@ -// The pinned strip: a horizontal row at the top of the workbench showing -// every currently-pinned message, each a jump-to-message chip. Renders -// nothing when there are no pins — an empty strip is chrome nobody -// asked to see, the same "no items, no surface" rule the timeline's own -// empty state follows for the reverse case (see `WorkbenchTimeline`). -// -// A load failure is distinct from honest empty (CL-6832): the strip -// surfaces an alert instead of disappearing. A host with no pins store -// (`unavailable`) still renders nothing — absent feature, absent chrome. - -import { PushPin } from "@corbits/icons"; - -import type { PinnedMessage } from "./api"; -import { CHAT_STRINGS } from "./strings"; -import type { PinsStatus } from "./use-workbench-feed"; - -/** - * A short, single-line preview of a pinned message's content — its - * first text part, trimmed; falling back to generic copy for a - * message whose only parts are non-text (a file, a block), since - * showing raw part data in a preview chip would be more confusing than - * a plain label. - */ -function previewOf(item: PinnedMessage): string { - const text = item.parts.find( - (part): part is Extract => - part.kind === "text", - )?.text; - if (text === undefined) return CHAT_STRINGS.pinnedStripEmptyPreview; - const trimmed = text.trim(); - if (trimmed.length === 0) return CHAT_STRINGS.pinnedStripEmptyPreview; - return trimmed.length > 80 ? `${trimmed.slice(0, 80)}…` : trimmed; -} - -export function PinnedStrip({ - status, - onJump, -}: { - readonly status: PinsStatus; - /** Scrolls the timeline to the pinned message's own row — the host - * owns nothing here beyond calling this; `PinnedStrip` resolves the - * DOM id itself via `messageDomId`. */ - readonly onJump: (messageId: string) => void; -}) { - if (status.kind === "loading" || status.kind === "unavailable") return null; - - if (status.kind === "error") { - return ( -
-
- ); - } - - if (status.items.length === 0) return null; - - return ( -
-
- ); -} diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index 9fc74fcdd0..5d7c1501cd 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -152,22 +152,8 @@ export const CHAT_STRINGS = { : "Couldn't unpin that workbench — try again.", agentDmOpenError: (name: string) => `Couldn't open a conversation with ${name} — try again.`, - reactionChipLabel: (emoji: string, count: number) => - `React with ${emoji} (${count})`, - reactionAddAction: "Add reaction", - reactionPickerLabel: "Choose a reaction", - reactionPickerOptionLabel: (emoji: string) => `React with ${emoji}`, - reactionToggleError: "Couldn't update that reaction — try again.", streamMessageDropped: "Couldn't apply that message live — refreshing.", - pinMessageAction: "Pin message", - unpinMessageAction: "Unpin message", - pinMessageError: "Couldn't pin that message — try again.", - unpinMessageError: "Couldn't unpin that message — try again.", turnCancelError: "Couldn't stop that turn — try again.", - pinnedStripLabel: "Pinned messages", - pinnedStripEmptyPreview: "Pinned message", - pinnedStripJumpAction: (preview: string) => `Jump to: ${preview}`, - pinnedStripLoadError: "Couldn't load pinned messages.", pendingSendLabel: "Sending…", pendingSendFailedLabel: "Not sent", pendingSendRetryAction: "Retry", diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index 22e9d20619..4ad421b764 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -40,9 +40,6 @@ import { Copy, DotsThree, PencilSimple, - PushPin, - PushPinSlash, - Smiley, } from "@corbits/icons"; import { memo } from "react"; import { useEffect, useRef, useState } from "react"; @@ -53,9 +50,7 @@ import type { MessageSender, ParticipantRecord, Part, - ReactionSummary, } from "./api"; -import { REACTION_EMOJI } from "./api"; import { ArtifactChip } from "./artifact-chip"; import type { ApprovalActions } from "./blocks/approval-actions"; import type { BlockResponseActions } from "./blocks/block-responses"; @@ -90,28 +85,8 @@ import type { FailedTurnModelChoice } from "./failed-turn-models"; */ export type ThreadAffordanceMode = "reply" | "fork"; -/** - * The reaction chip row's live round-trip — the host's toggle against - * `@corbits/chat`'s reaction routes, mirroring how `blockResponses` - * threads the poll/form round-trip down to its card. Undefined renders - * no reaction affordance at all (no chips, no "add reaction" trigger), - * the same "no port, no feature" contract every other optional action - * on this timeline follows. - */ -export type ReactionActions = { - readonly onToggle: (messageId: string, emoji: string) => void; -}; - -/** The pin/unpin round-trip a message's hover row offers — undefined - * renders no pin affordance at all. */ -export type PinActions = { - readonly onPin: (messageId: string) => void; - readonly onUnpin: (messageId: string) => void; -}; - -/** The DOM id a message's group renders under — the pinned strip's - * jump-to-message target (`document.getElementById`). Exported so the - * host never has to hand-guess the id format. */ +/** The DOM id a message's group renders under. Exported so the host + * never has to hand-guess the id format. */ export function messageDomId(messageId: string): string { return `chat-message-${messageId}`; } @@ -1030,46 +1005,6 @@ function DayDivider({ createdAt }: { createdAt: string }) { ); } -/** - * The reaction chip row: every emoji with at least one reactor renders as a - * chip (count + reacted-state). Renders nothing when there are no reactions - * — the "add a reaction" affordance itself lives in `MessageHoverToolbar` - * now, not here, so a message with zero reactions shows no chip row at all - * until hovered. - */ -function ReactionChips({ - messageId, - reactions, - reactionActions, -}: { - readonly messageId: string; - readonly reactions: readonly ReactionSummary[]; - readonly reactionActions: ReactionActions; -}) { - if (reactions.length === 0) return null; - return ( -
- {reactions.map((reaction) => ( - - ))} -
- ); -} - async function copyMessageText(text: string): Promise { try { await navigator.clipboard.writeText(text); @@ -1312,7 +1247,7 @@ function StreamingMessageGroup({ } /** - * The ellipsis/right-click menu for a message: reply-in-thread, copy, pin, + * The ellipsis/right-click menu for a message: reply-in-thread, copy, * and Edit (own prompts) in one place so the hover ellipsis and a * right-click always offer the same actions. */ @@ -1321,13 +1256,11 @@ function buildMessageMenu({ threadAffordanceMode, onOpenThread, onEditMessage, - pinActions, }: { readonly item: MessageItem; readonly threadAffordanceMode: ThreadAffordanceMode; readonly onOpenThread: ((messageId: string) => void) | undefined; readonly onEditMessage: ((messageId: string) => void) | undefined; - readonly pinActions: PinActions | undefined; }): ContextMenu { const entries: ContextMenuEntry[] = []; @@ -1370,25 +1303,6 @@ function buildMessageMenu({ ); } - if (pinActions !== undefined) { - const pinned = item.pinned ?? false; - entries.push( - contextMenuItem({ - id: "toggle-pin", - label: pinned - ? CHAT_STRINGS.unpinMessageAction - : CHAT_STRINGS.pinMessageAction, - icon: pinned ? ( -
@@ -2098,8 +1871,6 @@ export function WorkbenchTimeline({ blockResponses, connectGithubActions, connectServiceActions, - reactionActions, - pinActions, pendingActions, failedTurnRecovery, onRetryFailedTurn, @@ -2152,13 +1923,6 @@ export function WorkbenchTimeline({ /** Host round-trip for the generic "connect-service" card. Undefined * renders every connect-service card in its disconnected framing. */ readonly connectServiceActions?: ConnectServiceActions; - /** The reaction chip row's live round-trip — see `ReactionActions`. - * Undefined renders no chips and no "add reaction" trigger at all, - * the same "no port, no feature" contract `blockResponses` follows. */ - readonly reactionActions?: ReactionActions; - /** The hover pin/unpin toggle — see `PinActions`. Undefined renders - * no pin affordance on any message. */ - readonly pinActions?: PinActions; /** The failed pending bubble's inline Retry/Discard — see * `PendingActions`. Undefined renders a failed pending item with no * recovery affordance at all (still shown as failed). */ @@ -2405,8 +2169,6 @@ export function WorkbenchTimeline({ {...(connectServiceActions !== undefined ? { connectServiceActions } : {})} - {...(reactionActions !== undefined ? { reactionActions } : {})} - {...(pinActions !== undefined ? { pinActions } : {})} {...(pendingActions !== undefined ? { pendingActions } : {})} {...(onRetryFailedTurn !== undefined ? { onRetryFailedTurn } diff --git a/packages/chat-ui/src/use-workbench-feed.ts b/packages/chat-ui/src/use-workbench-feed.ts index 16fbf6e272..401373346f 100644 --- a/packages/chat-ui/src/use-workbench-feed.ts +++ b/packages/chat-ui/src/use-workbench-feed.ts @@ -1,35 +1,28 @@ -// One workbench's feed: the three reads that make it up, every view the +// One workbench's feed: the mailbox reads that make it up, every view the // timeline can derive from them, and the single refresh that keeps them -// current (CL-6313). +// current (CL-6313; CL-8174 slice 2b). // -// Thread membership is a property of a message, not a function of which -// endpoint was called, so the root feed, any open thread, and the reply -// counts on each thread affordance are all filters over one cached -// mailbox — see `./thread-feed.ts`. That is what lets an agent turn's -// burst of stream events cost one refetch instead of one request per -// thread per event. +// The feed reads through `@corbits/mailbox`'s own thread routes rather than +// `@corbits/chat`'s message/thread routes now: a workbench is a plain +// tenant (CL-8083), and every message posted to it already lands in the +// mailbox via the same fan-out the Inbox reads (see +// `packages/chat-ui/src/mailbox-timeline.ts`). Thread membership is a +// property of a message, not a function of which endpoint was called, so +// the root feed and every open thread are both filters over the one +// mailbox this hook loads — see `./thread-feed.ts`. +// +// Pins and reactions have no mailbox equivalent and are retired with this +// slice: no chip row, no pin toggle, no pinned strip. import { useCallback, useEffect, useMemo, useRef } from "react"; -import type { QueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { UnauthenticatedError } from "@corbits/api-query"; import { - ChatApiError, - describeChatError, - listMessages, - listPinnedMessages, - listThreads, - workbenchesQueryKey, -} from "./api"; -import type { - MessageItem, - MessagesResponse, - PinnedMessage, - ReactionSummary, - Workbench, - WorkbenchThreadRow, -} from "./api"; -import { CHAT_STRINGS } from "./strings"; + loadRoomMailboxMessages, + loadRoomThreadRows, + MailboxThreadFetchError, + roomMailboxEventsUrl, +} from "./mailbox-timeline"; +import type { MessageItem, MessagesResponse, WorkbenchThreadRow } from "./api"; /** Whether this workbench's mailbox has loaded, and why not if it hasn't. * The items themselves are a separate question — which slice of the @@ -50,40 +43,6 @@ export type FeedStatus = } | { readonly kind: "ready" }; -/** Whether this workbench's pinned strip has loaded, and why not if it - * hasn't (CL-6832). A successful empty list is `ready` with no items; a - * read failure is `error`; a 404 (pins store not wired on this host) is - * `unavailable` — the same "absent store, absent surface" contract the - * wire's own `pinned` field follows. Never coerce a failure into `[]`. */ -export type PinsStatus = - | { readonly kind: "loading" } - | { readonly kind: "ready"; readonly items: readonly PinnedMessage[] } - | { readonly kind: "unavailable" } - | { readonly kind: "error"; readonly message: string }; - -/** Pure fold over the pins query — kept free of React so the empty-vs-error - * distinction is unit-testable without mounting the feed hook. */ -export function pinsStatusFor(args: { - readonly activeWorkbenchId: string | null; - readonly data: readonly PinnedMessage[] | undefined; - readonly error: unknown; -}): PinsStatus { - if (args.activeWorkbenchId === null) return { kind: "loading" }; - // React Query keeps the last successful data through a failed refetch, so - // a background failure leaves the strip exactly as it was. - if (args.data !== undefined) return { kind: "ready", items: args.data }; - if (args.error != null) { - if (args.error instanceof ChatApiError && args.error.status === 404) { - return { kind: "unavailable" }; - } - return { - kind: "error", - message: describeChatError(args.error, CHAT_STRINGS.pinnedStripLoadError), - }; - } - return { kind: "loading" }; -} - /** How long a loaded feed counts as fresh. An agent turn emits dozens of * stream events in under a second; with a stale window every one of them * after the first is served from cache instead of hitting the hub. */ @@ -122,87 +81,11 @@ export function chatThreadsQueryKey( ) { return [...chatFeedQueryKeyPrefix(tenantId, workbenchId), "threads"] as const; } -export function chatPinsQueryKey(tenantId: string, workbenchId: string | null) { - return [...chatFeedQueryKeyPrefix(tenantId, workbenchId), "pins"] as const; -} - -/** Cap matching `packages/chat/src/room-messages.ts`'s bench-list preview. */ -const LIST_PREVIEW_MAX_LENGTH = 80; - -/** - * Person-facing text for a sidebar list-row preview from one stream - * message's parts (CL-6795). Text parts only — join/event/attachment-only - * rows contribute nothing so the prior readable preview is kept rather - * than blanked. Truncation matches the server's `previewOf`. - */ -function streamListPreview(parts: MessageItem["parts"]): string { - const text = parts - .filter( - (part): part is Extract => - part.kind === "text", - ) - .map((part) => part.text) - .join(" ") - .replace(/\s+/g, " ") - .trim(); - if (text.length === 0) return ""; - return text.length > LIST_PREVIEW_MAX_LENGTH - ? `${text.slice(0, LIST_PREVIEW_MAX_LENGTH).trimEnd()}…` - : text; -} /** - * Settles the workbench/chat list-cache row for a streamed message - * (CL-6795): bump `lastActivityAt` when the message is at least as new as - * the cached activity, replace `preview` only when this message carries - * person-facing text, and never blank a prior readable preview on a - * join/event/attachment-only row. Touches both kind caches — the row - * lives in exactly one — so the sidebar updates without a list refetch. - */ -function settleWorkbenchListRow( - queryClient: QueryClient, - tenantId: string, - workbenchId: string, - message: MessageItem, -): void { - const nextPreview = streamListPreview(message.parts); - for (const kind of ["workbench", "chat"] as const) { - queryClient.setQueryData( - workbenchesQueryKey(tenantId, kind), - (current: readonly Workbench[] | undefined) => { - if (current === undefined) return current; - let changed = false; - const items = current.map((row) => { - if (row.id !== workbenchId) return row; - const priorActivity = row.lastActivityAt; - const isNewerOrEqual = - priorActivity === undefined || message.createdAt >= priorActivity; - if (!isNewerOrEqual) return row; - const lastActivityAt = message.createdAt; - const preview = nextPreview.length > 0 ? nextPreview : row.preview; - if ( - lastActivityAt === row.lastActivityAt && - preview === row.preview - ) { - return row; - } - changed = true; - return { - ...row, - lastActivityAt, - ...(preview !== undefined ? { preview } : {}), - }; - }); - return changed ? items : current; - }, - ); - } -} - -/** - * The `GET /threads` cache shape — shared by stream apply and the - * optimistic-send path that seeds a just-created reply thread before - * `openThreadById` runs (CL-6660). + * The `GET /threads` cache shape — shared by the optimistic-send path that + * seeds a just-created reply thread before `openThreadById` runs + * (CL-6660). */ export type ThreadsQueryData = { readonly rootThreadId: string; @@ -276,214 +159,9 @@ export function ensureReplyThreadRow( return { ...current, items: [...current.items, newRow] }; } -/** - * Folds a freshly published `chat.message` row straight into the messages - * cache (CL-6328) — the §6/1.2 bar is zero refetches triggered by a stream - * event, and this event already carries everything a `GET .../messages` - * page item would. Deduped by `id` or `clientId` so the row this reader's - * own optimistic send already wrote (`use-optimistic-sends.ts`) is never - * doubled when its own echo arrives back over the stream. - * - * When the matching row is already present but lacks `threadId` and the - * stream carries one, the cached row is patched in place (CL-6660) — an - * optimistic confirm that raced ahead of thread assignment used to leave - * the message stuck on the root feed. A message that belongs to a thread - * also bumps (or seeds) that thread's `replyCount`/`lastActivityAt` row - * in the threads cache — only when this apply newly scopes the message - * into the thread, so an echo of an already-counted optimistic write is - * a no-op on replyCount. The workbench list-cache row settles its - * preview/`lastActivityAt` here too (CL-6795). - */ -export function applyStreamMessage( - queryClient: QueryClient, - tenantId: string, - workbenchId: string, - message: MessageItem, -): void { - let threadActivityDelta = 0; - queryClient.setQueryData( - chatMessagesQueryKey(tenantId, workbenchId), - (current: MessagesResponse | undefined) => { - if (current === undefined) return current; - const matchIndex = current.items.findIndex( - (item) => - item.id === message.id || - (message.clientId !== undefined && - item.clientId === message.clientId), - ); - if (matchIndex >= 0) { - const existing = current.items[matchIndex]; - if (existing === undefined) return current; - if ( - message.threadId !== undefined && - existing.threadId !== message.threadId - ) { - threadActivityDelta = 1; - const items = current.items.slice(); - items[matchIndex] = { ...existing, threadId: message.threadId }; - return { ...current, items }; - } - return current; - } - if (message.threadId !== undefined) threadActivityDelta = 1; - return { ...current, items: [...current.items, message] }; - }, - ); - settleWorkbenchListRow(queryClient, tenantId, workbenchId, message); - if (message.threadId === undefined) return; - const threadId = message.threadId; - queryClient.setQueryData( - chatThreadsQueryKey(tenantId, workbenchId), - (current: ThreadsQueryData | undefined) => { - if (current === undefined) return current; - return ensureReplyThreadRow(current, { - threadId, - createdAt: message.createdAt, - bumpReplyCount: threadActivityDelta > 0, - }); - }, - ); -} - -/** - * Folds a `chat.reaction` delta into the reacted message's own summary — - * the emoji, count, and this signed-in principal's own membership in the - * reactor set — rather than refetching the row it's about. - */ -export function applyStreamReaction( - queryClient: QueryClient, - tenantId: string, - workbenchId: string, - reaction: { - readonly messageId: string; - readonly emoji: string; - readonly principalId: string; - readonly added: boolean; - }, - selfPrincipalId: string | undefined, -): void { - const isSelf = reaction.principalId === selfPrincipalId; - queryClient.setQueryData( - chatMessagesQueryKey(tenantId, workbenchId), - (current: MessagesResponse | undefined) => { - if (current === undefined) return current; - const items = current.items.map((item) => { - if (item.id !== reaction.messageId) return item; - const reactions = item.reactions ?? []; - const existingIndex = reactions.findIndex( - (entry) => entry.emoji === reaction.emoji, - ); - let nextReactions: readonly ReactionSummary[]; - if (reaction.added) { - nextReactions = - existingIndex === -1 - ? [ - ...reactions, - { emoji: reaction.emoji, count: 1, reactedByMe: isSelf }, - ] - : reactions.map((entry, index) => - index === existingIndex - ? { - ...entry, - count: entry.count + 1, - reactedByMe: entry.reactedByMe || isSelf, - } - : entry, - ); - } else { - // A removal for an emoji this cache never saw added is a - // no-op, not a negative count. - const existing = reactions[existingIndex]; - const nextCount = existing === undefined ? 0 : existing.count - 1; - if (existing === undefined) { - nextReactions = reactions; - } else if (nextCount <= 0) { - nextReactions = reactions.filter( - (_, index) => index !== existingIndex, - ); - } else { - nextReactions = reactions.map((entry, index) => - index === existingIndex - ? { - ...entry, - count: nextCount, - reactedByMe: isSelf ? false : entry.reactedByMe, - } - : entry, - ); - } - } - return { ...item, reactions: nextReactions }; - }); - return { ...current, items }; - }, - ); -} - -/** - * Folds a `chat.pin`/unpin delta into both the message's own `pinned` flag - * and the pinned strip's list — the strip's row is built from the message - * already sitting in the messages cache plus `pinnedBy`/`pinnedAt`, so - * pinning never needs a `GET /pins` round-trip. - */ -export function applyStreamPin( - queryClient: QueryClient, - tenantId: string, - workbenchId: string, - pin: { - readonly messageId: string; - readonly pinned: boolean; - readonly pinnedBy?: string; - readonly pinnedAt?: string; - }, -): void { - queryClient.setQueryData( - chatMessagesQueryKey(tenantId, workbenchId), - (current: MessagesResponse | undefined) => { - if (current === undefined) return current; - return { - ...current, - items: current.items.map((item) => - item.id === pin.messageId ? { ...item, pinned: pin.pinned } : item, - ), - }; - }, - ); - queryClient.setQueryData( - chatPinsQueryKey(tenantId, workbenchId), - (current: readonly PinnedMessage[] | undefined) => { - if (current === undefined) return current; - if (!pin.pinned) { - return current.filter((entry) => entry.id !== pin.messageId); - } - if (current.some((entry) => entry.id === pin.messageId)) return current; - if (pin.pinnedBy === undefined || pin.pinnedAt === undefined) { - return current; - } - const messages = queryClient.getQueryData( - chatMessagesQueryKey(tenantId, workbenchId), - ); - const message = messages?.items.find((item) => item.id === pin.messageId); - if (message === undefined) return current; - return [ - ...current, - { - ...message, - pinned: true, - pinnedBy: pin.pinnedBy, - pinnedAt: pin.pinnedAt, - }, - ]; - }, - ); -} - export interface WorkbenchFeed { readonly threads: readonly WorkbenchThreadRow[]; readonly rootThreadId: string; - /** Pins load state — empty success, error, and unavailable stay distinct - * (CL-6832). Prefer this over inventing an empty list on failure. */ - readonly pinsStatus: PinsStatus; /** Every message in the workbench, unfiltered. What thread a reader is * looking at selects a slice of this — see `./thread-feed.ts`. */ readonly loadedMessages: readonly MessageItem[]; @@ -515,48 +193,30 @@ export function useWorkbenchFeed(args: { | undefined >(undefined); - // Threads and the mailbox are two queries, and every view the timeline - // can show is derived from them (CL-6313). Each message carries the - // thread it belongs to, so opening a thread filters data already - // loaded rather than calling a different endpoint — which is what lets - // a burst of stream events collapse into a single refetch instead of - // one request per thread per event. const messagesQuery = useQuery({ queryKey: chatMessagesQueryKey(tenantId, activeWorkbenchId), - queryFn: () => listMessages(tenantId, activeWorkbenchId ?? ""), + // Kept as `{ items }` — the same `MessagesResponse` shape the old + // `GET .../messages` read produced — so `use-optimistic-sends.ts`'s + // own optimistic writes into this cache (which target `.items`, + // wholly independent of where the confirmed rows came from) need no + // changes for this slice's fetch-source swap. + queryFn: async (): Promise => ({ + items: await loadRoomMailboxMessages(tenantId, activeWorkbenchId ?? ""), + }), enabled: activeWorkbenchId !== null, staleTime: CHAT_FEED_STALE_MS, }); const threadsQuery = useQuery({ queryKey: chatThreadsQueryKey(tenantId, activeWorkbenchId), - queryFn: () => listThreads(tenantId, activeWorkbenchId ?? ""), - enabled: activeWorkbenchId !== null, - staleTime: CHAT_FEED_STALE_MS, - }); - const pinsQuery = useQuery({ - queryKey: chatPinsQueryKey(tenantId, activeWorkbenchId), - queryFn: () => listPinnedMessages(tenantId, activeWorkbenchId ?? ""), + queryFn: () => loadRoomThreadRows(tenantId, activeWorkbenchId ?? ""), enabled: activeWorkbenchId !== null, staleTime: CHAT_FEED_STALE_MS, }); const threads = threadsQuery.data?.items ?? NO_THREADS; const rootThreadId = threadsQuery.data?.rootThreadId ?? ""; - const pinsStatus = useMemo( - () => - pinsStatusFor({ - activeWorkbenchId, - data: pinsQuery.data, - error: pinsQuery.error, - }), - [activeWorkbenchId, pinsQuery.data, pinsQuery.error], - ); const loadedMessages = messagesQuery.data?.items ?? NO_MESSAGES; - // The parent a thread hangs off comes from the thread itself once it - // exists, and from the message the reader is replying to before it - // does — one value either way, so the feed never has to know which - // case it is in. // A 401 is terminal for this session: keep refetching and the app would // hammer the hub unauthenticated forever, so every refresh trigger // below checks this first. A 404 means the workbench itself is gone @@ -564,10 +224,11 @@ export function useWorkbenchFeed(args: { // transient failure a retry could fix. const messagesError = messagesQuery.error; const isUnauthorized = - messagesError instanceof UnauthenticatedError || - (messagesError instanceof ChatApiError && messagesError.status === 401); + messagesError instanceof MailboxThreadFetchError && + messagesError.status === 401; const workbenchNotFound = - messagesError instanceof ChatApiError && messagesError.status === 404; + messagesError instanceof MailboxThreadFetchError && + messagesError.status === 404; // React Query keeps the last successful data through a failed refetch, // so a background failure leaves the timeline exactly as it was and @@ -576,9 +237,14 @@ export function useWorkbenchFeed(args: { if (activeWorkbenchId === null) return { kind: "loading" }; if (messagesQuery.data !== undefined) return { kind: "ready" }; if (messagesError !== null) { + // Never the raw `MailboxThreadFetchError` message verbatim — like + // `describeChatError`, it can embed the request path. `workbenchNotFound` + // and `isUnauthorized` below carry the two statuses the UI gives its + // own dedicated copy and recovery to; everything else gets this one + // plain-language fallback. return { kind: "error", - message: describeChatError(messagesError, "Couldn't load messages."), + message: "Couldn't load messages.", workbenchNotFound, isUnauthorized, }; @@ -644,10 +310,28 @@ export function useWorkbenchFeed(args: { }, [tenantId, activeWorkbenchId], ); + + // Live updates: the mailbox's own SSE stream (the same one Inbox reads) + // rather than a bespoke chat one — an event names the affected mail + // message's id and (optionally) what happened to it, never the full + // row, so the only thing a subscriber can safely do with it is refresh + // the feed it might belong to. Coalesced through the same `refreshFeed` + // every other trigger uses, so a burst of mail events costs one refetch. + useEffect(() => { + if (activeWorkbenchId === null) return; + // A host with no `EventSource` at all (an older test harness that + // never stubs one, matching `useWorkbenchStream`'s own contract) gets + // no live updates rather than a thrown error — `refreshFeed` still + // runs from every other trigger (send, thread navigation, tab focus). + if (typeof EventSource === "undefined") return; + const source = new EventSource(roomMailboxEventsUrl(tenantId)); + source.addEventListener("mailbox", () => refreshFeed()); + return () => source.close(); + }, [tenantId, activeWorkbenchId, refreshFeed]); + return { threads, rootThreadId, - pinsStatus, loadedMessages, feedStatus, threadsLoaded: threadsQuery.data !== undefined, diff --git a/packages/chat-ui/src/use-workbench-stream.ts b/packages/chat-ui/src/use-workbench-stream.ts index 9b6940995f..25f260dc94 100644 --- a/packages/chat-ui/src/use-workbench-stream.ts +++ b/packages/chat-ui/src/use-workbench-stream.ts @@ -165,8 +165,6 @@ export function useWorkbenchStream( "chat.agent", "chat.settings", "chat.typing", - "chat.reaction", - "chat.pin", "chat.presence", "chat.presence.snapshot", "chat.workbenches-mutated", diff --git a/packages/chat-ui/src/wire/reaction-emoji.ts b/packages/chat-ui/src/wire/reaction-emoji.ts deleted file mode 100644 index 2f01e77a3c..0000000000 --- a/packages/chat-ui/src/wire/reaction-emoji.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Mirrored from packages/chat/src (CL-8148 T5b): apps/web and @corbits/chat-ui -// must not import @corbits/chat, a server-only package. This is the browser- -// facing half of the same wire contract the hub's chat routes still speak; -// once the hub moves onto native mail threads (T5a/T5c) this file becomes -// the one source of truth and packages/chat's copy goes away. - -// The curated set of emoji a message reaction can use — deliberately -// small (Slack's own default quick-react row is the same shape) rather -// than free-form input, so a reaction chip is always one of a handful -// of glyphs a reader recognizes at a glance, never an arbitrary string -// an agent or a client could stuff into the row. Pure data, no drizzle -// import, so both `./routes.ts` (server-side validation) and -// `@corbits/chat-ui` (the picker) import this one list rather than -// keeping two in sync. -export const REACTION_EMOJI = ["👍", "❤️", "😂", "🎉", "👀", "🚀"] as const; - -export type ReactionEmoji = (typeof REACTION_EMOJI)[number]; - -export function isKnownReactionEmoji(value: string): value is ReactionEmoji { - return (REACTION_EMOJI as readonly string[]).includes(value); -} diff --git a/packages/chat-ui/src/wire/stream-events.ts b/packages/chat-ui/src/wire/stream-events.ts index 38175a2922..bd8dafb6ac 100644 --- a/packages/chat-ui/src/wire/stream-events.ts +++ b/packages/chat-ui/src/wire/stream-events.ts @@ -18,11 +18,7 @@ // `GET /workbenches/:id/messages` page item and a freshly published // `chat.message` are structurally the same shape, minus the fields // (`reactions`, `pinned`, `clientId`) that are always absent on a -// message nobody has reacted to, pinned, or reconciled yet. Reaction -// and pin events already carry the full changed row for their own -// narrow concern (which emoji, which principal, pinned by whom, when) -// — a subscriber folds that delta into state it already has rather -// than refetching the row it's about. +// message nobody has reacted to, pinned, or reconciled yet. import { type } from "arktype"; import { Part } from "./parts"; @@ -63,22 +59,6 @@ export const ChatSettingsEventData = type({ }); export type ChatSettingsEventData = typeof ChatSettingsEventData.infer; -export const ChatReactionEventData = type({ - messageId: "string", - emoji: "string", - principalId: "string", - added: "boolean", -}); -export type ChatReactionEventData = typeof ChatReactionEventData.infer; - -export const ChatPinEventData = type({ - messageId: "string", - pinned: "boolean", - "pinnedBy?": "string", - "pinnedAt?": "string", -}); -export type ChatPinEventData = typeof ChatPinEventData.infer; - export const ChatTypingEventData = type({ principalId: "string", }); diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index f3e654250d..6fcfeb1c84 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -1552,94 +1552,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { }, ); - app.get( - "/workbenches/:id/threads/:threadId/messages", - deps.requireGrant(idResource("workflow-run", "id"), "read"), - async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - const threadId = c.req.param("threadId"); - if (!(await workbenchInTenant(deps.store, tenant.id, workbenchId))) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - if (deps.threads === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "threads not available", - }), - 404, - ); - } - const thread = await deps.threads.getThread(tenant.id, threadId); - if (thread === undefined || thread.workbenchId !== workbenchId) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "thread not found", - }), - 404, - ); - } - // A message's thread is the one it was assigned to, or the root - // thread when it was never assigned at all — `workbench_thread_messages` - // states that default ("root feed by default"), and this is the - // one place that resolves it. `POST /messages` is the only caller - // that records membership, so every agent-originated message - // (`chat-orchestrator`'s reply/approve-block/artifact posters, - // `workbench-service`'s join and leave notices) arrives with none: - // listing a feed by membership rows alone would silently hide all - // of them, a fresh chat's very first agent reply included. - const membership = await resolveThreadMembership(tenant.id, workbenchId); - if (membership === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "threads not available", - }), - 404, - ); - } - const listed = await deps.roomMessages.listMessages({ - tenantId: tenant.id, - workbenchId, - }); - const items = listed.items - .filter((message) => membership.threadIdOf(message.id) === threadId) - .map((message) => ({ - id: message.id, - createdAt: message.createdAt, - sender: message.sender, - parts: message.parts, - })); - return c.json({ - thread: { - id: thread.id, - kind: thread.kind, - parentMessageId: thread.parentMessageId, - parentThreadId: thread.parentThreadId, - runRef: thread.runRef, - title: thread.title, - createdAt: thread.createdAt.toISOString(), - }, - items: await enrichWithReactionsAndPins( - deps, - tenant.id, - workbenchId, - principal.id, - items, - ), - }); - }, - ); - app.post( "/workbenches/:id/delivery-threads", deps.requireGrant(idResource("workflow-run", "id"), "write"),