diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index d83e6044b..a1801a46c 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -482,8 +482,10 @@ export type RoomMessage = { readonly authorName: string; readonly body: string; readonly at: string; - /** Native in-reply-to children: the room's sub-threads. */ - readonly replies: readonly RoomMessage[]; + /** In-reply-to parent, when this turn named one and it matched a known + * message — metadata for the sub-thread panel, never used to hide a turn + * from the main timeline. */ + readonly parentMessageId: string | undefined; }; function authorName(address: string): string { @@ -517,8 +519,10 @@ async function readRoomFolder(tenantId: string, folder: "INBOX" | "Sent"): Promi })); } -/** The room timeline: every root turn oldest first, its in-reply-to chain - * nested beneath it as the room's sub-threads. */ +/** The room timeline: every turn oldest first, flat — no turn is ever + * dropped from the main list. A turn whose in-reply-to names a known + * message keeps that as `parentMessageId`, metadata for the sub-thread + * panel to walk the ancestor chain of whichever turn the person opened. */ export async function readRoom(tenantId: string): Promise { const [inbox, sent] = await Promise.all([ readRoomFolder(tenantId, "INBOX"), @@ -526,26 +530,35 @@ export async function readRoom(tenantId: string): Promise Date.parse(a.at) - Date.parse(b.at)); const known = new Set(turns.map((turn) => turn.messageId)); - const children = new Map(); - const roots: RoomTurn[] = []; - for (const turn of turns) { - const parentId = turn.parentId; - if (parentId === undefined || !known.has(parentId)) { - roots.push(turn); - continue; - } - children.set(parentId, [...(children.get(parentId) ?? []), turn]); - } - const build = (turn: RoomTurn): RoomMessage => ({ + return turns.map((turn) => ({ id: turn.id, messageId: turn.messageId, author: turn.author, authorName: turn.authorName, body: turn.body, at: turn.at, - replies: (children.get(turn.messageId) ?? []).map(build), - }); - return roots.map(build); + parentMessageId: + turn.parentId !== undefined && known.has(turn.parentId) ? turn.parentId : undefined, + })); +} + +/** The ancestor chain of a turn, oldest first, ending with the turn itself + * — what the sub-thread panel shows for the turn the person opened. */ +export function ancestorChain( + messages: readonly RoomMessage[], + messageId: string, +): readonly RoomMessage[] { + const byMessageId = new Map(messages.map((message) => [message.messageId, message])); + const chain: RoomMessage[] = []; + let current = byMessageId.get(messageId); + const seen = new Set(); + while (current !== undefined && !seen.has(current.messageId)) { + seen.add(current.messageId); + chain.unshift(current); + current = + current.parentMessageId === undefined ? undefined : byMessageId.get(current.parentMessageId); + } + return chain; } /** The one send seam for a room: a single mailbox send addressed to every diff --git a/apps/web/src/pages/workbench-room-page.tsx b/apps/web/src/pages/workbench-room-page.tsx index d437675d0..afd169569 100644 --- a/apps/web/src/pages/workbench-room-page.tsx +++ b/apps/web/src/pages/workbench-room-page.tsx @@ -22,6 +22,7 @@ import { useEffect, useState } from "react"; import { Markdown } from "@/chat/markdown"; import { agentInitials, + ancestorChain, listRoomParticipants, readRoom, sendToRoom, @@ -82,10 +83,11 @@ function RoomComposer({ function RoomMessageRow({ message, - onOpenReplies, + onReply, }: { readonly message: RoomMessage; - readonly onOpenReplies: (message: RoomMessage) => void; + /** Undefined in the sub-thread panel, where a row is read-only context. */ + readonly onReply?: (message: RoomMessage) => void; }) { return (
@@ -96,13 +98,9 @@ function RoomMessageRow({
- {message.replies.length === 0 ? null : ( - )}
@@ -320,7 +318,8 @@ function Room({ roomTenantId }: { readonly roomTenantId: string }) { const messages = timeline.data ?? []; const latestMessage = [...messages].sort((a, b) => Date.parse(b.at) - Date.parse(a.at))[0]; - const opened = messages.find((message) => message.id === openThread); + const openedChain = openThread === null ? [] : ancestorChain(messages, openThread); + const opened = openedChain.at(-1); const failure: unknown = timeline.error ?? participants.error; if (failure !== null && failure !== undefined && timeline.data === undefined) { @@ -352,7 +351,7 @@ function Room({ roomTenantId }: { readonly roomTenantId: string }) { setOpenThread(target.id)} + onReply={(target) => setOpenThread(target.messageId)} /> ))}
@@ -380,12 +379,8 @@ function Room({ roomTenantId }: { readonly roomTenantId: string }) {
- {[opened, ...opened.replies].map((message) => ( - undefined} - /> + {openedChain.map((message) => ( + ))}