From 784749a5a5b6d77052eb50316a15930ee6c38713 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 11:07:12 -0700 Subject: [PATCH 1/2] CL-6795: keep sidebar preview across join/event rows (server) Walk listActivity lookback when the newest row has an empty preview (join/event/attachment-only), same path as failure lookback, so the bench list keeps the last person-facing text instead of blanking. --- packages/chat/src/index.ts | 1 + packages/chat/src/room-messages.test.ts | 76 +++++++++++++++++++ packages/chat/src/room-messages.ts | 13 +++- .../chat/test/room-activity.drizzle.test.ts | 40 ++++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index 431985753..765d3ac38 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -304,6 +304,7 @@ export { createDrizzleRoomMessageStore, createInMemoryRoomMessageStore, postRoomMessage, + previewOf, } from "./room-messages"; export type { RoomMessage, diff --git a/packages/chat/src/room-messages.test.ts b/packages/chat/src/room-messages.test.ts index a3b5da715..76a1959c7 100644 --- a/packages/chat/src/room-messages.test.ts +++ b/packages/chat/src/room-messages.test.ts @@ -296,6 +296,82 @@ describe("listActivity", () => { expect(activity[WORKBENCH]?.preview).not.toMatch(/model key/i); expect(activity[WORKBENCH]?.preview).not.toMatch(/ref /i); }); + + // CL-6795: bench-list preview settles on the latest person-facing text — + // never a blank title-only row while readable messages exist, and never a + // stale greeting preferred over a newer human message. + test("a newer user message previews over a stale agent greeting", async () => { + const roomMessages = createInMemoryRoomMessageStore(); + const publisher = recordingPublisher(); + await postRoomMessage( + { roomMessages, publish: publisher.publish }, + { + tenantId: TENANT, + workbenchId: WORKBENCH, + sender: { name: "Myra", address: "run_myra@acme.example" }, + runId: "run_myra", + parts: [{ kind: "text", text: "Hi — I'm Myra. What are we working on?" }], + }, + ); + await Bun.sleep(2); + const user = await postRoomMessage( + { roomMessages, publish: publisher.publish }, + { + tenantId: TENANT, + workbenchId: WORKBENCH, + sender: { name: null, address: "prn_ada@acme.example" }, + parts: [{ kind: "text", text: "draft the agenda for Monday" }], + }, + ); + + const activity = await roomMessages.listActivity({ + tenantId: TENANT, + workbenches: [{ workbenchId: WORKBENCH }], + }); + + expect(activity[WORKBENCH]?.lastActivityAt).toBe(user.createdAt); + expect(activity[WORKBENCH]?.preview).toBe("draft the agenda for Monday"); + expect(activity[WORKBENCH]?.preview).not.toMatch(/I'm Myra/i); + }); + + test("a join notice after readable text keeps the prior preview, never blanks", async () => { + const roomMessages = createInMemoryRoomMessageStore(); + const publisher = recordingPublisher(); + await postRoomMessage( + { roomMessages, publish: publisher.publish }, + { + tenantId: TENANT, + workbenchId: WORKBENCH, + sender: { name: null, address: "prn_ada@acme.example" }, + parts: [{ kind: "text", text: "let's pull Scout in" }], + }, + ); + await Bun.sleep(2); + const joined = await postRoomMessage( + { roomMessages, publish: publisher.publish }, + { + tenantId: TENANT, + workbenchId: WORKBENCH, + sender: { name: null, address: "run_scout@acme.example" }, + runId: "run_scout", + parts: [ + { + kind: "event", + event: "workbench.agent-joined", + data: { address: "run_scout@acme.example" }, + }, + ], + }, + ); + + const activity = await roomMessages.listActivity({ + tenantId: TENANT, + workbenches: [{ workbenchId: WORKBENCH }], + }); + + expect(activity[WORKBENCH]?.lastActivityAt).toBe(joined.createdAt); + expect(activity[WORKBENCH]?.preview).toBe("let's pull Scout in"); + }); }); describe("previewOf", () => { diff --git a/packages/chat/src/room-messages.ts b/packages/chat/src/room-messages.ts index c67efdaa1..b1fe90eb1 100644 --- a/packages/chat/src/room-messages.ts +++ b/packages/chat/src/room-messages.ts @@ -181,8 +181,9 @@ function isFailurePreviewParts(parts: readonly Part[]): boolean { * Pick bench-list preview text from newest-first messages: skip failed * turns and classified failure paragraphs, keep the last good human/agent * text, and fall back to the short consumer notice when nothing else - * qualifies (CL-6735). Does not skip ordinary user/agent replies - * (CL-6795 is separate). + * qualifies (CL-6735). Event / attachment-only rows contribute no text and + * are walked past so a join notice never blanks a prior readable preview + * (CL-6795). Ordinary user/agent replies are never skipped. */ function activityPreviewFromNewestFirst( newestFirst: readonly RoomMessage[], @@ -199,6 +200,12 @@ function activityPreviewFromNewestFirst( return ""; } +/** True when listActivity must walk back past the newest row for a preview + * (CL-6735 failures; CL-6795 empty event/attachment-only newest). */ +function needsPreviewLookback(parts: readonly Part[]): boolean { + return isFailurePreviewParts(parts) || previewOf(parts).length === 0; +} + function summaryOf( newest: RoomMessage, unreadCount: number, @@ -430,7 +437,7 @@ export function createDrizzleRoomMessageStore( const newest = toRoomMessage(row as MessageRow); const unreadCount = unreadByWorkbenchId.get(newest.workbenchId) ?? 0; let newestFirstForPreview: readonly RoomMessage[] = [newest]; - if (isFailurePreviewParts(newest.parts)) { + if (needsPreviewLookback(newest.parts)) { const recentRows = await db .select() .from(workbenchMessages) diff --git a/packages/chat/test/room-activity.drizzle.test.ts b/packages/chat/test/room-activity.drizzle.test.ts index 2ecbf8dd1..8a8013aae 100644 --- a/packages/chat/test/room-activity.drizzle.test.ts +++ b/packages/chat/test/room-activity.drizzle.test.ts @@ -188,4 +188,44 @@ describeIfDb("createDrizzleRoomMessageStore: listActivity", () => { await sql.end(); } }); + + // CL-6795: a join/event newest row must not blank the list preview while + // earlier readable text still exists. + test("a join notice after readable text keeps the prior preview", async () => { + const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); + try { + const store = createDrizzleRoomMessageStore(drizzle(sql)); + await store.insertMessage({ + id: "msg_join_keep_1", + tenantId: TENANT, + workbenchId: "run_join_keep", + sender: { name: null, address: "prn_alice@acme.example" }, + parts: [{ kind: "text", text: "let's pull Scout in" }], + }); + const joined = await store.insertMessage({ + id: "msg_join_keep_2", + tenantId: TENANT, + workbenchId: "run_join_keep", + sender: { name: null, address: "run_scout@acme.example" }, + runId: "run_scout", + parts: [ + { + kind: "event", + event: "workbench.agent-joined", + data: { address: "run_scout@acme.example" }, + }, + ], + }); + + const activity = await store.listActivity({ + tenantId: TENANT, + workbenches: [{ workbenchId: "run_join_keep" }], + }); + + expect(activity["run_join_keep"]?.lastActivityAt).toBe(joined.createdAt); + expect(activity["run_join_keep"]?.preview).toBe("let's pull Scout in"); + } finally { + await sql.end(); + } + }); }); From 72dc438817fe4dd26fb17024c80d728640c3ba26 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 11:10:39 -0700 Subject: [PATCH 2/2] Fix lint: prettier for CL-6795 server preview lookback --- packages/chat/src/room-messages.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/chat/src/room-messages.test.ts b/packages/chat/src/room-messages.test.ts index 76a1959c7..b80bad1cc 100644 --- a/packages/chat/src/room-messages.test.ts +++ b/packages/chat/src/room-messages.test.ts @@ -310,7 +310,9 @@ describe("listActivity", () => { workbenchId: WORKBENCH, sender: { name: "Myra", address: "run_myra@acme.example" }, runId: "run_myra", - parts: [{ kind: "text", text: "Hi — I'm Myra. What are we working on?" }], + parts: [ + { kind: "text", text: "Hi — I'm Myra. What are we working on?" }, + ], }, ); await Bun.sleep(2);