diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index ed62921308c3..7097d05c5247 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -246,6 +246,26 @@ img.chat-avatar { padding-right: 70px; } +.chat-message-source-label { + display: inline-flex; + align-items: center; + min-height: 18px; + margin: 0 0 6px; + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--border) 78%, transparent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--secondary) 54%, transparent); + color: color-mix(in srgb, var(--text) 62%, var(--muted) 38%); + font-size: 11px; + font-weight: 650; + line-height: 1; + letter-spacing: 0; +} + +.chat-bubble--tool-shell > .chat-message-source-label { + margin: 0 0 6px 1px; +} + .chat-duplicate-count { display: inline-flex; align-items: center; @@ -283,7 +303,9 @@ img.chat-avatar { pointer-events: auto; } -@media (hover: none), (max-width: 768px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) { +@media (hover: none), + (max-width: 768px), + (max-width: 932px) and (max-height: 500px) and (orientation: landscape) { .chat-bubble-actions { opacity: 1; pointer-events: auto; diff --git a/ui/src/ui/chat/build-chat-items.test.ts b/ui/src/ui/chat/build-chat-items.test.ts index f2883be0946a..4ccfd26ac4e3 100644 --- a/ui/src/ui/chat/build-chat-items.test.ts +++ b/ui/src/ui/chat/build-chat-items.test.ts @@ -524,6 +524,7 @@ describe("buildChatItems", () => { text: "Visible reply", startedAt: 1, isStreaming: true, + source: "final", }, ]); }); diff --git a/ui/src/ui/chat/build-chat-items.ts b/ui/src/ui/chat/build-chat-items.ts index e820d8c1af94..09cad27f1c34 100644 --- a/ui/src/ui/chat/build-chat-items.ts +++ b/ui/src/ui/chat/build-chat-items.ts @@ -831,6 +831,7 @@ export function buildChatItems(props: BuildChatItemsProps): Array { it("omits streaming bubble class for completed stream segments", () => { const container = document.createElement("div"); - render(renderStreamingGroup("Completed segment", 1, false), container); + render(renderStreamingGroup("Completed segment", 1, false, "commentary"), container); const bubble = container.querySelector(".chat-bubble"); expect(bubble?.classList.contains("streaming")).toBe(false); @@ -912,6 +912,65 @@ describe("grouped chat rendering", () => { expect(text?.textContent).toBe("**live**\nreply"); }); + it("labels commentary, tool call, and final message blocks", () => { + const container = document.createElement("div"); + + render( + html` + ${renderStreamingGroup("Checking workspace", 1, false, "commentary")} + ${renderMessageGroup( + createMessageGroup( + { + role: "toolResult", + toolCallId: "call_1", + toolName: "shell", + content: "tool output", + timestamp: 2, + }, + "tool", + ), + { + showReasoning: true, + showToolCalls: true, + }, + )} + ${renderMessageGroup( + createMessageGroup( + { + role: "assistant", + content: [{ type: "text", text: "Done" }], + timestamp: 3, + }, + "assistant", + ), + { + showReasoning: true, + showToolCalls: true, + assistantName: "OpenClaw", + assistantAvatar: null, + }, + )} + `, + container, + ); + + expect( + [...container.querySelectorAll(".chat-message-source-label")].map((node) => + node.textContent?.trim(), + ), + ).toEqual(["Commentary", "Tool call", "Final message"]); + }); + + it("labels live final-answer streams as final messages", () => { + const container = document.createElement("div"); + + render(renderStreamingGroup("Streaming final answer", 1), container); + + expect(container.querySelector(".chat-message-source-label")?.textContent?.trim()).toBe( + "Final message", + ); + }); + it("renders configured local user names", () => { const renderUser = (opts: Partial) => { const container = document.createElement("div"); diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index d9569134b030..8dc30736c645 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -387,6 +387,7 @@ export function renderStreamingGroup( text: string, startedAt: number, isStreaming = true, + source: "commentary" | "final" = "final", onOpenSidebar?: (content: SidebarContent) => void, assistant?: AssistantIdentity, basePath?: string, @@ -403,6 +404,7 @@ export function renderStreamingGroup( role: "assistant", content: [{ type: "text", text }], timestamp: startedAt, + openclawStreamFallback: { source: source === "commentary" ? "segment" : "current" }, }, `stream:${startedAt}`, { isStreaming, showReasoning: false }, @@ -446,6 +448,23 @@ type RenderMessageGroupOptions = { type GroupedMessageRenderOptions = Parameters[2]; +function streamFallbackSource(message: Record): string | null { + const fallback = message.openclawStreamFallback; + if (!fallback || typeof fallback !== "object" || Array.isArray(fallback)) { + return null; + } + const source = (fallback as { source?: unknown }).source; + return typeof source === "string" ? source : null; +} + +function renderMessageSourceLabel(label: string | null) { + return label + ? html`
+ ${label} +
` + : nothing; +} + function buildGroupedMessageRenderOptions( group: MessageGroup, item: MessageGroup["messages"][number], @@ -1715,6 +1734,14 @@ function renderGroupedMessage( const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null; const isToolMessage = normalizedRole === "tool" || isToolResult; + const fallbackSource = streamFallbackSource(m); + const messageSourceLabel = isToolMessage + ? "Tool call" + : normalizedRole === "assistant" + ? fallbackSource === "segment" + ? "Commentary" + : "Final message" + : null; const reserveActionSpace = hasActions && !isToolMessage; const bubbleClasses = [ "chat-bubble", @@ -1791,6 +1818,7 @@ function renderGroupedMessage( data-message-id=${messageKey} data-message-text=${extractedText || nothing} > + ${renderMessageSourceLabel(messageSourceLabel)} ${renderReplyPill(normalizedMessage.replyTarget)} ${hasActions ? html`
diff --git a/ui/src/ui/types/chat-types.ts b/ui/src/ui/types/chat-types.ts index 749a8859cff4..a272a2b63671 100644 --- a/ui/src/ui/types/chat-types.ts +++ b/ui/src/ui/types/chat-types.ts @@ -13,7 +13,14 @@ export type ChatItem = action?: { kind: "session-checkpoints"; label: string }; timestamp: number; } - | { kind: "stream"; key: string; text: string; startedAt: number; isStreaming: boolean } + | { + kind: "stream"; + key: string; + text: string; + startedAt: number; + isStreaming: boolean; + source: "commentary" | "final"; + } | { kind: "reading-indicator"; key: string }; /** A group of consecutive messages from the same role (Slack-style layout) */ diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 62b33360bbd9..18a53f2df897 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -2388,6 +2388,7 @@ export function renderChat(props: ChatProps) { item.text, item.startedAt, item.isStreaming, + item.source, props.onOpenSidebar, assistantIdentity, props.basePath,