diff --git a/packages/chat-ui/src/use-workbench-feed.ts b/packages/chat-ui/src/use-workbench-feed.ts index 477827b15..26fe6b741 100644 --- a/packages/chat-ui/src/use-workbench-feed.ts +++ b/packages/chat-ui/src/use-workbench-feed.ts @@ -19,12 +19,14 @@ import { listMessages, listPinnedMessages, listThreads, + workbenchesQueryKey, } from "./api"; import type { MessageItem, MessagesResponse, PinnedMessage, ReactionSummary, + Workbench, WorkbenchThreadRow, } from "./api"; @@ -90,6 +92,79 @@ 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; + }, + ); + } +} + /** * 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 @@ -100,6 +175,11 @@ export function chatPinsQueryKey(tenantId: string, workbenchId: string | null) { * belongs to a thread also bumps that thread's `replyCount`/ * `lastActivityAt` row in the threads cache — the one piece of thread * metadata `MessageItem` itself doesn't carry (see `./thread-feed.ts`). + * The workbench list-cache row settles its preview/`lastActivityAt` here + * too (CL-6795) so the sidebar tracks the latest person-facing text + * without waiting for a list refetch — including when the messages + * append was a no-op because this connection's optimistic send already + * wrote the row. */ export function applyStreamMessage( queryClient: QueryClient, @@ -121,6 +201,7 @@ export function applyStreamMessage( return { ...current, items: [...current.items, message] }; }, ); + settleWorkbenchListRow(queryClient, tenantId, workbenchId, message); if (message.threadId === undefined) return; queryClient.setQueryData( chatThreadsQueryKey(tenantId, workbenchId), diff --git a/packages/chat-ui/test/stream-apply.test.ts b/packages/chat-ui/test/stream-apply.test.ts index b6b60994e..97465eb24 100644 --- a/packages/chat-ui/test/stream-apply.test.ts +++ b/packages/chat-ui/test/stream-apply.test.ts @@ -15,7 +15,13 @@ import { chatPinsQueryKey, chatThreadsQueryKey, } from "../src/use-workbench-feed"; -import type { MessageItem, MessagesResponse, PinnedMessage } from "../src/api"; +import type { + MessageItem, + MessagesResponse, + PinnedMessage, + Workbench, +} from "../src/api"; +import { workbenchesQueryKey } from "../src/api"; const TENANT = "tnt_1"; const WORKBENCH = "wb_1"; @@ -33,6 +39,14 @@ const baseMessage: MessageItem = { 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(); @@ -143,6 +157,73 @@ describe("applyStreamMessage", () => { ), ).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"); + }); }); describe("applyStreamReaction", () => {