From 35450847580c6c1a34aa416e97309d4520e74401 Mon Sep 17 00:00:00 2001 From: Ananovo <78636812+techotaku39@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:06:21 +0800 Subject: [PATCH 1/4] feat(web): add configurable tool grouping mode --- web/src/chat/toolGroups.test.ts | 25 +++++++++ web/src/chat/toolGroups.ts | 17 +++--- web/src/components/SessionChat.tsx | 5 +- web/src/hooks/useToolGroupingMode.test.ts | 30 +++++++++++ web/src/hooks/useToolGroupingMode.ts | 65 +++++++++++++++++++++++ web/src/lib/locales/en.ts | 3 ++ web/src/lib/locales/zh-CN.ts | 3 ++ web/src/routes/settings/chat.tsx | 8 +++ web/src/routes/settings/index.test.tsx | 10 ++++ 9 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 web/src/hooks/useToolGroupingMode.test.ts create mode 100644 web/src/hooks/useToolGroupingMode.ts diff --git a/web/src/chat/toolGroups.test.ts b/web/src/chat/toolGroups.test.ts index 732eae9123..2945019266 100644 --- a/web/src/chat/toolGroups.test.ts +++ b/web/src/chat/toolGroups.test.ts @@ -249,6 +249,31 @@ describe('buildVisibleChatBlocks', () => { expect(isToolGroupBlock(visible[2]) && visible[2].presentationMode).toBe('codex-exploration') }) + it('groups structured general Codex commands when grouped mode is selected', () => { + const first = makeToolBlock('codex-test-1', 'CodexBash', { + command: 'bun test', + command_actions: [{ type: 'unknown', command: 'bun test' }] + }) + const second = makeToolBlock('codex-test-2', 'CodexBash', { + command: 'bun run typecheck', + command_actions: [{ type: 'unknown', command: 'bun run typecheck' }] + }) + + const classified = buildVisibleChatBlocks([first, second], { + hasMoreMessages: false, + groupingMode: 'classified' + }) + const grouped = buildVisibleChatBlocks([first, second], { + hasMoreMessages: false, + groupingMode: 'grouped' + }) + + expect(classified).toEqual([first, second]) + expect(grouped).toHaveLength(1) + expect(isToolGroupBlock(grouped[0]) && grouped[0].presentationMode).toBe('default') + expect(isToolGroupBlock(grouped[0]) && grouped[0].defaultOpen).toBe(false) + }) + it('groups contiguous eligible root tool cards', () => { const visible = buildVisibleChatBlocks([ makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), diff --git a/web/src/chat/toolGroups.ts b/web/src/chat/toolGroups.ts index d4f958e79e..b1b4857c82 100644 --- a/web/src/chat/toolGroups.ts +++ b/web/src/chat/toolGroups.ts @@ -6,6 +6,7 @@ import { isRequestUserInputToolName } from '@/components/ToolCard/requestUserInp import { getInputStringAny } from '@/lib/toolInputUtils' export type ToolGroupActionKind = 'read' | 'search' | 'command' | 'mutation' | 'web' | 'other' +export type ToolGroupingMode = 'grouped' | 'classified' export type ToolGroupSummary = { totalTools: number @@ -55,6 +56,7 @@ export function visibleBlockRole(block: VisibleChatBlock): VisibleChatBlockRole type ToolGroupingOptions = { hasMoreMessages: boolean previousGroups?: ToolGroupBlock[] + groupingMode?: ToolGroupingMode codexExplorationCollapsed?: boolean } @@ -221,20 +223,20 @@ function isInteractiveToolBlock(block: ToolCallBlock): boolean { || isRequestUserInputToolName(block.tool.name) } -export function isEligibleForToolGrouping(block: ToolCallBlock): boolean { +export function isEligibleForToolGrouping(block: ToolCallBlock, groupingMode: ToolGroupingMode = 'classified'): boolean { if (isSubagentToolName(block.tool.name)) return false if (PLAN_TOOL_NAMES.has(block.tool.name)) return false if (MILESTONE_TOOL_NAMES.has(block.tool.name)) return false if (isInteractiveToolBlock(block)) return false - if (block.tool.name === 'CodexBash' && getCodexCommandActions(block).length > 0) { + if (groupingMode === 'classified' && block.tool.name === 'CodexBash' && getCodexCommandActions(block).length > 0) { return isCodexExplorationTool(block) } return true } -function getGroupingFamily(block: ToolCallBlock): 'default' | 'codex-exploration' | null { - if (!isEligibleForToolGrouping(block)) return null - return isCodexExplorationTool(block) ? 'codex-exploration' : 'default' +function getGroupingFamily(block: ToolCallBlock, groupingMode: ToolGroupingMode): 'default' | 'codex-exploration' | null { + if (!isEligibleForToolGrouping(block, groupingMode)) return null + return groupingMode === 'classified' && isCodexExplorationTool(block) ? 'codex-exploration' : 'default' } function createToolGroupId( @@ -265,6 +267,7 @@ export function buildVisibleChatBlocks( ): VisibleChatBlock[] { const visibleBlocks: VisibleChatBlock[] = [] const previousGroups = options.previousGroups ?? [] + const groupingMode = options.groupingMode ?? 'classified' for (let index = 0; index < blocks.length; index += 1) { const block = blocks[index] @@ -272,7 +275,7 @@ export function buildVisibleChatBlocks( visibleBlocks.push(block) continue } - const groupingFamily = getGroupingFamily(block) + const groupingFamily = getGroupingFamily(block, groupingMode) if (!groupingFamily) { visibleBlocks.push(block) continue @@ -282,7 +285,7 @@ export function buildVisibleChatBlocks( let cursor = index + 1 while (cursor < blocks.length) { const candidate = blocks[cursor] - if (candidate.kind !== 'tool-call' || getGroupingFamily(candidate) !== groupingFamily) { + if (candidate.kind !== 'tool-call' || getGroupingFamily(candidate, groupingMode) !== groupingFamily) { break } tools.push(candidate) diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 7825ac047c..e4c346f228 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -80,6 +80,7 @@ import { TeamPanel } from '@/components/TeamPanel' import { SessionStatusPanel } from '@/components/SessionStatusPanel' import { buildSessionStatusData } from '@/chat/sessionStatus' import { usePlatform } from '@/hooks/usePlatform' +import { useToolGroupingMode } from '@/hooks/useToolGroupingMode' import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { useCodexModels } from '@/hooks/queries/useCodexModels' import { useCursorModels } from '@/hooks/queries/useCursorModels' @@ -555,6 +556,7 @@ function SessionChatInner(props: SessionChatProps) { const { t } = useTranslation() const { codexExplorationCollapsed } = useCodexExplorationCollapse() const navigate = useNavigate() + const { toolGroupingMode } = useToolGroupingMode() const [historyActionPending, setHistoryActionPending] = useState(false) const onForkConversation = useCallback(async (messageLocalId?: string) => { @@ -1284,9 +1286,10 @@ function SessionChatInner(props: SessionChatProps) { () => buildVisibleChatBlocks(reconciled.blocks, { hasMoreMessages: props.hasMoreMessages, previousGroups: visibleGroupsRef.current, + groupingMode: toolGroupingMode, codexExplorationCollapsed }), - [reconciled.blocks, props.hasMoreMessages, codexExplorationCollapsed] + [reconciled.blocks, props.hasMoreMessages, toolGroupingMode, codexExplorationCollapsed] ) // Fork-current must compare against assistant-ui message ids (`kind:id`), diff --git a/web/src/hooks/useToolGroupingMode.test.ts b/web/src/hooks/useToolGroupingMode.test.ts new file mode 100644 index 0000000000..42e18a17b8 --- /dev/null +++ b/web/src/hooks/useToolGroupingMode.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + DEFAULT_TOOL_GROUPING_MODE, + getInitialToolGroupingMode, + getToolGroupingModeOptions, +} from './useToolGroupingMode' + +describe('useToolGroupingMode helpers', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('returns grouped and classified options', () => { + expect(getToolGroupingModeOptions()).toEqual([ + { value: 'classified', labelKey: 'settings.chat.toolGrouping.classified' }, + { value: 'grouped', labelKey: 'settings.chat.toolGrouping.grouped' }, + ]) + }) + + it('defaults to classified for missing or invalid values', () => { + expect(getInitialToolGroupingMode()).toBe(DEFAULT_TOOL_GROUPING_MODE) + window.localStorage.setItem('hapi-tool-grouping-mode', 'invalid') + expect(getInitialToolGroupingMode()).toBe(DEFAULT_TOOL_GROUPING_MODE) + }) + + it('reads the grouped preference', () => { + window.localStorage.setItem('hapi-tool-grouping-mode', 'grouped') + expect(getInitialToolGroupingMode()).toBe('grouped') + }) +}) diff --git a/web/src/hooks/useToolGroupingMode.ts b/web/src/hooks/useToolGroupingMode.ts new file mode 100644 index 0000000000..d4ab251f29 --- /dev/null +++ b/web/src/hooks/useToolGroupingMode.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useState } from 'react' +import type { ToolGroupingMode } from '@/chat/toolGroups' + +export const DEFAULT_TOOL_GROUPING_MODE: ToolGroupingMode = 'classified' + +export function getToolGroupingModeOptions(): ReadonlyArray<{ value: ToolGroupingMode; labelKey: string }> { + return [ + { value: 'classified', labelKey: 'settings.chat.toolGrouping.classified' }, + { value: 'grouped', labelKey: 'settings.chat.toolGrouping.grouped' }, + ] +} + +const STORAGE_KEY = 'hapi-tool-grouping-mode' + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +function parseToolGroupingMode(raw: string | null): ToolGroupingMode { + return raw === 'grouped' || raw === 'classified' + ? raw + : DEFAULT_TOOL_GROUPING_MODE +} + +export function getInitialToolGroupingMode(): ToolGroupingMode { + if (!isBrowser()) return DEFAULT_TOOL_GROUPING_MODE + try { + return parseToolGroupingMode(localStorage.getItem(STORAGE_KEY)) + } catch { + return DEFAULT_TOOL_GROUPING_MODE + } +} + +export function useToolGroupingMode(): { + toolGroupingMode: ToolGroupingMode + setToolGroupingMode: (mode: ToolGroupingMode) => void +} { + const [toolGroupingMode, setToolGroupingModeState] = useState(getInitialToolGroupingMode) + + useEffect(() => { + if (!isBrowser()) return + const onStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEY) { + setToolGroupingModeState(parseToolGroupingMode(event.newValue)) + } + } + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + + const setToolGroupingMode = useCallback((mode: ToolGroupingMode) => { + setToolGroupingModeState(mode) + try { + if (mode === DEFAULT_TOOL_GROUPING_MODE) { + localStorage.removeItem(STORAGE_KEY) + } else { + localStorage.setItem(STORAGE_KEY, mode) + } + } catch { + // Ignore storage errors + } + }, []) + + return { toolGroupingMode, setToolGroupingMode } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 5a51fc4a19..058c949677 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -799,6 +799,9 @@ export default { 'settings.chat.terminalToolDisplay': 'Terminal Tool Cards', 'settings.chat.terminalToolDisplay.compact': 'Compact (command only)', 'settings.chat.terminalToolDisplay.detailed': 'Detailed (show output preview)', + 'settings.chat.toolGrouping': 'Group Tools', + 'settings.chat.toolGrouping.grouped': 'Grouped', + 'settings.chat.toolGrouping.classified': 'Classified', 'settings.chat.codexExplorationCollapsed': 'Collapse explored tool groups by default', 'settings.chat.codexExplorationCollapsed.desc': 'Keep Codex read and search activity collapsed until you open it.', 'settings.chat.reasoningCollapsed': 'Collapse reasoning by default', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 0fa776246b..59db6c837b 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -798,6 +798,9 @@ export default { 'settings.chat.terminalToolDisplay': '终端工具卡片', 'settings.chat.terminalToolDisplay.compact': '简洁(仅命令)', 'settings.chat.terminalToolDisplay.detailed': '详细(显示输出预览)', + 'settings.chat.toolGrouping': '是否聚合', + 'settings.chat.toolGrouping.grouped': '聚合', + 'settings.chat.toolGrouping.classified': '细分', 'settings.chat.codexExplorationCollapsed': '已探索工具组默认收起', 'settings.chat.codexExplorationCollapsed.desc': 'Codex 的读取和搜索活动默认收起,点击后查看详情。', 'settings.chat.reasoningCollapsed': '思考默认折叠', diff --git a/web/src/routes/settings/chat.tsx b/web/src/routes/settings/chat.tsx index ec61765510..cdcd439749 100644 --- a/web/src/routes/settings/chat.tsx +++ b/web/src/routes/settings/chat.tsx @@ -1,6 +1,7 @@ import { useTranslation } from '@/lib/use-translation' import { getComposerEnterBehaviorOptions, useComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior' import { getTerminalToolDisplayModeOptions, useTerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' +import { getToolGroupingModeOptions, useToolGroupingMode } from '@/hooks/useToolGroupingMode' import { useCodexExplorationCollapse } from '@/hooks/useCodexExplorationCollapse' import { useReasoningCollapse } from '@/hooks/useReasoningCollapse' import { @@ -50,6 +51,7 @@ export default function SettingsChatPage() { const { t } = useTranslation() const { composerEnterBehavior, setComposerEnterBehavior } = useComposerEnterBehavior() const { terminalToolDisplayMode, setTerminalToolDisplayMode } = useTerminalToolDisplayMode() + const { toolGroupingMode, setToolGroupingMode } = useToolGroupingMode() const { codexExplorationCollapsed, setCodexExplorationCollapsed } = useCodexExplorationCollapse() const { reasoningCollapsed, setReasoningCollapsed } = useReasoningCollapse() const { toolGroupBackground, userMessageBackground, setToolGroupBackground, setUserMessageBackground } = useChatSurfaceColors() @@ -71,6 +73,12 @@ export default function SettingsChatPage() { options={getTerminalToolDisplayModeOptions().map((option) => ({ value: option.value, label: t(option.labelKey) }))} onChange={setTerminalToolDisplayMode} /> + ({ value: option.value, label: t(option.labelKey) }))} + onChange={setToolGroupingMode} + /> ({ useCodexExplorationCollapse: () => ({ codexExplorationCollapsed: true, setCodexExplorationCollapsed }), })) +vi.mock('@/hooks/useToolGroupingMode', () => ({ + useToolGroupingMode: () => ({ toolGroupingMode: 'classified', setToolGroupingMode: vi.fn() }), + getToolGroupingModeOptions: () => [ + { value: 'classified', labelKey: 'settings.chat.toolGrouping.classified' }, + { value: 'grouped', labelKey: 'settings.chat.toolGrouping.grouped' }, + ], +})) + vi.mock('@/hooks/useChatSurfaceColors', () => ({ useChatSurfaceColors: () => ({ toolGroupBackground: 'default', @@ -279,6 +287,8 @@ describe('responsive settings pages', () => { renderPage() fireEvent.click(screen.getByRole('radio', { name: 'Insert newline' })) expect(setComposerEnterBehavior).toHaveBeenCalledWith('newline') + expect(screen.getByRole('radio', { name: 'Grouped' })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: 'Classified' })).toBeChecked() expect(screen.getByText('Grouped Tool Use Background')).toBeInTheDocument() }) From a0b133558069430bdd5b73d955f33b35a1f4a49e Mon Sep 17 00:00:00 2001 From: Ananovo <78636812+techotaku39@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:17:17 +0800 Subject: [PATCH 2/4] fix(web): default tool grouping to grouped --- web/src/hooks/useToolGroupingMode.test.ts | 10 +++++----- web/src/hooks/useToolGroupingMode.ts | 4 ++-- web/src/routes/settings/index.test.tsx | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/web/src/hooks/useToolGroupingMode.test.ts b/web/src/hooks/useToolGroupingMode.test.ts index 42e18a17b8..228556b36c 100644 --- a/web/src/hooks/useToolGroupingMode.test.ts +++ b/web/src/hooks/useToolGroupingMode.test.ts @@ -12,19 +12,19 @@ describe('useToolGroupingMode helpers', () => { it('returns grouped and classified options', () => { expect(getToolGroupingModeOptions()).toEqual([ - { value: 'classified', labelKey: 'settings.chat.toolGrouping.classified' }, { value: 'grouped', labelKey: 'settings.chat.toolGrouping.grouped' }, + { value: 'classified', labelKey: 'settings.chat.toolGrouping.classified' }, ]) }) - it('defaults to classified for missing or invalid values', () => { + it('defaults to grouped for missing or invalid values', () => { expect(getInitialToolGroupingMode()).toBe(DEFAULT_TOOL_GROUPING_MODE) window.localStorage.setItem('hapi-tool-grouping-mode', 'invalid') expect(getInitialToolGroupingMode()).toBe(DEFAULT_TOOL_GROUPING_MODE) }) - it('reads the grouped preference', () => { - window.localStorage.setItem('hapi-tool-grouping-mode', 'grouped') - expect(getInitialToolGroupingMode()).toBe('grouped') + it('reads the classified preference', () => { + window.localStorage.setItem('hapi-tool-grouping-mode', 'classified') + expect(getInitialToolGroupingMode()).toBe('classified') }) }) diff --git a/web/src/hooks/useToolGroupingMode.ts b/web/src/hooks/useToolGroupingMode.ts index d4ab251f29..d142a7dc4d 100644 --- a/web/src/hooks/useToolGroupingMode.ts +++ b/web/src/hooks/useToolGroupingMode.ts @@ -1,12 +1,12 @@ import { useCallback, useEffect, useState } from 'react' import type { ToolGroupingMode } from '@/chat/toolGroups' -export const DEFAULT_TOOL_GROUPING_MODE: ToolGroupingMode = 'classified' +export const DEFAULT_TOOL_GROUPING_MODE: ToolGroupingMode = 'grouped' export function getToolGroupingModeOptions(): ReadonlyArray<{ value: ToolGroupingMode; labelKey: string }> { return [ - { value: 'classified', labelKey: 'settings.chat.toolGrouping.classified' }, { value: 'grouped', labelKey: 'settings.chat.toolGrouping.grouped' }, + { value: 'classified', labelKey: 'settings.chat.toolGrouping.classified' }, ] } diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index 6d56106e11..caf57eedbc 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -143,10 +143,10 @@ vi.mock('@/hooks/useCodexExplorationCollapse', () => ({ })) vi.mock('@/hooks/useToolGroupingMode', () => ({ - useToolGroupingMode: () => ({ toolGroupingMode: 'classified', setToolGroupingMode: vi.fn() }), + useToolGroupingMode: () => ({ toolGroupingMode: 'grouped', setToolGroupingMode: vi.fn() }), getToolGroupingModeOptions: () => [ - { value: 'classified', labelKey: 'settings.chat.toolGrouping.classified' }, { value: 'grouped', labelKey: 'settings.chat.toolGrouping.grouped' }, + { value: 'classified', labelKey: 'settings.chat.toolGrouping.classified' }, ], })) @@ -288,7 +288,7 @@ describe('responsive settings pages', () => { fireEvent.click(screen.getByRole('radio', { name: 'Insert newline' })) expect(setComposerEnterBehavior).toHaveBeenCalledWith('newline') expect(screen.getByRole('radio', { name: 'Grouped' })).toBeInTheDocument() - expect(screen.getByRole('radio', { name: 'Classified' })).toBeChecked() + expect(screen.getByRole('radio', { name: 'Grouped' })).toBeChecked() expect(screen.getByText('Grouped Tool Use Background')).toBeInTheDocument() }) From d6c84839cae86403d491715196e1bb61b8ca1307 Mon Sep 17 00:00:00 2001 From: Ananovo <78636812+techotaku39@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:50:43 +0800 Subject: [PATCH 3/4] fix(web): sync tool grouping mode in the same window --- web/src/hooks/useToolGroupingMode.test.ts | 17 +++++++++++++++++ web/src/hooks/useToolGroupingMode.ts | 11 ++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/web/src/hooks/useToolGroupingMode.test.ts b/web/src/hooks/useToolGroupingMode.test.ts index 228556b36c..3eb5b696c9 100644 --- a/web/src/hooks/useToolGroupingMode.test.ts +++ b/web/src/hooks/useToolGroupingMode.test.ts @@ -1,8 +1,10 @@ +import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it } from 'vitest' import { DEFAULT_TOOL_GROUPING_MODE, getInitialToolGroupingMode, getToolGroupingModeOptions, + useToolGroupingMode, } from './useToolGroupingMode' describe('useToolGroupingMode helpers', () => { @@ -27,4 +29,19 @@ describe('useToolGroupingMode helpers', () => { window.localStorage.setItem('hapi-tool-grouping-mode', 'classified') expect(getInitialToolGroupingMode()).toBe('classified') }) + + it('syncs changes between hook consumers in the same window', () => { + const first = renderHook(() => useToolGroupingMode()) + const second = renderHook(() => useToolGroupingMode()) + + act(() => first.result.current.setToolGroupingMode('classified')) + + expect(first.result.current.toolGroupingMode).toBe('classified') + expect(second.result.current.toolGroupingMode).toBe('classified') + + act(() => second.result.current.setToolGroupingMode('grouped')) + + expect(first.result.current.toolGroupingMode).toBe('grouped') + expect(second.result.current.toolGroupingMode).toBe('grouped') + }) }) diff --git a/web/src/hooks/useToolGroupingMode.ts b/web/src/hooks/useToolGroupingMode.ts index d142a7dc4d..0a503dde84 100644 --- a/web/src/hooks/useToolGroupingMode.ts +++ b/web/src/hooks/useToolGroupingMode.ts @@ -11,6 +11,7 @@ export function getToolGroupingModeOptions(): ReadonlyArray<{ value: ToolGroupin } const STORAGE_KEY = 'hapi-tool-grouping-mode' +const CHANGE_EVENT = 'hapi-tool-grouping-mode-change' function isBrowser(): boolean { return typeof window !== 'undefined' && typeof document !== 'undefined' @@ -44,8 +45,15 @@ export function useToolGroupingMode(): { setToolGroupingModeState(parseToolGroupingMode(event.newValue)) } } + const onChange = () => { + setToolGroupingModeState(getInitialToolGroupingMode()) + } window.addEventListener('storage', onStorage) - return () => window.removeEventListener('storage', onStorage) + window.addEventListener(CHANGE_EVENT, onChange) + return () => { + window.removeEventListener('storage', onStorage) + window.removeEventListener(CHANGE_EVENT, onChange) + } }, []) const setToolGroupingMode = useCallback((mode: ToolGroupingMode) => { @@ -56,6 +64,7 @@ export function useToolGroupingMode(): { } else { localStorage.setItem(STORAGE_KEY, mode) } + window.dispatchEvent(new Event(CHANGE_EVENT)) } catch { // Ignore storage errors } From b62788b053616a9b2f02b118116a917bec253be9 Mon Sep 17 00:00:00 2001 From: Ananovo <78636812+techotaku39@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:02:15 +0800 Subject: [PATCH 4/4] fix(web): reset tool group ids when mode changes --- web/src/chat/toolGroups.test.ts | 41 ++++++++++++++++++++++++++++++ web/src/chat/toolGroups.ts | 5 +++- web/src/components/SessionChat.tsx | 5 +++- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/web/src/chat/toolGroups.test.ts b/web/src/chat/toolGroups.test.ts index 2945019266..0595cdfe31 100644 --- a/web/src/chat/toolGroups.test.ts +++ b/web/src/chat/toolGroups.test.ts @@ -274,6 +274,47 @@ describe('buildVisibleChatBlocks', () => { expect(isToolGroupBlock(grouped[0]) && grouped[0].defaultOpen).toBe(false) }) + it('does not reuse grouped ids when switching to classified mode', () => { + const firstRead = makeToolBlock('codex-read-1', 'CodexBash', { + command: 'cat package.json', + command_actions: [{ + type: 'read', + command: 'cat package.json', + name: 'package.json', + path: '/repo/package.json' + }] + }) + const command = makeToolBlock('codex-test', 'CodexBash', { + command: 'bun test', + command_actions: [{ type: 'unknown', command: 'bun test' }] + }) + const secondRead = makeToolBlock('codex-read-2', 'CodexBash', { + command: 'cat README.md', + command_actions: [{ + type: 'read', + command: 'cat README.md', + name: 'README.md', + path: '/repo/README.md' + }] + }) + const blocks = [firstRead, command, secondRead] + const grouped = buildVisibleChatBlocks(blocks, { + hasMoreMessages: false, + groupingMode: 'grouped' + }) + const classified = buildVisibleChatBlocks(blocks, { + hasMoreMessages: false, + previousGroups: grouped.filter(isToolGroupBlock), + previousGroupingMode: 'grouped', + groupingMode: 'classified' + }) + const classifiedGroups = classified.filter(isToolGroupBlock) + + expect(grouped.filter(isToolGroupBlock)).toHaveLength(1) + expect(classifiedGroups).toHaveLength(2) + expect(new Set(classifiedGroups.map((group) => group.id)).size).toBe(2) + }) + it('groups contiguous eligible root tool cards', () => { const visible = buildVisibleChatBlocks([ makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), diff --git a/web/src/chat/toolGroups.ts b/web/src/chat/toolGroups.ts index b1b4857c82..9c752f0842 100644 --- a/web/src/chat/toolGroups.ts +++ b/web/src/chat/toolGroups.ts @@ -56,6 +56,7 @@ export function visibleBlockRole(block: VisibleChatBlock): VisibleChatBlockRole type ToolGroupingOptions = { hasMoreMessages: boolean previousGroups?: ToolGroupBlock[] + previousGroupingMode?: ToolGroupingMode groupingMode?: ToolGroupingMode codexExplorationCollapsed?: boolean } @@ -266,8 +267,10 @@ export function buildVisibleChatBlocks( options: ToolGroupingOptions ): VisibleChatBlock[] { const visibleBlocks: VisibleChatBlock[] = [] - const previousGroups = options.previousGroups ?? [] const groupingMode = options.groupingMode ?? 'classified' + const previousGroups = options.previousGroupingMode == null || options.previousGroupingMode === groupingMode + ? (options.previousGroups ?? []) + : [] for (let index = 0; index < blocks.length; index += 1) { const block = blocks[index] diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index e4c346f228..048cd4e692 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -595,6 +595,7 @@ function SessionChatInner(props: SessionChatProps) { const normalizedCacheRef = useRef>(new Map()) const blocksByIdRef = useRef>(new Map()) const visibleGroupsRef = useRef([]) + const visibleGroupsModeRef = useRef(toolGroupingMode) const [forceScrollToken, setForceScrollToken] = useState(0) const [outlineOpen, setOutlineOpen] = useState(props.initialOutlineOpen ?? false) const [terminalVisible, setTerminalVisible] = useState(false) @@ -1286,6 +1287,7 @@ function SessionChatInner(props: SessionChatProps) { () => buildVisibleChatBlocks(reconciled.blocks, { hasMoreMessages: props.hasMoreMessages, previousGroups: visibleGroupsRef.current, + previousGroupingMode: visibleGroupsModeRef.current, groupingMode: toolGroupingMode, codexExplorationCollapsed }), @@ -1317,8 +1319,9 @@ function SessionChatInner(props: SessionChatProps) { }, [latestCompletedBoundaryId]) useEffect(() => { + visibleGroupsModeRef.current = toolGroupingMode visibleGroupsRef.current = visibleBlocks.filter(isToolGroupBlock) - }, [visibleBlocks]) + }, [toolGroupingMode, visibleBlocks]) // "N new messages" counts rendered blocks, not raw messages: a subagent run // is dozens of sidechain messages but a single Task card, and a tool_use +