From 8d43c4b1db5a40165f511e495570096fafa43256 Mon Sep 17 00:00:00 2001 From: Hiko Qiu Date: Mon, 20 Apr 2026 04:13:25 +0800 Subject: [PATCH 1/8] chore: checkpoint current OpenCow worktree --- .../DetailPanel/SessionPanel/TaskWidgets.tsx | 4 +- .../SessionPanel/ToolBatchCollapsible.tsx | 33 ++++++++------ .../SessionPanel/ToolResultBlockView.tsx | 19 +++----- .../components/ContentBlockRenderer.test.tsx | 5 ++- .../components/SessionMessageList.test.tsx | 2 +- .../components/TaskExecutionView.test.tsx | 11 +++++ .../components/ToolBatchCollapsible.test.tsx | 35 ++++++++++++--- .../components/ToolResultBlockView.test.tsx | 43 +++++++++++-------- 8 files changed, 97 insertions(+), 55 deletions(-) diff --git a/src/renderer/components/DetailPanel/SessionPanel/TaskWidgets.tsx b/src/renderer/components/DetailPanel/SessionPanel/TaskWidgets.tsx index 339ac35..c63a4e9 100644 --- a/src/renderer/components/DetailPanel/SessionPanel/TaskWidgets.tsx +++ b/src/renderer/components/DetailPanel/SessionPanel/TaskWidgets.tsx @@ -412,7 +412,7 @@ export const TaskExecutionView = memo(function TaskExecutionView({ return (
@@ -467,7 +467,7 @@ function TaskHeader({ ) } diff --git a/src/renderer/components/DetailPanel/SessionPanel/ToolBatchCollapsible.tsx b/src/renderer/components/DetailPanel/SessionPanel/ToolBatchCollapsible.tsx index dec901e..b00b70e 100644 --- a/src/renderer/components/DetailPanel/SessionPanel/ToolBatchCollapsible.tsx +++ b/src/renderer/components/DetailPanel/SessionPanel/ToolBatchCollapsible.tsx @@ -137,7 +137,7 @@ export const ToolBatchCollapsible = memo(function ToolBatchCollapsible({
{canCollapse && ( <> diff --git a/src/renderer/components/DetailPanel/SessionPanel/ToolResultBlockView.tsx b/src/renderer/components/DetailPanel/SessionPanel/ToolResultBlockView.tsx index fc1925c..fb37329 100644 --- a/src/renderer/components/DetailPanel/SessionPanel/ToolResultBlockView.tsx +++ b/src/renderer/components/DetailPanel/SessionPanel/ToolResultBlockView.tsx @@ -57,6 +57,26 @@ interface ToolResultBlockViewProps { sessionId?: string } +/** + * Whether a tool_result block produces any visible UI at all. + * + * Most successful tool results are intentionally suppressed to avoid repeating + * raw payloads in the session stream. Only rich result cards, provenance-backed + * media, and error outputs should reserve vertical space. + */ +export function shouldRenderToolResultBlock( + block: ToolResultBlock, + toolName?: string, +): boolean { + if (!block.content) return false + if (toolName && shouldSuppressResult(toolName)) return false + if (block.isError) return true + if (!toolName) return false + const renderer = RESULT_CARD_REGISTRY.get(toolName) + if (!renderer) return false + return renderer(block.content) !== null +} + // ─── Result Card Registry ─────────────────────────────────────────────────── /** @@ -143,8 +163,7 @@ export const ToolResultBlockView = memo(function ToolResultBlockView({ block, se // Resolve originating tool lifecycle via Context (O(1) Map lookup) const toolInfo = useToolLifecycle(block.toolUseId) - // Path 1: Widget Tools with suppressResult=true — their result is rendered by the Widget. - if (toolInfo && shouldSuppressResult(toolInfo.name)) return <> + if (!shouldRenderToolResultBlock(block, toolInfo?.name)) return <> // Path 2: Result Card — rich card for tools whose data lives in tool_result. if (toolInfo && block.content && !block.isError) { diff --git a/tests/unit/components/AssistantMessage.test.tsx b/tests/unit/components/AssistantMessage.test.tsx index b221af9..2b041fc 100644 --- a/tests/unit/components/AssistantMessage.test.tsx +++ b/tests/unit/components/AssistantMessage.test.tsx @@ -5,7 +5,10 @@ import React from 'react' import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' import '@testing-library/jest-dom/vitest' -import { AssistantMessage } from '../../../src/renderer/components/DetailPanel/SessionPanel/AssistantMessage' +import { + AssistantMessage, + isCompactAssistantContent, +} from '../../../src/renderer/components/DetailPanel/SessionPanel/AssistantMessage' import { NativeCapabilityTools } from '../../../src/shared/nativeCapabilityToolNames' import type { ManagedSessionMessage } from '../../../src/shared/types' @@ -37,6 +40,19 @@ function makeAssistantMessage(blocks: ManagedSessionMessage['content']): Managed } describe('AssistantMessage', () => { + it('detects compact assistant content only for tool-only blocks', () => { + expect(isCompactAssistantContent([ + { type: 'tool_use', id: 'tu-1', name: 'Read', input: { file_path: 'README.md' } }, + { type: 'tool_result', toolUseId: 'tu-1', content: 'ok' }, + { type: 'thinking', thinking: 'next step' }, + ])).toBe(true) + + expect(isCompactAssistantContent([ + { type: 'text', text: 'hello' }, + { type: 'tool_use', id: 'tu-1', name: 'Read', input: { file_path: 'README.md' } }, + ])).toBe(false) + }) + it('keeps schedule propose tool segment expanded (not collapsed)', () => { const message = makeAssistantMessage([ { @@ -69,5 +85,38 @@ describe('AssistantMessage', () => { expect(screen.getAllByTestId('block-tool_use').length).toBe(2) expect(screen.getAllByTestId('block-tool_result').length).toBe(2) }) -}) + it('uses compact outer spacing for tool-only assistant messages', () => { + const message = makeAssistantMessage([ + { type: 'tool_use', id: 'tu-1', name: 'Read', input: { file_path: 'README.md' } }, + ]) + + render() + + expect(screen.getByTestId('block-tool_use').parentElement?.className).toContain('py-px') + }) + + it('uses compact outer spacing for mixed tool and thinking messages', () => { + const message = makeAssistantMessage([ + { type: 'tool_use', id: 'tu-1', name: 'Read', input: { file_path: 'README.md' } }, + { type: 'thinking', thinking: 'next step' }, + { type: 'tool_result', toolUseId: 'tu-1', content: 'ok' }, + ]) + + const { container } = render() + + const root = container.querySelector('[data-msg-id="msg-1"][data-msg-role="assistant"]') as HTMLElement | null + expect(root?.className).toContain('py-px') + expect(root?.firstElementChild?.className ?? '').toContain('space-y-1') + }) + + it('keeps prose assistant messages on the regular vertical rhythm', () => { + const message = makeAssistantMessage([ + { type: 'text', text: 'hello world' }, + ]) + + render() + + expect(screen.getByTestId('block-text').parentElement?.className).toContain('py-0.5') + }) +}) diff --git a/tests/unit/components/SessionMessageList.test.tsx b/tests/unit/components/SessionMessageList.test.tsx index e30c9e0..6b66279 100644 --- a/tests/unit/components/SessionMessageList.test.tsx +++ b/tests/unit/components/SessionMessageList.test.tsx @@ -6,7 +6,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen } from '@testing-library/react' import '@testing-library/jest-dom/vitest' import { SessionMessageList } from '../../../src/renderer/components/DetailPanel/SessionPanel/SessionMessageList' -import type { ManagedSessionMessage, ManagedSessionState, ContentBlock, SystemEvent } from '../../../src/shared/types' +import type { + ManagedSessionMessage, + ManagedSessionState, + ContentBlock, + SystemEvent, + ToolResultBlock, +} from '../../../src/shared/types' import { resolveLatestSessionDraft } from '../../../src/shared/sessionDraftOutputParser' const lifecycleHookMock = vi.hoisted(() => { @@ -266,6 +272,38 @@ describe('SessionMessageList', () => { expect(screen.getAllByLabelText(/Assistant message/)).toHaveLength(2) }) + it('filters invisible tool-result user messages so they do not leave empty rows between tool batches', () => { + const { container } = render( + + ) + + expect(screen.getByRole('button', { name: /Expand 4 tool calls/i })).toBeInTheDocument() + expect(container.querySelectorAll('[data-msg-role="user-tool-result"]')).toHaveLength(0) + expect(screen.getByRole('list').children).toHaveLength(1) + }) + it('renders empty state when no messages', () => { render() expect(screen.getByRole('list').children).toHaveLength(0) diff --git a/tests/unit/components/TaskExecutionView.test.tsx b/tests/unit/components/TaskExecutionView.test.tsx index 90e7753..c385d12 100644 --- a/tests/unit/components/TaskExecutionView.test.tsx +++ b/tests/unit/components/TaskExecutionView.test.tsx @@ -97,6 +97,14 @@ describe('TaskExecutionView', () => { expect(screen.getByText('3.2s')).toBeInTheDocument() }) + it('hides the redundant completed status label when no duration is available', () => { + renderWithTaskEvents( + , + { state: 'completed' }, + ) + expect(screen.queryByText('Completed')).toBeNull() + }) + it('shows "Failed" when lifecycle reports failed', () => { renderWithTaskEvents( , @@ -198,9 +206,36 @@ describe('TaskExecutionView', () => { const region = screen.getByRole('region', { name: /Search codebase/ }) const toggle = screen.getByRole('button', { expanded: false }) const toggleClasses = toggle.className.split(/\s+/) + const regionClasses = region.className.split(/\s+/) expect(region.className).toContain('inline-flex') expect(toggle.className).toContain('inline-flex') expect(toggleClasses).not.toContain('w-full') + expect(regionClasses).toContain('rounded-full') + expect(toggleClasses).toContain('py-0.5') + expect(toggleClasses).not.toContain('py-1.5') + }) + + it('keeps the compact pill height when expanded', async () => { + const user = userEvent.setup() + renderWithTaskEvents( + , + { state: 'completed', summary: 'Done' }, + ) + + const toggle = screen.getByRole('button', { expanded: false }) + await user.click(toggle) + + const expandedClasses = screen.getByRole('button', { expanded: true }).className.split(/\s+/) + expect(expandedClasses).toContain('py-0.5') + expect(expandedClasses).not.toContain('py-1.5') + }) + + it('does not add extra outer top margin to the task pill container', () => { + renderWithTaskEvents() + const region = screen.getByRole('region', { name: /Search codebase/ }) + const regionClasses = region.className.split(/\s+/) + + expect(regionClasses).not.toContain('mt-1') }) }) diff --git a/tests/unit/components/ToolBatchCollapsible.test.tsx b/tests/unit/components/ToolBatchCollapsible.test.tsx index 712418b..7ee4480 100644 --- a/tests/unit/components/ToolBatchCollapsible.test.tsx +++ b/tests/unit/components/ToolBatchCollapsible.test.tsx @@ -503,7 +503,7 @@ describe('ToolBatchCollapsible', () => { } it('renders collapsed state with tool count', () => { - renderBatch([ + const { container } = renderBatch([ makeToolOnlyMsg('Read', 'a1'), makeToolOnlyMsg('Grep', 'a2'), makeToolOnlyMsg('Edit', 'a3') @@ -512,6 +512,8 @@ describe('ToolBatchCollapsible', () => { // Should show "3 tool calls" summary expect(screen.getByText(/3 tool calls/)).toBeInTheDocument() expect(screen.getByText(/Read, Grep, Edit/)).toBeInTheDocument() + const batchRoot = container.querySelector('[data-msg-id="a1"][data-msg-role="assistant"]') as HTMLElement | null + expect(batchRoot?.className).toContain('py-px') }) it('renders collapsed state with tool count (×N for duplicates)', () => { diff --git a/tests/unit/components/ToolResultBlockView.test.tsx b/tests/unit/components/ToolResultBlockView.test.tsx index 445a695..cfb4f19 100644 --- a/tests/unit/components/ToolResultBlockView.test.tsx +++ b/tests/unit/components/ToolResultBlockView.test.tsx @@ -6,7 +6,10 @@ import { describe, it, expect } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import '@testing-library/jest-dom/vitest' -import { ToolResultBlockView } from '../../../src/renderer/components/DetailPanel/SessionPanel/ToolResultBlockView' +import { + ToolResultBlockView, + shouldRenderToolResultBlock, +} from '../../../src/renderer/components/DetailPanel/SessionPanel/ToolResultBlockView' import type { ToolResultBlock } from '../../../src/shared/types' function makeBlock(overrides: Partial = {}): ToolResultBlock { @@ -19,6 +22,14 @@ function makeBlock(overrides: Partial = {}): ToolResultBlock { } describe('ToolResultBlockView', () => { + it('visibility helper treats successful raw tool output as hidden', () => { + expect(shouldRenderToolResultBlock(makeBlock())).toBe(false) + }) + + it('visibility helper keeps error output visible', () => { + expect(shouldRenderToolResultBlock(makeBlock({ content: 'Error: not found', isError: true }))).toBe(true) + }) + it('suppresses non-error short content entirely', () => { const { container } = render() expect(container.innerHTML).toBe('') From 44e64686dd0072171f38ecf15aa26d78c4dc120a Mon Sep 17 00:00:00 2001 From: Hiko Qiu Date: Mon, 20 Apr 2026 06:13:13 +0800 Subject: [PATCH 7/8] fix(session-panel): avoid conditional hook in tool result fallback Move the expandable raw error renderer into a dedicated child component so useState is always called unconditionally. This fixes the lint-blocking react-hooks/rules-of-hooks violation in CI. Co-Authored-By: OpenCowOfficial Co-Authored-By: Claude Sonnet 4.6 --- .../DetailPanel/SessionPanel/ToolResultBlockView.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/DetailPanel/SessionPanel/ToolResultBlockView.tsx b/src/renderer/components/DetailPanel/SessionPanel/ToolResultBlockView.tsx index fb37329..aef923c 100644 --- a/src/renderer/components/DetailPanel/SessionPanel/ToolResultBlockView.tsx +++ b/src/renderer/components/DetailPanel/SessionPanel/ToolResultBlockView.tsx @@ -183,11 +183,14 @@ export const ToolResultBlockView = memo(function ToolResultBlockView({ block, se function RawToolResult({ block }: { block: ToolResultBlock }): React.JSX.Element { if (!block.content || !block.isError) return <> + return +} - const lines = block.content.split('\n') +function RawErrorToolResult({ content }: { content: string }): React.JSX.Element { + const lines = content.split('\n') const isLong = lines.length > COLLAPSE_THRESHOLD const [expanded, setExpanded] = useState(!isLong) - const displayContent = expanded ? block.content : lines.slice(0, COLLAPSE_THRESHOLD).join('\n') + const displayContent = expanded ? content : lines.slice(0, COLLAPSE_THRESHOLD).join('\n') return (
From 65275ea6f4d0baf4dfcdbf0e1ec18eda8b521fcf Mon Sep 17 00:00:00 2001 From: Hiko Qiu Date: Mon, 20 Apr 2026 06:38:45 +0800 Subject: [PATCH 8/8] fix(session-panel): localize todo widgets and preserve current-turn todos Co-Authored-By: Claude Sonnet 4.6 --- .../SessionPanel/SessionStatusBar.tsx | 8 +- .../DetailPanel/SessionPanel/TodoWidgets.tsx | 20 ++--- src/renderer/lib/todoSnapshot.ts | 10 ++- src/renderer/locales/en-US/sessions.json | 4 + src/renderer/locales/zh-CN/sessions.json | 4 + .../unit/components/SessionStatusBar.test.tsx | 75 +++++++++++++++++++ tests/unit/lib/todoSnapshot.test.ts | 28 +++++++ tests/unit/stores/commandStore.test.ts | 75 +++++++++++++++++++ 8 files changed, 211 insertions(+), 13 deletions(-) diff --git a/src/renderer/components/DetailPanel/SessionPanel/SessionStatusBar.tsx b/src/renderer/components/DetailPanel/SessionPanel/SessionStatusBar.tsx index 5411a88..82496d6 100644 --- a/src/renderer/components/DetailPanel/SessionPanel/SessionStatusBar.tsx +++ b/src/renderer/components/DetailPanel/SessionPanel/SessionStatusBar.tsx @@ -9,9 +9,10 @@ import type { SessionHistoryContext } from './sessionHistoryTypes' import { ContextWindowRing } from '../../ui/ContextWindowRing' import { Tooltip } from '../../ui/Tooltip' import { PillDropdown } from '../../ui/PillDropdown' +import { TodoStatusPill } from './TodoWidgets' import { formatDuration, computeActiveDuration } from '@/lib/sessionHelpers' import { isProcessCorruptedError } from '../../../lib/sessionErrors' -import { useStreamingSessionMetrics, useCommandStore } from '@/stores/commandStore' +import { selectLatestOpenTodos, useStreamingSessionMetrics, useCommandStore } from '@/stores/commandStore' import { useStoreWithEqualityFn } from 'zustand/traditional' import { shallow } from 'zustand/shallow' import { resolveContextDisplayState } from '@shared/contextDisplay' @@ -137,6 +138,7 @@ export const SessionStatusBar = React.memo(function SessionStatusBar({ const metrics = useStreamingSessionMetrics(sessionId) const activeDurationMs = metrics?.activeDurationMs ?? 0 const activeStartedAt = metrics?.activeStartedAt ?? null + const latestTodos = useCommandStore((s) => selectLatestOpenTodos(s, sessionId)) // Context display — resolves from the full session snapshot. // Uses useStoreWithEqualityFn + shallow to avoid re-renders when @@ -175,6 +177,7 @@ export const SessionStatusBar = React.memo(function SessionStatusBar({ state === 'stopped' || state === 'error' ) && !!onNewSession && !!onNewBlankSession + const isTodoPaused = state === 'idle' || state === 'stopped' || state === 'error' return (
+ {latestTodos && ( + + )} {showStop && ( {showCompleted && ( @@ -211,6 +212,7 @@ export const TodoStatusPill = memo(function TodoStatusPill({ todos: TodoItem[] isPaused?: boolean }): React.JSX.Element { + const { t } = useTranslation('sessions') const [popoverOpen, setPopoverOpen] = useState(false) return ( @@ -223,7 +225,7 @@ export const TodoStatusPill = memo(function TodoStatusPill({ trigger={