diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json
index 3ae96b2dd158..5ed009329557 100644
--- a/apps/.i18n/native-source.json
+++ b/apps/.i18n/native-source.json
@@ -40707,7 +40707,7 @@
},
{
"kind": "conditional-branch",
- "line": 708,
+ "line": 701,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Voice note",
"surface": "apple",
@@ -40715,7 +40715,7 @@
},
{
"kind": "ui-localized-call",
- "line": 749,
+ "line": 742,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Attachment",
"surface": "apple",
@@ -40723,7 +40723,7 @@
},
{
"kind": "ui-call",
- "line": 789,
+ "line": 782,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Writing",
"surface": "apple",
@@ -40731,7 +40731,7 @@
},
{
"kind": "ui-call",
- "line": 828,
+ "line": 821,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Preparing audio…",
"surface": "apple",
@@ -40739,7 +40739,7 @@
},
{
"kind": "ui-call",
- "line": 831,
+ "line": 824,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Speaking…",
"surface": "apple",
@@ -40747,7 +40747,7 @@
},
{
"kind": "conditional-branch",
- "line": 839,
+ "line": 832,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Preparing audio, tap to cancel",
"surface": "apple",
@@ -40755,7 +40755,7 @@
},
{
"kind": "conditional-branch",
- "line": 840,
+ "line": 833,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Speaking, tap to stop",
"surface": "apple",
@@ -41987,7 +41987,7 @@
},
{
"kind": "ui-call",
- "line": 1172,
+ "line": 1165,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Copy Message",
"surface": "apple",
@@ -41995,7 +41995,7 @@
},
{
"kind": "ui-call",
- "line": 1195,
+ "line": 1188,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Open Full Message",
"surface": "apple",
@@ -42003,7 +42003,7 @@
},
{
"kind": "ui-call",
- "line": 1214,
+ "line": 1207,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Rewind to Here",
"surface": "apple",
@@ -42011,7 +42011,7 @@
},
{
"kind": "ui-call",
- "line": 1234,
+ "line": 1227,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Fork from Here",
"surface": "apple",
@@ -42019,7 +42019,7 @@
},
{
"kind": "ui-localized-call",
- "line": 1260,
+ "line": 1253,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Reply",
"surface": "apple",
@@ -42027,7 +42027,7 @@
},
{
"kind": "ui-localized-call",
- "line": 1270,
+ "line": 1263,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "You",
"surface": "apple",
@@ -42035,7 +42035,7 @@
},
{
"kind": "ui-localized-call",
- "line": 1272,
+ "line": 1265,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Assistant",
"surface": "apple",
@@ -42043,7 +42043,7 @@
},
{
"kind": "ui-call",
- "line": 1338,
+ "line": 1331,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Loading chat",
"surface": "apple",
@@ -42051,7 +42051,7 @@
},
{
"kind": "ui-modifier",
- "line": 1418,
+ "line": 1411,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Dismiss",
"surface": "apple",
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift
index d040f84a9549..37cdade0e353 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift
@@ -515,16 +515,9 @@ private struct ChatMessageBody: View {
}
private var primaryText: String {
- let parts = self.message.content.compactMap { content -> String? in
- let kind = (content.type ?? "text").lowercased()
- guard kind == "text" || kind.isEmpty else { return nil }
- return content.text
- }
- return OpenClawChatMessage.displayText(
- contentText: parts.joined(separator: "\n"),
- role: self.message.role,
- stopReason: self.message.stopReason,
- errorMessage: self.message.errorMessage)
+ ChatMessageVisibleText.displayText(
+ in: self.message,
+ includeThinking: self.displayOptions.contains(.reasoning))
}
private var inlineAttachments: [OpenClawChatMessageContent] {
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageVisibleText.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageVisibleText.swift
index 7e0c30f09264..7d825f85bf5b 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageVisibleText.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageVisibleText.swift
@@ -25,11 +25,24 @@ public enum ChatMessageVisibleText {
.isEmpty
}
- private static func primaryText(in message: OpenClawChatMessage) -> String {
+ static func displayText(in message: OpenClawChatMessage, includeThinking: Bool) -> String {
+ let isAssistant = message.role.trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased() == "assistant"
let parts = message.content.compactMap { content -> String? in
let kind = (content.type ?? "text").lowercased()
- guard kind == "text" || kind.isEmpty else { return nil }
- return content.text
+ if kind == "text" || kind.isEmpty {
+ return content.text
+ }
+ guard
+ includeThinking,
+ isAssistant,
+ kind == "thinking",
+ let thinking = content.thinking,
+ !thinking.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ else {
+ return nil
+ }
+ return "\n\(thinking)\n"
}
return OpenClawChatMessage.displayText(
contentText: parts.joined(separator: "\n"),
@@ -37,4 +50,8 @@ public enum ChatMessageVisibleText {
stopReason: message.stopReason,
errorMessage: message.errorMessage)
}
+
+ private static func primaryText(in message: OpenClawChatMessage) -> String {
+ self.displayText(in: message, includeThinking: false)
+ }
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift
index f04fb5e69462..6e955441003f 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift
@@ -1111,16 +1111,9 @@ extension OpenClawChatView {
}
private func primaryText(in message: OpenClawChatMessage) -> String {
- let parts = message.content.compactMap { content -> String? in
- let kind = (content.type ?? "text").lowercased()
- guard kind == "text" || kind.isEmpty else { return nil }
- return content.text
- }
- return OpenClawChatMessage.displayText(
- contentText: parts.joined(separator: "\n"),
- role: message.role,
- stopReason: message.stopReason,
- errorMessage: message.errorMessage)
+ ChatMessageVisibleText.displayText(
+ in: message,
+ includeThinking: self.displayOptions.contains(.reasoning))
}
private func hasInlineAttachments(in message: OpenClawChatMessage) -> Bool {
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageVisibleTextTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageVisibleTextTests.swift
index 4d613064734d..208a84c0e4ef 100644
--- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageVisibleTextTests.swift
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageVisibleTextTests.swift
@@ -17,6 +17,16 @@ private func toolCallContent(name: String) -> OpenClawChatMessageContent {
name: name)
}
+private func thinkingContent(_ thinking: String) -> OpenClawChatMessageContent {
+ OpenClawChatMessageContent(
+ type: "thinking",
+ text: nil,
+ thinking: thinking,
+ mimeType: nil,
+ fileName: nil,
+ content: nil)
+}
+
@Suite("ChatMessageVisibleText")
struct ChatMessageVisibleTextTests {
@Test func `assistant visible text skips non text blocks`() {
@@ -56,6 +66,23 @@ struct ChatMessageVisibleTextTests {
#expect(ChatMessageVisibleText.copyText(in: user) == "Keep this literal tag")
}
+ @Test func `assistant display includes structured thinking only when enabled`() {
+ let message = OpenClawChatMessage(
+ role: "assistant",
+ content: [
+ thinkingContent("Check the persisted state."),
+ textContent("Here is the answer."),
+ toolCallContent(name: "read"),
+ ],
+ timestamp: 1)
+
+ #expect(ChatMessageVisibleText.displayText(in: message, includeThinking: false)
+ == "Here is the answer.")
+ #expect(ChatMessageVisibleText.displayText(in: message, includeThinking: true)
+ == "\nCheck the persisted state.\n\nHere is the answer.")
+ #expect(ChatMessageVisibleText.copyText(in: message) == "Here is the answer.")
+ }
+
@Test func `history decode retains transcript identity and truncation signals`() throws {
let metadata = try JSONDecoder().decode(
OpenClawChatMessage.self,
diff --git a/src/gateway/chat-display-projection.sanitize.ts b/src/gateway/chat-display-projection.sanitize.ts
index adec0af4958a..6ac80d04a274 100644
--- a/src/gateway/chat-display-projection.sanitize.ts
+++ b/src/gateway/chat-display-projection.sanitize.ts
@@ -157,7 +157,7 @@ function sanitizeAssistantPhasedContentBlocks(content: unknown[]): {
};
}
-function projectAssistantTextFromMixedToolContent(
+function projectAssistantMixedToolContent(
content: unknown[],
maxChars: number,
): { content: unknown[]; changed: boolean } | null {
@@ -171,23 +171,31 @@ function projectAssistantTextFromMixedToolContent(
return null;
}
- const textBlocks: unknown[] = [];
+ let hasVisibleText = false;
+ const projectedContent: unknown[] = [];
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const entry = block as { type?: unknown; text?: unknown };
- if (entry.type !== "text" || typeof entry.text !== "string" || !entry.text.trim()) {
+ if (!isAssistantTextContentType(entry.type)) {
+ projectedContent.push(block);
+ continue;
+ }
+ if (typeof entry.text !== "string" || !entry.text.trim()) {
continue;
}
const stripped = stripInlineDirectiveTagsForDisplay(entry.text);
const truncated = truncateChatHistoryText(stripped.text, maxChars);
if (truncated.text.trim()) {
- textBlocks.push({ type: "text", text: truncated.text });
+ projectedContent.push({ type: "text", text: truncated.text });
+ hasVisibleText = true;
}
}
- return textBlocks.length > 0 ? { content: textBlocks, changed: true } : null;
+ // Mixed messages supply both the visible bubble and its reasoning/tool trace.
+ // Keep structured siblings or a history reload loses activity shown while live.
+ return hasVisibleText ? { content: projectedContent, changed: true } : null;
}
function toFiniteNumber(x: unknown): number | undefined {
@@ -394,9 +402,9 @@ export function sanitizeChatHistoryMessage(
changed = true;
}
if (entry.role === "assistant" && Array.isArray(entry.content)) {
- const mixedToolText = projectAssistantTextFromMixedToolContent(entry.content, maxChars);
- if (mixedToolText) {
- entry.content = mixedToolText.content;
+ const mixedToolContent = projectAssistantMixedToolContent(entry.content, maxChars);
+ if (mixedToolContent) {
+ entry.content = mixedToolContent.content;
if (entry.phase === "commentary") {
delete entry.phase;
}
diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts
index 408db0074785..cab007762603 100644
--- a/src/gateway/server-methods/server-methods.test.ts
+++ b/src/gateway/server-methods/server-methods.test.ts
@@ -1123,7 +1123,15 @@ describe("projectRecentChatDisplayMessages", () => {
errorMessage: privateError,
errorBody: "private response body",
},
- content: [{ type: "text", text: "I read the requested file before the run failed." }],
+ content: [
+ { type: "text", text: "I read the requested file before the run failed." },
+ {
+ type: "toolCall",
+ id: "call-1",
+ name: "read",
+ arguments: { path: "README.md" },
+ },
+ ],
},
];
@@ -1927,7 +1935,7 @@ describe("projectRecentChatDisplayMessages", () => {
]);
});
- it("keeps visible assistant progress text from mixed tool-use messages", () => {
+ it("preserves structured trace alongside visible assistant progress text", () => {
const result = projectRecentChatDisplayMessages([
{
role: "user",
@@ -1968,7 +1976,16 @@ describe("projectRecentChatDisplayMessages", () => {
expect(result[1]).toEqual({
role: "assistant",
- content: [{ type: "text", text: "I will clean that up now." }],
+ content: [
+ { type: "thinking", thinking: "private reasoning" },
+ { type: "text", text: "I will clean that up now." },
+ {
+ type: "toolCall",
+ id: "call-read",
+ name: "read",
+ arguments: { path: "AGENTS.md" },
+ },
+ ],
timestamp: 2,
__openclaw: { seq: 2 },
});
diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts
index bd78f857f3ec..fd4d27b4e606 100644
--- a/src/gateway/server.chat.gateway-server-chat-b.test.ts
+++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts
@@ -5192,7 +5192,7 @@ describe("gateway server chat", () => {
});
});
- test("chat.history keeps visible assistant progress text from mixed tool-use transcript messages", async () => {
+ test("chat.history preserves assistant trace from mixed tool-use transcript messages", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await prepareMainHistoryHarness({ ws, createSessionDir });
await writeMainSessionTranscript([
@@ -5240,7 +5240,14 @@ describe("gateway server chat", () => {
};
expect(assistantMessage.role).toBe("assistant");
expect(assistantMessage.content).toEqual([
+ { type: "thinking", thinking: "private reasoning" },
{ type: "text", text: "I will clean that up now." },
+ {
+ type: "toolCall",
+ id: "call-read",
+ name: "read",
+ arguments: { path: "AGENTS.md" },
+ },
]);
expect(assistantMessage.timestamp).toBe(2);
});
diff --git a/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts b/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts
index a0606dbd8b5f..a1978a76b924 100644
--- a/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts
+++ b/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts
@@ -1,3 +1,5 @@
+import { mkdir } from "node:fs/promises";
+import path from "node:path";
import { expect, it } from "vitest";
import {
chatSessionListResponse,
@@ -17,6 +19,138 @@ import {
const suite = createChatFlowE2eSuite();
suite.define(() => {
+ it("restores reasoning and tool activity after navigating away from a session", async () => {
+ const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
+ if (artifactDir) {
+ await mkdir(artifactDir, { recursive: true });
+ }
+ const context = await suite.newBrowserContext({
+ locale: "en-US",
+ ...(artifactDir
+ ? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } }
+ : {}),
+ serviceWorkers: "block",
+ viewport: { height: 900, width: 1280 },
+ });
+ const page = await context.newPage();
+ const sessionA = "agent:main:session-a";
+ const sessionB = "agent:main:session-b";
+ const visibleAnswer = "Trace preserved after navigation.";
+ const reasoning = "Checked the persisted session trace.";
+ const currentMessages = [
+ {
+ role: "assistant",
+ content: [{ type: "text", text: "Current session placeholder." }],
+ timestamp: 1,
+ },
+ ];
+ const traceMessages = [
+ {
+ role: "assistant",
+ content: [
+ { type: "thinking", thinking: reasoning },
+ { type: "text", text: visibleAnswer },
+ {
+ type: "toolCall",
+ id: "call-read",
+ name: "read",
+ arguments: { path: "AGENTS.md" },
+ },
+ ],
+ timestamp: 2,
+ },
+ {
+ role: "toolResult",
+ toolCallId: "call-read",
+ toolName: "read",
+ content: [{ type: "text", text: "file contents" }],
+ timestamp: 3,
+ },
+ ];
+ const responseCases = {
+ cases: [
+ {
+ match: { sessionKey: sessionB },
+ response: { messages: traceMessages, sessionId: "trace-session", thinkingLevel: "high" },
+ },
+ {
+ match: { sessionKey: sessionA },
+ response: {
+ messages: currentMessages,
+ sessionId: "current-session",
+ thinkingLevel: "high",
+ },
+ },
+ ],
+ };
+ const gateway = await installMockGateway(page, {
+ methodResponses: {
+ "chat.history": responseCases,
+ "chat.startup": responseCases,
+ "sessions.list": chatSessionListResponse([
+ {
+ key: sessionA,
+ kind: "direct",
+ label: "Session A",
+ reasoningLevel: "high",
+ updatedAt: 2,
+ },
+ {
+ key: sessionB,
+ kind: "direct",
+ label: "Session B",
+ reasoningLevel: "high",
+ updatedAt: 1,
+ },
+ ]),
+ },
+ sessionKey: sessionA,
+ });
+
+ try {
+ await page.goto(`${suite.server.baseUrl}chat`);
+ await page.getByText("Current session placeholder.").waitFor({ timeout: 10_000 });
+
+ const sessionLink = (sessionKey: string) =>
+ page.locator(
+ `.sidebar-recent-session[data-session-key="${sessionKey}"] a.sidebar-recent-session__link`,
+ );
+ const expectTrace = async () => {
+ await page.getByText(visibleAnswer, { exact: true }).waitFor({ timeout: 10_000 });
+ await expect.poll(() => page.locator(".chat-thinking").textContent()).toContain(reasoning);
+ await expect
+ .poll(() => page.locator(".chat-tool-msg-summary").textContent())
+ .toContain("Read");
+ };
+
+ await sessionLink(sessionB).click();
+ await expectTrace();
+ if (artifactDir) {
+ await page.screenshot({
+ fullPage: true,
+ path: path.join(artifactDir, "trace-after-first-navigation.png"),
+ });
+ }
+
+ await sessionLink(sessionA).click();
+ await page.getByText("Current session placeholder.").waitFor({ timeout: 10_000 });
+ const historyRequestsBeforeReturn = (await gateway.getRequests("chat.history")).length;
+ await sessionLink(sessionB).click();
+ await expect
+ .poll(async () => (await gateway.getRequests("chat.history")).length)
+ .toBeGreaterThan(historyRequestsBeforeReturn);
+ await expectTrace();
+ if (artifactDir) {
+ await page.screenshot({
+ fullPage: true,
+ path: path.join(artifactDir, "trace-after-return.png"),
+ });
+ }
+ } finally {
+ await suite.closeBrowserContext(context);
+ }
+ });
+
it("keeps valid assistant history visible after a malformed transcript block", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",