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
49 changes: 31 additions & 18 deletions apps/web/src/chat/threads-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -517,35 +519,46 @@ 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<readonly RoomMessage[]> {
const [inbox, sent] = await Promise.all([
readRoomFolder(tenantId, "INBOX"),
readRoomFolder(tenantId, "Sent"),
]);
const turns = [...inbox, ...sent].sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
const known = new Set(turns.map((turn) => turn.messageId));
const children = new Map<string, RoomTurn[]>();
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<string>();
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
Expand Down
29 changes: 12 additions & 17 deletions apps/web/src/pages/workbench-room-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useEffect, useState } from "react";
import { Markdown } from "@/chat/markdown";
import {
agentInitials,
ancestorChain,
listRoomParticipants,
readRoom,
sendToRoom,
Expand Down Expand Up @@ -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 (
<div className="chat-thread-message" data-author={message.author}>
Expand All @@ -96,13 +98,9 @@ function RoomMessageRow({
</span>
<div className="chat-thread-body">
<Markdown text={message.body} />
{message.replies.length === 0 ? null : (
<button
type="button"
className="room-replies-link"
onClick={() => onOpenReplies(message)}
>
{message.replies.length === 1 ? "1 reply" : `${String(message.replies.length)} replies`}
{onReply === undefined ? null : (
<button type="button" className="room-replies-link" onClick={() => onReply(message)}>
Reply
</button>
)}
</div>
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -352,7 +351,7 @@ function Room({ roomTenantId }: { readonly roomTenantId: string }) {
<RoomMessageRow
key={message.id}
message={message}
onOpenReplies={(target) => setOpenThread(target.id)}
onReply={(target) => setOpenThread(target.messageId)}
/>
))}
</div>
Expand Down Expand Up @@ -380,12 +379,8 @@ function Room({ roomTenantId }: { readonly roomTenantId: string }) {
</Button>
</div>
<div className="chat-thread-messages">
{[opened, ...opened.replies].map((message) => (
<RoomMessageRow
key={message.id}
message={message}
onOpenReplies={() => undefined}
/>
{openedChain.map((message) => (
<RoomMessageRow key={message.id} message={message} />
))}
</div>
<RoomComposer
Expand Down
Loading