mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
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:
committed by
GitHub
parent
12d7009f71
commit
29d5dcfac6
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user