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)}
)}