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 <name>' 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
This commit is contained in:
Peter Steinberger
2026-07-23 07:46:34 -07:00
committed by GitHub
parent 12d7009f71
commit 29d5dcfac6
12 changed files with 296 additions and 15 deletions
+1
View File
@@ -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
+5 -1
View File
@@ -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
{
+1
View File
@@ -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",
+2
View File
@@ -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;
+67
View File
@@ -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", () => {
+56 -8
View File
@@ -395,6 +395,39 @@ function isKeyedAssistantStreamFallbackMessage(message: unknown): boolean {
return typeof fallback?.itemId === "string" && fallback.itemId.trim().length > 0;
}
function stampReplyAttribution(
items: Array<ChatItem | MessageGroup>,
): Array<ChatItem | MessageGroup> {
const userSenderKeys = new Set<string>();
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<ChatItem | MessageGroup> {
const result: Array<ChatItem | MessageGroup> = [];
let currentGroup: MessageGroup | null = null;
@@ -412,17 +445,15 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
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<ChatItem | MessageGroup> {
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<ChatItem | MessageGro
continue;
}
const role = normalizeRoleForGrouping(normalized.role);
if (role === "system") {
const text = extractTextCached(msg);
if (text?.trim()) {
items.push({ kind: "notice", key: itemKey, text, timestamp: normalized.timestamp });
}
continue;
}
const isToolResult = normalized.role.toLowerCase() === "toolresult";
const persistedCanvasSource = isToolResult ? extractChatMessagePreview(msg) : null;
const renderPersistedPreview =
@@ -1610,6 +1651,7 @@ function sameMessageGroup(previous: MessageGroup, next: MessageGroup): boolean {
previous.role === next.role &&
previous.senderLabel === next.senderLabel &&
senderIdentityKey(previous.sender) === senderIdentityKey(next.sender) &&
senderIdentityKey(previous.replyToSender) === senderIdentityKey(next.replyToSender) &&
previous.isStreaming === next.isStreaming &&
previous.turnSucceeded === next.turnSucceeded &&
previous.messages.length === next.messages.length &&
@@ -1638,6 +1680,12 @@ function sameChatItem(previous: RenderChatItem, next: RenderChatItem): boolean {
previous.message === next.message &&
previous.duplicateCount === next.duplicateCount
);
case "notice":
return (
previous.kind === "notice" &&
previous.text === next.text &&
previous.timestamp === next.timestamp
);
case "divider":
return (
previous.kind === "divider" &&
@@ -47,3 +47,11 @@ export function renderChatDivider(
</div>
`;
}
export function renderChatNotice(item: Extract<ChatItem, { kind: "notice" }>) {
return html`
<div class="chat-notice" data-chat-row-key=${item.key} data-ts=${String(item.timestamp)}>
${item.text}
</div>
`;
}
@@ -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<HTMLElement>(".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<HTMLElement>(".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(
+23 -4
View File
@@ -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`
<div
class="chat-group ${roleClass} chat-group--with-footer${senderHue === null
? ""
: " chat-group--sender-tint"}"
class="chat-group ${roleClass} chat-group--with-footer${isPeerGroup
? " chat-group--peer"
: ""}${senderHue === null ? "" : " chat-group--sender-tint"}"
style=${senderHue === null ? nothing : `--chat-sender-hue: ${senderHue}`}
data-chat-row-key=${group.key}
>
@@ -1135,6 +1144,16 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
)
: nothing}
<div class="chat-group-messages">
${replyToLabel
? html`
<div class="chat-reply-attribution" title=${replyToTitle} aria-label=${replyToTitle}>
<span class="chat-reply-attribution__icon" aria-hidden="true"
>${icons.cornerDownLeft}</span
>
<span>${replyToLabel}</span>
</div>
`
: nothing}
${group.messages.map((item, index) => {
const actionDetails = messageActionDetails[index];
return html`
+4 -1
View File
@@ -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,
+50 -1
View File
@@ -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));
+4
View File
@@ -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;