feat(web): make Codex exploration collapse configurable (#1352)

* feat(web): configure Codex exploration collapse

Default Codex exploration groups to collapsed and expose a persisted chat setting for users who prefer them expanded.\n\nvia [HAPI](https://hapi.run)\n\nCo-Authored-By: HAPI <noreply@hapi.run>

* test(web): cover Codex exploration preference

Cover preference persistence, default cleanup, and cross-tab storage synchronization.\n\nvia [HAPI](https://hapi.run)\n\nCo-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-08-04 08:04:12 +08:00
committed by GitHub
parent 951091fb3d
commit d0ae6c1f8d
9 changed files with 205 additions and 7 deletions
+17 -2
View File
@@ -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',
+2 -1
View File
@@ -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,
+5 -2
View File
@@ -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`),
@@ -0,0 +1,78 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import {
DEFAULT_CODEX_EXPLORATION_COLLAPSED,
getInitialCodexExplorationCollapsed,
useCodexExplorationCollapse,
} from './useCodexExplorationCollapse'
const STORAGE_KEY = 'hapi-codex-exploration-collapsed'
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(STORAGE_KEY, 'false')
expect(getInitialCodexExplorationCollapsed()).toBe(false)
window.localStorage.setItem(STORAGE_KEY, 'invalid')
expect(getInitialCodexExplorationCollapsed()).toBe(DEFAULT_CODEX_EXPLORATION_COLLAPSED)
})
})
describe('useCodexExplorationCollapse', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('persists the expanded preference', () => {
const { result } = renderHook(() => useCodexExplorationCollapse())
act(() => {
result.current.setCodexExplorationCollapsed(false)
})
expect(result.current.codexExplorationCollapsed).toBe(false)
expect(window.localStorage.getItem(STORAGE_KEY)).toBe('false')
})
it('removes the stored preference when restored to the default', () => {
window.localStorage.setItem(STORAGE_KEY, 'false')
const { result } = renderHook(() => useCodexExplorationCollapse())
act(() => {
result.current.setCodexExplorationCollapsed(true)
})
expect(result.current.codexExplorationCollapsed).toBe(true)
expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull()
})
it('applies cross-tab changes from storage events', () => {
const { result } = renderHook(() => useCodexExplorationCollapse())
act(() => {
window.dispatchEvent(new StorageEvent('storage', {
key: STORAGE_KEY,
newValue: 'false',
}))
})
expect(result.current.codexExplorationCollapsed).toBe(false)
act(() => {
window.dispatchEvent(new StorageEvent('storage', {
key: STORAGE_KEY,
newValue: null,
}))
})
expect(result.current.codexExplorationCollapsed).toBe(true)
})
})
@@ -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<boolean>(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 }
}
+2
View File
@@ -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',
+2
View File
@@ -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': '默认颜色',
+9 -1
View File
@@ -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 (
<SettingsPageContent description={t('settings.chat.description')}>
@@ -67,6 +69,12 @@ export default function SettingsChatPage() {
options={getTerminalToolDisplayModeOptions().map((option) => ({ value: option.value, label: t(option.labelKey) }))}
onChange={setTerminalToolDisplayMode}
/>
<SettingsSwitch
label={t('settings.chat.codexExplorationCollapsed')}
description={t('settings.chat.codexExplorationCollapsed.desc')}
checked={codexExplorationCollapsed}
onChange={setCodexExplorationCollapsed}
/>
</SettingsSection>
<SettingsSection title={t('settings.chat.colors')}>
<ChatSurfaceColorControl label={t('settings.chat.groupedToolBackground')} preference={toolGroupBackground} onPresetChange={(preset) => setToolGroupBackground(toPresetChatSurfaceColorPreference(preset))} onCustomChange={(value) => setToolGroupBackground(toCustomChatSurfaceColorPreference(value))} />
+14 -1
View File
@@ -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(<SettingsChatPage />)
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(<SettingsAboutPage />)
expect(screen.queryByText('Companion')).not.toBeInTheDocument()