From 29d5dcfac6a60efe1de0e3706e91833583eeffeb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Jul 2026 07:46:34 -0700 Subject: [PATCH] feat(ui): restructure chat transcript for multi-user sessions (#112938) * feat(ui): restructure chat transcript for multi-user sessions Viewer-relative alignment: attributed messages from other participants (senderId != viewer) render left-aligned as peers with their avatar, name, and identity tint; only the viewer's own messages stay right-aligned. System-role transcript entries (e.g. local command output) now render as centered notice rows instead of a pseudo-participant with a question-mark avatar. In threads with 2+ attributed senders, assistant replies carry a 'Replying to ' attribution chip derived from the preceding attributed user turn; unattributed turns clear the attribution rather than mislabeling. Also drops redundant role lowercasing on already-normalized roles. * docs(web): describe multi-user chat transcript layout * docs(web): refresh chat transcript docs map --- docs/docs_map.md | 1 + docs/web/control-ui.md | 6 +- ui/src/i18n/locales/en.ts | 1 + ui/src/lib/chat/chat-types.ts | 2 + ui/src/pages/chat/chat-thread.test.ts | 67 +++++++++++++++++ ui/src/pages/chat/chat-thread.ts | 64 ++++++++++++++-- ui/src/pages/chat/components/chat-divider.ts | 8 ++ .../chat/components/chat-message.test.ts | 75 +++++++++++++++++++ ui/src/pages/chat/components/chat-message.ts | 27 ++++++- ui/src/pages/chat/components/chat-thread.ts | 5 +- ui/src/styles/chat/grouped.css | 51 ++++++++++++- ui/src/styles/chat/layout.css | 4 + 12 files changed, 296 insertions(+), 15 deletions(-) diff --git a/docs/docs_map.md b/docs/docs_map.md index 4e3f3a0affca..38694efb6c42 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -10724,6 +10724,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Connection loss and reconnect - H2: PWA install and web push - H2: Hosted embeds + - H2: Chat transcript layout - H2: Chat message width - H2: Tailnet access (recommended) - H2: Insecure HTTP diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index dd0a98679234..4a977d1a1223 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -558,9 +558,13 @@ Use `trusted` only when the embedded document genuinely needs same-origin behavi Absolute external `http(s)` embed URLs stay blocked by default. To let `[embed url="https://..."]` load third-party pages, set `gateway.controlUi.allowExternalEmbedUrls: true`. +## Chat transcript layout + +The chat transcript uses a centered readable frame aligned with the composer. Assistant and tool output stay left-aligned while your own messages stay right-aligned inside that frame. In multi-user sessions (for example a group chat relayed from a channel plugin), messages from other attributed participants render left-aligned with the author's avatar, name, and a stable per-identity color, so only the signed-in viewer's messages read as "mine". When two or more attributed participants are present, assistant replies carry a small "Replying to name" marker naming the participant whose message triggered the turn. System entries such as local slash-command output render as centered notice rows without an avatar. + ## Chat message width -The chat transcript uses a centered readable frame aligned with the composer. Assistant and tool output stay left-aligned while user bubbles stay right-aligned inside that frame. Wide-monitor deployments can override the transcript width without patching bundled CSS by setting `ui.prefs.chatMessageMaxWidth`: +Wide-monitor deployments can override the transcript width without patching bundled CSS by setting `ui.prefs.chatMessageMaxWidth`: ```json5 { diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 99ee68ed4fe8..15e122320196 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4072,6 +4072,7 @@ export const en: TranslationMap = { openInCanvas: "Open in canvas", reply: "Reply", replyToMessage: "Reply to message", + replyingTo: "Replying to {name}", rewind: "Rewind", rewindConfirm: "Rewind to before this message?", rewindToHere: "Rewind to here", diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts index 3b5d6ebdb9d4..a1444477abe8 100644 --- a/ui/src/lib/chat/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -51,6 +51,7 @@ export type ChatQueueItem = { /** Union type for items in the chat thread */ export type ChatItem = | { kind: "message"; key: string; message: unknown; duplicateCount?: number } + | { kind: "notice"; key: string; text: string; timestamp: number } | { kind: "divider"; key: string; @@ -94,6 +95,7 @@ export type MessageGroup = { role: string; senderLabel?: string | null; sender?: SenderIdentity; + replyToSender?: SenderIdentity; messages: Array<{ message: unknown; key: string; duplicateCount?: number }>; timestamp: number; isStreaming: boolean; diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index f448606c4358..0b90bdee9d7a 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -940,12 +940,14 @@ describe("buildCachedChatItems", () => { role: "user", content: "first", senderLabel: "Iris", + __openclaw: { senderId: "iris", senderName: "Iris" }, timestamp: 1000, }, { role: "user", content: "second", senderLabel: "Joaquin De Rojas", + __openclaw: { senderId: "joaquin", senderName: "Joaquin De Rojas" }, timestamp: 1001, }, ], @@ -953,6 +955,71 @@ describe("buildCachedChatItems", () => { expect(groups).toHaveLength(2); expect(groups.map((group) => group.senderLabel)).toEqual(["Iris", "Joaquin De Rojas"]); + expect(groups.map((group) => group.sender?.id)).toEqual(["iris", "joaquin"]); + }); + + it("renders non-compaction system messages as notices and skips empty output", () => { + const items = buildCachedChatItems( + createProps({ + messages: [ + { role: "system", content: "Command output\n indented", timestamp: 1000 }, + { role: "system", content: " \n", timestamp: 1001 }, + ], + }), + ); + + expect(items).toEqual([ + { + kind: "notice", + key: expect.any(String), + text: "Command output\n indented", + timestamp: 1000, + }, + ]); + }); + + it("attributes assistant groups to the latest user in multi-sender threads", () => { + const groups = messageGroups({ + messages: [ + { + role: "user", + content: "Alice asks", + __openclaw: { senderId: "alice", senderName: "Alice" }, + timestamp: 1000, + }, + { role: "assistant", content: "For Alice", timestamp: 1001 }, + { + role: "user", + content: "Bob asks", + __openclaw: { senderId: "bob", senderName: "Bob" }, + timestamp: 1002, + }, + { role: "user", content: "Local follow-up", timestamp: 1003 }, + { role: "assistant", content: "For Bob", timestamp: 1004 }, + ], + }); + + const assistantGroups = groups.filter((group) => group.role === "assistant"); + expect(assistantGroups.map((group) => group.replyToSender)).toEqual([ + { id: "alice", name: "Alice" }, + undefined, + ]); + }); + + it("does not add reply attribution in a single-sender thread", () => { + const groups = messageGroups({ + messages: [ + { + role: "user", + content: "Alice asks", + __openclaw: { senderId: "alice", senderName: "Alice" }, + timestamp: 1000, + }, + { role: "assistant", content: "For Alice", timestamp: 1001 }, + ], + }); + + expect(groups.find((group) => group.role === "assistant")?.replyToSender).toBeUndefined(); }); it("keeps differently cased user roles in one group", () => { diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index b1f9115fe661..046d45fef0a0 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -395,6 +395,39 @@ function isKeyedAssistantStreamFallbackMessage(message: unknown): boolean { return typeof fallback?.itemId === "string" && fallback.itemId.trim().length > 0; } +function stampReplyAttribution( + items: Array, +): Array { + const userSenderKeys = new Set(); + for (const item of items) { + if (item.kind !== "group" || item.role !== "user" || !item.sender) { + continue; + } + const senderKey = senderIdentityKey(item.sender); + if (senderKey) { + userSenderKeys.add(senderKey); + } + } + if (userSenderKeys.size < 2) { + return items; + } + + let latestUserSender: MessageGroup["sender"]; + for (const item of items) { + if (item.kind !== "group") { + continue; + } + if (item.role === "user") { + // A sender-less user group clears attribution: no chip is safer than + // mislabeling the reply as addressed to the previous participant. + latestUserSender = item.sender; + } else if (item.role === "assistant" && latestUserSender) { + item.replyToSender = latestUserSender; + } + } + return items; +} + function groupMessages(items: ChatItem[]): Array { const result: Array = []; let currentGroup: MessageGroup | null = null; @@ -412,17 +445,15 @@ function groupMessages(items: ChatItem[]): Array { const normalized = normalizeMessage(item.message); const role = normalizeRoleForGrouping(normalized.role); const senderLabel = - role.toLowerCase() === "user" || role.toLowerCase() === "assistant" - ? (normalized.senderLabel ?? null) - : null; - const sender = role.toLowerCase() === "user" ? normalized.sender : undefined; + role === "user" || role === "assistant" ? (normalized.senderLabel ?? null) : null; + const sender = role === "user" ? normalized.sender : undefined; const timestamp = normalized.timestamp || Date.now(); - const shouldSplitBySender = role.toLowerCase() === "user" || role.toLowerCase() === "assistant"; + const shouldSplitBySender = role === "user" || role === "assistant"; const startsProjectedTurn = asRecord(asRecord(item.message)?.["__openclaw"])?.turnBoundary === true; const splitsAssistantCommentary = - role.toLowerCase() === "assistant" && - currentGroup?.role.toLowerCase() === "assistant" && + role === "assistant" && + currentGroup?.role === "assistant" && isKeyedAssistantStreamFallbackMessage(currentGroup.messages[0]?.message) !== isKeyedAssistantStreamFallbackMessage(item.message); @@ -460,7 +491,7 @@ function groupMessages(items: ChatItem[]): Array { if (currentGroup) { result.push(currentGroup); } - return result; + return stampReplyAttribution(result); } function mergeToolCallResultPair(callItem: ChatItem, resultItem: ChatItem): ChatItem | null { @@ -1165,6 +1196,7 @@ function chatItemTimestamp(item: ChatItem): number | null { case "message": return rawMessageTimestamp(item.message); case "divider": + case "notice": return item.timestamp; case "stream": return item.startedAt; @@ -1284,6 +1316,15 @@ function buildChatItems(props: BuildChatItemsProps): Array `; } + +export function renderChatNotice(item: Extract) { + return html` +
+ ${item.text} +
+ `; +} diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index 64efa34074a0..2d7e3fa3930e 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -8,6 +8,7 @@ import { setUiTimeFormatPreference } from "../../../lib/format.ts"; import { setAvatarGatewayOrigin } from "../../../lib/identity-avatar.ts"; import * as localStorageModule from "../../../local-storage.ts"; import * as chatAvatar from "../chat-avatar.ts"; +import { renderChatNotice } from "./chat-divider.ts"; import { dismissConfirmedActionPopovers, renderMessageGroup, @@ -1845,6 +1846,80 @@ describe("grouped chat rendering", () => { expect(local?.style.getPropertyValue("--chat-sender-hue")).toBe(""); }); + it.each([ + { label: "foreign sender", sender: { id: "other-user" }, userId: "current-user", peer: true }, + { label: "own sender", sender: { id: "current-user" }, userId: "current-user", peer: false }, + { label: "unattributed sender", sender: undefined, userId: "current-user", peer: false }, + { + label: "attributed sender without a viewer", + sender: { id: "other-user" }, + userId: null, + peer: true, + }, + ])("sets peer alignment for $label", ({ sender, userId, peer }) => { + const container = document.createElement("div"); + render( + renderMessageGroup( + { + kind: "group", + key: "peer-group", + role: "user", + ...(sender ? { sender } : {}), + messages: [{ key: "peer-message", message: { role: "user", content: "hi" } }], + timestamp: 1000, + isStreaming: false, + }, + { showReasoning: true, showToolCalls: true, userId }, + ), + container, + ); + + expect( + container.querySelector(".chat-group.user")?.classList.contains("chat-group--peer"), + ).toBe(peer); + }); + + it("renders assistant reply attribution for a multi-sender thread", () => { + const container = document.createElement("div"); + render( + renderMessageGroup( + { + kind: "group", + key: "reply-attribution", + role: "assistant", + replyToSender: { id: "alice@example.com", name: "Alice" }, + messages: [{ key: "reply", message: { role: "assistant", content: "hello" } }], + timestamp: 1000, + isStreaming: false, + }, + { showReasoning: true, showToolCalls: true }, + ), + container, + ); + + const attribution = container.querySelector(".chat-reply-attribution"); + expect(attribution?.textContent?.trim()).toBe("Alice"); + expect(attribution?.getAttribute("title")).toBe("Replying to Alice"); + expect(attribution?.nextElementSibling?.classList.contains("chat-bubble")).toBe(true); + }); + + it("renders multiline system notices as plain centered rows", () => { + const container = document.createElement("div"); + render( + renderChatNotice({ + kind: "notice", + key: "notice:command", + text: "first line\n second line", + timestamp: 1000, + }), + container, + ); + + const notice = container.querySelector(".chat-notice"); + expect(notice?.textContent?.trim()).toBe("first line\n second line"); + expect(notice?.dataset.chatRowKey).toBe("notice:command"); + }); + it("uses the current profile display name for the signed-in user's historical messages", () => { const container = document.createElement("div"); render( diff --git a/ui/src/pages/chat/components/chat-message.ts b/ui/src/pages/chat/components/chat-message.ts index ad75f14636fb..8ba5426304a9 100644 --- a/ui/src/pages/chat/components/chat-message.ts +++ b/ui/src/pages/chat/components/chat-message.ts @@ -29,6 +29,7 @@ import { normalizeMessage, } from "../../../lib/chat/message-normalizer.ts"; import { normalizeRoleForGrouping } from "../../../lib/chat/message-normalizer.ts"; +import { formatSenderLabel } from "../../../lib/chat/sender-label.ts"; import { summarizeToolGroup } from "../../../lib/chat/tool-call-grouping.ts"; import { extractToolCardsCached, @@ -915,6 +916,10 @@ const USER_TURN_ENTRY_ANIMATION_WINDOW_MS = 400; const USER_TURN_ENTRY_FRESH_SUBMIT_MS = 2_000; const USER_TURN_ENTRY_SEEN_CAP = 256; +function isPeerSenderGroup(group: MessageGroup, userId: string | null | undefined): boolean { + return Boolean(group.sender && !(userId && group.sender.id === userId)); +} + function shouldAnimateUserTurnEntry(messageKey: string, message: unknown): boolean { const now = Date.now(); const seen = userTurnEntrySeenByMessageKey.get(messageKey); @@ -951,7 +956,8 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup avatar: opts.userAvatar ?? null, }); const userLabel = group.senderLabel?.trim(); - const isCurrentUser = opts.userId && group.sender?.id === opts.userId; + const isPeerGroup = normalizedRole === "user" && isPeerSenderGroup(group, opts.userId); + const isCurrentUser = normalizedRole === "user" && Boolean(group.sender) && !isPeerGroup; const who = normalizedRole === "user" ? isCurrentUser @@ -1109,12 +1115,15 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup // messages keep the accent skin. const senderHue = normalizedRole === "user" && group.sender ? resolveIdentityHue(group.sender) : null; + const replyToLabel = + normalizedRole === "assistant" ? formatSenderLabel(group.replyToSender) : null; + const replyToTitle = replyToLabel ? t("chat.messages.replyingTo", { name: replyToLabel }) : null; return html`
@@ -1135,6 +1144,16 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup ) : nothing}
+ ${replyToLabel + ? html` +
+ + ${replyToLabel} +
+ ` + : nothing} ${group.messages.map((item, index) => { const actionDetails = messageActionDetails[index]; return html` diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index 053e52871476..6680e28214fd 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -67,7 +67,7 @@ import type { PlanStatus } from "../tool-stream.ts"; import { getToolTitlesVersion } from "../tool-titles.ts"; import { renderBackgroundTasksStatusRow } from "./chat-background-tasks-status.ts"; import type { BackgroundTasksProps } from "./chat-background-tasks.ts"; -import { renderChatDivider } from "./chat-divider.ts"; +import { renderChatDivider, renderChatNotice } from "./chat-divider.ts"; import { dismissConfirmedActionPopovers, getAssistantAttachmentAvailabilityRenderVersion, @@ -1291,6 +1291,9 @@ function renderChatThreadContents( if (item.kind === "divider") { return renderChatDivider(item, props.onOpenSessionCheckpoints); } + if (item.kind === "notice") { + return renderChatNotice(item); + } if (item.kind === "stream-run") { return renderStreamGroup(item.parts, { questionPrompts, diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 9e1ea69659bc..1401641f2b32 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -22,6 +22,10 @@ justify-content: flex-start; } +.chat-group.user.chat-group--peer { + flex-direction: row; +} + /* Freshly submitted composer text flows up into the transcript. Applied only to the locally pending bubble (see shouldAnimateUserTurnEntry); the bubble keeps a stable key through the history handoff so this never replays. */ @@ -58,6 +62,13 @@ justify-content: end; } +.chat-group.user.chat-group--with-footer:where(.chat-group--peer) { + --chat-group-avatar-column: 1; + --chat-group-content-column: 2; + grid-template-columns: 36px minmax(0, var(--chat-message-column-max)); + justify-content: start; +} + .chat-group.chat-group--with-footer .chat-avatar { grid-column: var(--chat-group-avatar-column); grid-row: 1; @@ -92,6 +103,10 @@ align-items: flex-end; } +.chat-group.user.chat-group--peer .chat-group-messages { + align-items: flex-start; +} + .chat-group.tool { --chat-message-column-max: var(--chat-message-max-width, min(980px, calc(100% - 46px))); } @@ -100,6 +115,10 @@ justify-content: flex-end; } +.chat-group.user.chat-group--peer .chat-group-footer { + justify-content: flex-start; +} + /* Footer at bottom of a message group (role + time). It stays in flow while visually hidden so wrapped controls contribute to virtual-row measurement instead of painting over the next message. */ @@ -385,6 +404,16 @@ white-space: nowrap; } +.chat-notice { + margin: 14px auto; + padding: 0 16px; + color: var(--muted); + font-size: 12px; + line-height: 1.4; + text-align: center; + white-space: pre-wrap; +} + /* Avatar Styles */ .chat-avatar { width: 36px; @@ -585,6 +614,20 @@ img.chat-avatar { color: color-mix(in srgb, var(--foreground) 80%, var(--primary) 20%); } +.chat-reply-attribution { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--muted); + font-size: 12px; +} + +.chat-reply-attribution__icon, +.chat-reply-attribution__icon svg { + width: 14px; + height: 14px; +} + @media (hover: none), (max-width: 768px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) { @@ -839,6 +882,11 @@ details.msg-meta:not([open]) .msg-meta__details { left: auto; } +.chat-group.user.chat-group--peer .msg-meta__details { + right: auto; + left: 0; +} + .msg-meta__time, .msg-meta__tokens, .msg-meta__cache, @@ -992,7 +1040,8 @@ details.msg-meta:not([open]) .msg-meta__details { --chat-message-column-max: 88%; } - .chat-group.chat-group--with-footer { + .chat-group.chat-group--with-footer, + .chat-group.user.chat-group--peer.chat-group--with-footer { --chat-group-avatar-column: 1; --chat-group-content-column: 1; grid-template-columns: minmax(0, var(--chat-message-column-max)); diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index d582402545cc..bd4027b7440f 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -1064,6 +1064,10 @@ openclaw-chat-page { justify-content: flex-end; } +.chat-group.user.chat-group--peer .chat-message-images { + justify-content: flex-start; +} + .chat-assistant-attachments { display: flex; flex-direction: column;