diff --git a/web/src/chat/toolGroups.test.ts b/web/src/chat/toolGroups.test.ts index 174343b7..732eae91 100644 --- a/web/src/chat/toolGroups.test.ts +++ b/web/src/chat/toolGroups.test.ts @@ -170,7 +170,7 @@ describe('Codex activity headings', () => { }) describe('buildVisibleChatBlocks', () => { - it('renders one or more structured Codex exploration commands as an open exploration group', () => { + it('renders one or more structured Codex exploration commands as a collapsed exploration group', () => { const read = makeToolBlock('codex-read', 'CodexBash', { command: 'cat package.json', command_source: 'agent', @@ -198,10 +198,25 @@ describe('buildVisibleChatBlocks', () => { expect(isToolGroupBlock(visible[0])).toBe(true) if (!isToolGroupBlock(visible[0])) throw new Error('expected exploration group') expect(visible[0].presentationMode).toBe('codex-exploration') - expect(visible[0].defaultOpen).toBe(true) + expect(visible[0].defaultOpen).toBe(false) expect(visible[0].tools.map((tool) => tool.id)).toEqual(['codex-read', 'codex-search']) }) + it('opens exploration groups when the collapse preference is disabled', () => { + const visible = buildVisibleChatBlocks([ + makeToolBlock('codex-read', 'CodexBash', { + command: 'cat package.json', + command_actions: [{ type: 'read', command: 'cat package.json', name: 'package.json', path: '/repo/package.json' }] + }), + makeToolBlock('codex-search', 'CodexBash', { + command: 'rg nativeTitle web/src', + command_actions: [{ type: 'search', command: 'rg nativeTitle web/src', query: 'nativeTitle' }] + }) + ], { hasMoreMessages: false, codexExplorationCollapsed: false }) + + expect(isToolGroupBlock(visible[0]) && visible[0].defaultOpen).toBe(true) + }) + it('keeps structured general Codex commands separate from exploration groups', () => { const read = makeToolBlock('codex-read', 'CodexBash', { command: 'cat package.json', diff --git a/web/src/chat/toolGroups.ts b/web/src/chat/toolGroups.ts index 905d613d..15460136 100644 --- a/web/src/chat/toolGroups.ts +++ b/web/src/chat/toolGroups.ts @@ -55,6 +55,7 @@ export function visibleBlockRole(block: VisibleChatBlock): VisibleChatBlockRole type ToolGroupingOptions = { hasMoreMessages: boolean previousGroups?: ToolGroupBlock[] + codexExplorationCollapsed?: boolean } const PLAN_TOOL_NAMES = new Set([ @@ -305,7 +306,7 @@ export function buildVisibleChatBlocks( firstToolId: tools[0].id, lastToolId: tools[tools.length - 1].id, tools, - defaultOpen: groupingFamily === 'codex-exploration', + defaultOpen: groupingFamily === 'codex-exploration' && options.codexExplorationCollapsed === false, historyState: needsOlderHistory ? 'needs-older-history' : 'complete', needsOlderHistory, activityTitle, diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 9f3c6367..d6b285e5 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -21,6 +21,7 @@ import { reconcileChatBlocks } from '@/chat/reconcile' import { buildConversationOutline } from '@/chat/outline' import { buildVisibleChatBlocks, isToolGroupBlock, visibleBlockRole, type ToolGroupBlock } from '@/chat/toolGroups' import { useUnseenBlockCount } from '@/hooks/useUnseenBlockCount' +import { useCodexExplorationCollapse } from '@/hooks/useCodexExplorationCollapse' import { isQueuedForInvocation } from '@/lib/messages' import { inactiveSessionCanResume } from '@/lib/sessionResume' import { @@ -480,6 +481,7 @@ export function SessionChat(props: SessionChatProps) { function SessionChatInner(props: SessionChatProps) { const { haptic } = usePlatform() const { t } = useTranslation() + const { codexExplorationCollapsed } = useCodexExplorationCollapse() const navigate = useNavigate() const [historyActionPending, setHistoryActionPending] = useState(false) @@ -1164,9 +1166,10 @@ function SessionChatInner(props: SessionChatProps) { const visibleBlocks = useMemo( () => buildVisibleChatBlocks(reconciled.blocks, { hasMoreMessages: props.hasMoreMessages, - previousGroups: visibleGroupsRef.current + previousGroups: visibleGroupsRef.current, + codexExplorationCollapsed }), - [reconciled.blocks, props.hasMoreMessages] + [reconciled.blocks, props.hasMoreMessages, codexExplorationCollapsed] ) // Fork-current must compare against assistant-ui message ids (`kind:id`), diff --git a/web/src/hooks/useCodexExplorationCollapse.test.ts b/web/src/hooks/useCodexExplorationCollapse.test.ts new file mode 100644 index 00000000..70cffe84 --- /dev/null +++ b/web/src/hooks/useCodexExplorationCollapse.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + DEFAULT_CODEX_EXPLORATION_COLLAPSED, + getInitialCodexExplorationCollapsed, +} from './useCodexExplorationCollapse' + +describe('useCodexExplorationCollapse helpers', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('defaults to collapsed', () => { + expect(getInitialCodexExplorationCollapsed()).toBe(DEFAULT_CODEX_EXPLORATION_COLLAPSED) + }) + + it('reads valid stored values and ignores invalid values', () => { + window.localStorage.setItem('hapi-codex-exploration-collapsed', 'false') + expect(getInitialCodexExplorationCollapsed()).toBe(false) + + window.localStorage.setItem('hapi-codex-exploration-collapsed', 'invalid') + expect(getInitialCodexExplorationCollapsed()).toBe(DEFAULT_CODEX_EXPLORATION_COLLAPSED) + }) +}) diff --git a/web/src/hooks/useCodexExplorationCollapse.ts b/web/src/hooks/useCodexExplorationCollapse.ts new file mode 100644 index 00000000..037cc112 --- /dev/null +++ b/web/src/hooks/useCodexExplorationCollapse.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useState } from 'react' + +export const DEFAULT_CODEX_EXPLORATION_COLLAPSED = true + +const CODEX_EXPLORATION_COLLAPSED_STORAGE_KEY = 'hapi-codex-exploration-collapsed' + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +function safeGetItem(): string | null { + if (!isBrowser()) return null + try { + return localStorage.getItem(CODEX_EXPLORATION_COLLAPSED_STORAGE_KEY) + } catch { + return null + } +} + +function safeSetItem(value: string): void { + if (!isBrowser()) return + try { + localStorage.setItem(CODEX_EXPLORATION_COLLAPSED_STORAGE_KEY, value) + } catch { + // Ignore storage errors. + } +} + +function safeRemoveItem(): void { + if (!isBrowser()) return + try { + localStorage.removeItem(CODEX_EXPLORATION_COLLAPSED_STORAGE_KEY) + } catch { + // Ignore storage errors. + } +} + +function parseCodexExplorationCollapsed(raw: string | null): boolean { + if (raw === 'true') return true + if (raw === 'false') return false + return DEFAULT_CODEX_EXPLORATION_COLLAPSED +} + +export function getInitialCodexExplorationCollapsed(): boolean { + return parseCodexExplorationCollapsed(safeGetItem()) +} + +export function useCodexExplorationCollapse(): { + codexExplorationCollapsed: boolean + setCodexExplorationCollapsed: (value: boolean) => void +} { + const [codexExplorationCollapsed, setCodexExplorationCollapsedState] = useState(getInitialCodexExplorationCollapsed) + + useEffect(() => { + if (!isBrowser()) return + + const onStorage = (event: StorageEvent) => { + if (event.key !== CODEX_EXPLORATION_COLLAPSED_STORAGE_KEY) return + setCodexExplorationCollapsedState(parseCodexExplorationCollapsed(event.newValue)) + } + + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + + const setCodexExplorationCollapsed = useCallback((value: boolean) => { + setCodexExplorationCollapsedState(value) + if (value === DEFAULT_CODEX_EXPLORATION_COLLAPSED) { + safeRemoveItem() + } else { + safeSetItem(String(value)) + } + }, []) + + return { codexExplorationCollapsed, setCodexExplorationCollapsed } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 7eee438a..94a62d7a 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -752,6 +752,8 @@ 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.codexExplorationCollapsed': 'Collapse explored tool groups by default', + 'settings.chat.codexExplorationCollapsed.desc': 'Keep Codex read and search activity collapsed until you open it.', 'settings.chat.groupedToolBackground': 'Grouped Tool Use Background', 'settings.chat.userMessageBackground': 'User Message Background', 'settings.chat.surfaceColor.default': 'Default color', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 8e3e286c..19552127 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -756,6 +756,8 @@ export default { 'settings.chat.terminalToolDisplay': '终端工具卡片', 'settings.chat.terminalToolDisplay.compact': '简洁(仅命令)', 'settings.chat.terminalToolDisplay.detailed': '详细(显示输出预览)', + 'settings.chat.codexExplorationCollapsed': '已探索工具组默认收起', + 'settings.chat.codexExplorationCollapsed.desc': 'Codex 的读取和搜索活动默认收起,点击后查看详情。', 'settings.chat.groupedToolBackground': '聚合 Tool Use 背景', 'settings.chat.userMessageBackground': '用户消息背景', 'settings.chat.surfaceColor.default': '默认颜色', diff --git a/web/src/routes/settings/chat.tsx b/web/src/routes/settings/chat.tsx index d920df83..d2e3d750 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 { useCodexExplorationCollapse } from '@/hooks/useCodexExplorationCollapse' import { getChatSurfaceColorPickerValue, getChatSurfaceColorPresetOptions, @@ -10,7 +11,7 @@ import { type ChatSurfaceColorPreference, type ChatSurfaceColorPreset, } from '@/hooks/useChatSurfaceColors' -import { SettingsChoiceGroup, SettingsFieldLabel, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives' +import { SettingsChoiceGroup, SettingsFieldLabel, SettingsPageContent, SettingsSection, SettingsSwitch } from '@/components/settings/SettingsPrimitives' import { ComposerToolbarLayoutControl } from '@/components/settings/ComposerToolbarLayoutControl' function ChatSurfaceColorControl(props: { @@ -48,6 +49,7 @@ export default function SettingsChatPage() { const { t } = useTranslation() const { composerEnterBehavior, setComposerEnterBehavior } = useComposerEnterBehavior() const { terminalToolDisplayMode, setTerminalToolDisplayMode } = useTerminalToolDisplayMode() + const { codexExplorationCollapsed, setCodexExplorationCollapsed } = useCodexExplorationCollapse() const { toolGroupBackground, userMessageBackground, setToolGroupBackground, setUserMessageBackground } = useChatSurfaceColors() return ( @@ -67,6 +69,12 @@ export default function SettingsChatPage() { options={getTerminalToolDisplayModeOptions().map((option) => ({ value: option.value, label: t(option.labelKey) }))} onChange={setTerminalToolDisplayMode} /> + setToolGroupBackground(toPresetChatSurfaceColorPreference(preset))} onCustomChange={(value) => setToolGroupBackground(toCustomChatSurfaceColorPreference(value))} /> diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index e380bc75..7c3783af 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -10,7 +10,7 @@ import SettingsVoicePage from './voice' import SettingsVoiceVoicesPage from './voice-voices' import SettingsVoiceAdvancedPage from './voice-advanced' -const { context, navigate, setAppearance, setColorTheme, setFontScale, setTerminalFontSize, setComposerEnterBehavior, setVoice } = vi.hoisted(() => ({ +const { context, navigate, setAppearance, setColorTheme, setFontScale, setTerminalFontSize, setComposerEnterBehavior, setCodexExplorationCollapsed, setVoice } = vi.hoisted(() => ({ context: { token: '' }, navigate: vi.fn(), setAppearance: vi.fn(), @@ -18,6 +18,7 @@ const { context, navigate, setAppearance, setColorTheme, setFontScale, setTermin setFontScale: vi.fn(), setTerminalFontSize: vi.fn(), setComposerEnterBehavior: vi.fn(), + setCodexExplorationCollapsed: vi.fn(), setVoice: vi.fn(), })) @@ -129,6 +130,10 @@ vi.mock('@/hooks/useTerminalToolDisplayMode', () => ({ ], })) +vi.mock('@/hooks/useCodexExplorationCollapse', () => ({ + useCodexExplorationCollapse: () => ({ codexExplorationCollapsed: true, setCodexExplorationCollapsed }), +})) + vi.mock('@/hooks/useChatSurfaceColors', () => ({ useChatSurfaceColors: () => ({ toolGroupBackground: 'default', @@ -261,6 +266,14 @@ describe('responsive settings pages', () => { expect(screen.getByText('Grouped Tool Use Background')).toBeInTheDocument() }) + it('renders the default-collapse switch for Codex exploration groups', () => { + renderPage() + const toggle = screen.getByRole('checkbox', { name: 'Collapse explored tool groups by default' }) + expect(toggle).toBeChecked() + fireEvent.click(toggle) + expect(setCodexExplorationCollapsed).toHaveBeenCalledWith(false) + }) + it('renders About metadata on its own route page', () => { renderPage() expect(screen.queryByText('Companion')).not.toBeInTheDocument()