Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions packages/chat-ui/src/use-workbench-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ import {
listMessages,
listPinnedMessages,
listThreads,
workbenchesQueryKey,
} from "./api";
import type {
MessageItem,
MessagesResponse,
PinnedMessage,
ReactionSummary,
Workbench,
WorkbenchThreadRow,
} from "./api";

Expand Down Expand Up @@ -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<MessageItem["parts"][number], { kind: "text" }> =>
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
Expand All @@ -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,
Expand All @@ -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),
Expand Down
83 changes: 82 additions & 1 deletion packages/chat-ui/test/stream-apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -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<readonly Workbench[]>(
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<readonly Workbench[]>(
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", () => {
Expand Down
Loading