From 2c98df76ee72768f6e1d3ee3a94bc53a401655b9 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 22:46:02 -0700 Subject: [PATCH] fix(web): activity rows and chat titles use participant names and the person's first turn (CL-8449) --- apps/web/src/chat/threads-api.ts | 50 ++++++++++++++++++++-- apps/web/src/pages/workbench-room-page.tsx | 3 +- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index fa92627d68..ef8dde5c17 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -211,11 +211,31 @@ function extractAddress(raw: string): string { return (/<([^>]+)>/.exec(raw)?.[1] ?? raw).trim(); } +/** Addresses on a frame's `To:` header, unfolded and parsed straight from + * `raw` — the fallback for a person turn, whose `envelope.to` the mailbox + * currently reports empty (library fix in flight). */ +function toHeaderAddresses(raw: string): string[] { + let decoded: string; + try { + decoded = atob(raw); + } catch { + return []; + } + const headers = decoded.replace(/\r\n/g, "\n").split("\n\n")[0] ?? ""; + const unfolded = headers.replace(/\n[ \t]+/g, " "); + const to = /^to:\s*(.+)$/im.exec(unfolded)?.[1]; + return to === undefined ? [] : to.split(",").map((address) => address.trim()); +} + /** The run address on a message, from whichever side (from/to) carries * one — a plain address, not just its local part, so it can be matched * against an agent's full address set. */ -function participantAddress(envelope: { from: string; to: readonly string[] }): string | undefined { - return [envelope.from, ...envelope.to] +function participantAddress( + envelope: { from: string; to: readonly string[] }, + raw: string, +): string | undefined { + const to = envelope.to.length > 0 ? envelope.to : toHeaderAddresses(raw); + return [envelope.from, ...to] .map(extractAddress) .find((address) => address.split("@")[0]?.startsWith("run_")); } @@ -236,7 +256,7 @@ type MailTurn = { async function readFolder(tenantId: string, folder: "INBOX" | "Sent"): Promise { const page = await getJson(`${mailboxPath(tenantId)}?folder=${folder}&limit=100`, InboxPage); return page.messages.flatMap((message) => { - const address = participantAddress(message.envelope); + const address = participantAddress(message.envelope, message.raw); if (address === undefined) return []; return [ { @@ -341,8 +361,10 @@ export type ChatThread = { readonly messages: readonly ChatMessage[]; }; +/** The chat title is always the person's own opening turn — never an + * agent reply — so a chat never titles itself off what the agent said. */ function chatTitle(turns: readonly MailTurn[], agentName: string): string { - const first = turns[0]; + const first = turns.find((turn) => turn.author === "me"); if (first === undefined) return agentName; return first.subject.length > 0 ? first.subject : first.body.slice(0, 60); } @@ -484,6 +506,10 @@ export type RoomMessage = { readonly authorName: string; readonly body: string; readonly at: string; + /** The sender's raw address — matched against room participants for a + * display name; falls back to `authorName` (the address local part) when + * no participant matches. */ + readonly address: string; /** 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. */ @@ -495,12 +521,26 @@ function authorName(address: string): string { return local.length > 0 ? local : address; } +/** A turn's display name: the matching room participant's name, else the + * address local part — never the raw run/email address. */ +export function resolveParticipantName( + message: Pick, + participants: readonly RoomParticipant[], +): string { + if (message.author === "me") return message.authorName; + return ( + participants.find((participant) => participant.address === message.address)?.name ?? + message.authorName + ); +} + type RoomTurn = { readonly id: string; readonly messageId: string; readonly parentId: string | undefined; readonly author: "me" | "other"; readonly authorName: string; + readonly address: string; readonly body: string; readonly at: string; }; @@ -516,6 +556,7 @@ async function readRoomFolder(tenantId: string, folder: "INBOX" | "Sent"): Promi parentId: message.envelope.inReplyTo ?? message.envelope.references.at(-1), author: folder === "Sent" ? ("me" as const) : ("other" as const), authorName: folder === "Sent" ? "You" : authorName(message.envelope.from), + address: extractAddress(message.envelope.from), body: frameBody(message.raw), at: message.envelope.date, })); @@ -537,6 +578,7 @@ export async function readRoom(tenantId: string): PromiseNothing yet — say something to get started.

) : (

- {latestMessage.author === "me" ? "You" : latestMessage.authorName} ·{" "} + {resolveParticipantName(latestMessage, participants)} ·{" "} {formatRelativeTime(latestMessage.at)}

)}