();
@@ -776,9 +786,14 @@ export function Chat() {
)}
{messages.map((msg, idx) => {
if (foldedNarrationIndices.has(idx)) return null;
- const suppressToolCards = userRunCards.some((card) =>
- idx > card.triggerIndex && idx <= card.segmentEnd,
- );
+ const suppressToolCards = runSegmentMessageIndices.has(idx);
+ const isToolOnlyAssistant = normalizeMessageRole(msg.role) === 'assistant'
+ && extractToolUse(msg).length > 0
+ && extractText(msg).trim().length === 0
+ && !extractThinking(msg);
+ if (suppressToolCards && isToolOnlyAssistant && !(msg._attachedFiles?.length)) {
+ return null;
+ }
return (
0}
message={(() => {
const base = streamMsg
? {
diff --git a/src/pages/Chat/message-utils.ts b/src/pages/Chat/message-utils.ts
index 0abf98f..fc3e497 100644
--- a/src/pages/Chat/message-utils.ts
+++ b/src/pages/Chat/message-utils.ts
@@ -364,6 +364,11 @@ export function extractImages(
return images;
}
+/** Normalize Gateway/OpenClaw message roles for comparisons. */
+export function normalizeMessageRole(role: unknown): string {
+ return typeof role === 'string' ? role.trim().toLowerCase() : '';
+}
+
/**
* Extract tool use blocks from a message.
* Handles both Anthropic format (tool_use in content array) and
diff --git a/src/pages/Chat/task-visualization.ts b/src/pages/Chat/task-visualization.ts
index 0f1aaae..a621058 100644
--- a/src/pages/Chat/task-visualization.ts
+++ b/src/pages/Chat/task-visualization.ts
@@ -43,6 +43,67 @@ export function findReplyMessageIndex(messages: RawMessage[], hasStreamingReply:
return -1;
}
+/**
+ * Message indices that belong to an agent run segment (strictly after a run
+ * trigger user message up to the next real user message). Used to fold tool
+ * cards and process attachments into ExecutionGraphCard without depending on
+ * whether a graph card was successfully materialized (e.g. after history
+ * reload when the step cache is empty).
+ */
+export function buildRunSegmentMessageIndices(
+ messages: RawMessage[],
+ nextUserMessageIndexes: number[],
+ isRunTrigger: (message: RawMessage, index: number) => boolean,
+): Set {
+ const indices = new Set();
+ messages.forEach((message, triggerIndex) => {
+ if (!isRunTrigger(message, triggerIndex)) return;
+ const nextUserIndex = nextUserMessageIndexes[triggerIndex];
+ const segmentEnd = nextUserIndex === -1 ? messages.length - 1 : nextUserIndex - 1;
+ for (let idx = triggerIndex + 1; idx <= segmentEnd; idx += 1) {
+ indices.add(idx);
+ }
+ });
+
+ // History pagination loads a suffix of the transcript. When the triggering
+ // user turn fell off the window, assistant tool steps remain at the top of
+ // `messages[]` without a preceding user row — fold them into the first run.
+ let firstTriggerIndex = -1;
+ for (let idx = 0; idx < messages.length; idx += 1) {
+ if (isRunTrigger(messages[idx], idx)) {
+ firstTriggerIndex = idx;
+ break;
+ }
+ }
+ if (firstTriggerIndex > 0) {
+ for (let idx = 0; idx < firstTriggerIndex; idx += 1) {
+ if (messages[idx]?.role === 'assistant') {
+ indices.add(idx);
+ }
+ }
+ }
+
+ return indices;
+}
+
+/**
+ * Slice messages for a user-triggered run, including leading assistant orphans
+ * that belong to the same run but were separated by paginated history.
+ */
+export function getRunSegmentMessages(
+ messages: RawMessage[],
+ triggerIndex: number,
+ nextUserIndex: number,
+ isRunTrigger: (message: RawMessage, index: number) => boolean,
+): RawMessage[] {
+ const segmentEnd = nextUserIndex === -1 ? messages.length : nextUserIndex;
+ const core = messages.slice(triggerIndex + 1, segmentEnd);
+ const hasEarlierUser = messages.some((message, index) => index < triggerIndex && isRunTrigger(message, index));
+ if (hasEarlierUser || triggerIndex === 0) return core;
+ const orphans = messages.slice(0, triggerIndex).filter((message) => message.role === 'assistant');
+ return [...orphans, ...core];
+}
+
interface DeriveTaskStepsInput {
messages: RawMessage[];
streamingMessage: unknown | null;
diff --git a/tests/unit/chat-leading-orphan-tools.test.tsx b/tests/unit/chat-leading-orphan-tools.test.tsx
new file mode 100644
index 0000000..83c5fcb
--- /dev/null
+++ b/tests/unit/chat-leading-orphan-tools.test.tsx
@@ -0,0 +1,101 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+
+const { gatewayState, agentsState } = vi.hoisted(() => ({
+ gatewayState: { status: { state: 'running', port: 18789 } },
+ agentsState: {
+ agents: [{ id: 'main', name: 'main' }] as Array>,
+ fetchAgents: vi.fn(),
+ },
+}));
+
+vi.mock('@/stores/gateway', () => ({
+ useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState),
+}));
+
+vi.mock('@/stores/agents', () => ({
+ useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState),
+}));
+
+vi.mock('@/lib/host-api', () => ({
+ hostApiFetch: vi.fn().mockResolvedValue({ success: true, messages: [] }),
+}));
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, params?: Record | string) => {
+ if (typeof params === 'string') return params;
+ if (key === 'executionGraph.collapsedSummary') {
+ return `collapsed ${String(params?.toolCount ?? '')} ${String(params?.processCount ?? '')}`.trim();
+ }
+ if (key === 'executionGraph.agentRun') return 'Main execution';
+ if (key === 'executionGraph.title') return 'Execution Graph';
+ if (key === 'executionGraph.collapseAction') return 'Collapse';
+ if (key === 'executionGraph.thinkingLabel') return 'Thinking';
+ if (key.startsWith('taskPanel.stepStatus.')) return key.split('.').at(-1) ?? key;
+ return key;
+ },
+ }),
+}));
+
+vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
+ useStickToBottomInstant: vi.fn(() => ({
+ contentRef: { current: null },
+ scrollRef: { current: null },
+ scrollToBottom: vi.fn(),
+ isAtBottom: true,
+ })),
+}));
+
+vi.mock('@/hooks/use-min-loading', () => ({
+ useMinLoading: () => false,
+}));
+
+vi.mock('@/pages/Chat/ChatToolbar', () => ({ ChatToolbar: () => null }));
+vi.mock('@/pages/Chat/ChatInput', () => ({ ChatInput: () => null }));
+
+describe('Chat leading orphan tool folding', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ it('hides paginated-prefix tool rows and folds them into the first user execution graph', async () => {
+ const { useChatStore } = await import('@/stores/chat');
+ useChatStore.setState({
+ messages: [
+ { role: 'assistant', id: 'orphan-exec', content: [{ type: 'toolCall', id: 'e1', name: 'exec', input: {} }] },
+ { role: 'assistant', id: 'orphan-image', content: [{ type: 'toolCall', id: 'i1', name: 'image', input: {} }] },
+ { role: 'user', id: 'user-1', content: 'Continue the task' },
+ { role: 'assistant', id: 'reply', content: [{ type: 'text', text: 'Finished.' }] },
+ ],
+ loading: false,
+ error: null,
+ runError: null,
+ sending: false,
+ activeRunId: null,
+ streamingText: '',
+ streamingMessage: null,
+ streamingTools: [],
+ pendingFinal: false,
+ lastUserMessageAt: Date.now(),
+ pendingToolImages: [],
+ sessions: [{ key: 'agent:main:main' }],
+ currentSessionKey: 'agent:main:main',
+ currentAgentId: 'main',
+ sessionLabels: {},
+ sessionLastActivity: {},
+ thinkingLevel: null,
+ });
+
+ const { Chat } = await import('@/pages/Chat/index');
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('chat-execution-graph')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByTestId('chat-message-0')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('chat-message-1')).not.toBeInTheDocument();
+ expect(screen.getByText('Finished.')).toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/chat-message.test.tsx b/tests/unit/chat-message.test.tsx
index 7f13f1a..2cc1db5 100644
--- a/tests/unit/chat-message.test.tsx
+++ b/tests/unit/chat-message.test.tsx
@@ -102,6 +102,60 @@ describe('ChatMessage attachment dedupe', () => {
expect(screen.getByAltText('desktop_screenshot.jpg')).toBeInTheDocument();
});
+ it('keeps html artifacts visible when process attachments are suppressed', async () => {
+ const message: RawMessage = {
+ role: 'assistant',
+ content: '已生成 /workspace/demo.html',
+ };
+
+ render();
+
+ expect(await screen.findByText('demo.html')).toBeInTheDocument();
+ });
+
+ it('hides generic tool-result markdown attachments when process attachments are suppressed', () => {
+ const message: RawMessage = {
+ role: 'assistant',
+ content: 'Coder has finished the analysis, here are the conclusions.',
+ _attachedFiles: [
+ {
+ fileName: 'CHECKLIST.md',
+ mimeType: 'text/markdown',
+ fileSize: 433,
+ preview: null,
+ filePath: '/Users/bytedance/.openclaw/workspace/CHECKLIST.md',
+ source: 'tool-result',
+ },
+ ],
+ };
+
+ render();
+
+ expect(screen.getByText('Coder has finished the analysis, here are the conclusions.')).toBeInTheDocument();
+ expect(screen.queryByText('CHECKLIST.md')).not.toBeInTheDocument();
+ });
+
+ it('keeps attached SKILL.md visible when process attachments are suppressed', () => {
+ const message: RawMessage = {
+ role: 'assistant',
+ content: '这是文件。',
+ _attachedFiles: [
+ {
+ fileName: 'SKILL.md',
+ mimeType: 'text/markdown',
+ fileSize: 128,
+ preview: null,
+ filePath: '/workspace/skills/open-xueqiu/SKILL.md',
+ source: 'tool-result',
+ },
+ ],
+ };
+
+ render();
+
+ expect(screen.getByText('SKILL.md')).toBeInTheDocument();
+ });
+
it('keeps pdf and spreadsheet artifacts visible when process attachments are suppressed', () => {
const message: RawMessage = {
role: 'assistant',
diff --git a/tests/unit/chat-tool-card-suppression.test.tsx b/tests/unit/chat-tool-card-suppression.test.tsx
new file mode 100644
index 0000000..fd4f4fa
--- /dev/null
+++ b/tests/unit/chat-tool-card-suppression.test.tsx
@@ -0,0 +1,125 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+
+const { gatewayState, agentsState } = vi.hoisted(() => ({
+ gatewayState: {
+ status: { state: 'running', port: 18789 },
+ },
+ agentsState: {
+ agents: [{ id: 'main', name: 'main' }] as Array>,
+ fetchAgents: vi.fn(),
+ },
+}));
+
+vi.mock('@/stores/gateway', () => ({
+ useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState),
+}));
+
+vi.mock('@/stores/agents', () => ({
+ useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState),
+}));
+
+vi.mock('@/lib/host-api', () => ({
+ hostApiFetch: vi.fn().mockResolvedValue({ success: true, messages: [] }),
+}));
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, params?: Record | string) => {
+ if (typeof params === 'string') return params;
+ if (key === 'executionGraph.collapsedSummary') {
+ return `collapsed ${String(params?.toolCount ?? '')} ${String(params?.processCount ?? '')}`.trim();
+ }
+ if (key === 'executionGraph.agentRun') return 'Main execution';
+ if (key === 'executionGraph.title') return 'Execution Graph';
+ if (key === 'executionGraph.collapseAction') return 'Collapse';
+ if (key === 'executionGraph.thinkingLabel') return 'Thinking';
+ if (key.startsWith('taskPanel.stepStatus.')) return key.split('.').at(-1) ?? key;
+ return key;
+ },
+ }),
+}));
+
+vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
+ useStickToBottomInstant: vi.fn(() => ({
+ contentRef: { current: null },
+ scrollRef: { current: null },
+ scrollToBottom: vi.fn(),
+ isAtBottom: true,
+ })),
+}));
+
+vi.mock('@/hooks/use-min-loading', () => ({
+ useMinLoading: () => false,
+}));
+
+vi.mock('@/pages/Chat/ChatToolbar', () => ({
+ ChatToolbar: () => null,
+}));
+
+vi.mock('@/pages/Chat/ChatInput', () => ({
+ ChatInput: () => null,
+}));
+
+describe('Chat tool card suppression', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ it('does not render standalone tool cards for messages inside a user run segment', async () => {
+ const { useChatStore } = await import('@/stores/chat');
+ useChatStore.setState({
+ messages: [
+ { role: 'user', content: 'Generate assets' },
+ {
+ role: 'assistant',
+ id: 'tool-exec',
+ content: [{ type: 'tool_use', id: 'exec-1', name: 'exec', input: { command: 'ls' } }],
+ },
+ {
+ role: 'assistant',
+ id: 'tool-image',
+ content: [{ type: 'tool_use', id: 'image-1', name: 'image', input: { path: '/tmp/a.png' } }],
+ },
+ {
+ role: 'assistant',
+ id: 'tool-process',
+ content: [{ type: 'tool_use', id: 'process-1', name: 'process', input: { action: 'list' } }],
+ },
+ {
+ role: 'assistant',
+ id: 'reply',
+ content: [{ type: 'text', text: 'All done.' }],
+ },
+ ],
+ loading: false,
+ error: null,
+ runError: null,
+ sending: false,
+ activeRunId: null,
+ streamingText: '',
+ streamingMessage: null,
+ streamingTools: [],
+ pendingFinal: false,
+ lastUserMessageAt: Date.now(),
+ pendingToolImages: [],
+ sessions: [{ key: 'agent:main:main' }],
+ currentSessionKey: 'agent:main:main',
+ currentAgentId: 'main',
+ sessionLabels: {},
+ sessionLastActivity: {},
+ thinkingLevel: null,
+ });
+
+ const { Chat } = await import('@/pages/Chat/index');
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('chat-execution-graph')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByText('exec')).not.toBeInTheDocument();
+ expect(screen.getByText('All done.')).toBeInTheDocument();
+ expect(screen.getByTestId('chat-execution-graph')).toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/run-segment-indices.test.ts b/tests/unit/run-segment-indices.test.ts
new file mode 100644
index 0000000..7ec6470
--- /dev/null
+++ b/tests/unit/run-segment-indices.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+import { buildRunSegmentMessageIndices } from '@/pages/Chat/task-visualization';
+import type { RawMessage } from '@/stores/chat';
+
+describe('buildRunSegmentMessageIndices', () => {
+ it('marks assistant messages between real user turns', () => {
+ const messages: RawMessage[] = [
+ { role: 'user', content: 'first question' },
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'exec', input: {} }] },
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't2', name: 'image', input: {} }] },
+ { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
+ { role: 'user', content: 'follow up' },
+ { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
+ ];
+ const nextUserMessageIndexes = [4, -1, -1, -1, -1, -1];
+ const indices = buildRunSegmentMessageIndices(
+ messages,
+ nextUserMessageIndexes,
+ (message) => message.role === 'user',
+ );
+
+ expect(indices.has(1)).toBe(true);
+ expect(indices.has(2)).toBe(true);
+ expect(indices.has(3)).toBe(true);
+ expect(indices.has(5)).toBe(true);
+ expect(indices.has(0)).toBe(false);
+ expect(indices.has(4)).toBe(false);
+ });
+
+ it('folds leading assistant orphans before the first user in a paginated suffix', () => {
+ const messages: RawMessage[] = [
+ { role: 'assistant', content: [{ type: 'toolCall', id: 't1', name: 'exec', input: {} }] },
+ { role: 'assistant', content: [{ type: 'toolCall', id: 't2', name: 'image', input: {} }] },
+ { role: 'user', content: 'question fell off the earlier page' },
+ { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
+ ];
+ const nextUserMessageIndexes = [-1, -1, -1, -1];
+ const indices = buildRunSegmentMessageIndices(
+ messages,
+ nextUserMessageIndexes,
+ (message) => message.role === 'user',
+ );
+
+ expect(indices.has(0)).toBe(true);
+ expect(indices.has(1)).toBe(true);
+ expect(indices.has(3)).toBe(true);
+ expect(indices.has(2)).toBe(false);
+ });
+});