diff --git a/web/src/chat/toolGroups.test.ts b/web/src/chat/toolGroups.test.ts
index b3279193..3e47b96a 100644
--- a/web/src/chat/toolGroups.test.ts
+++ b/web/src/chat/toolGroups.test.ts
@@ -141,6 +141,33 @@ describe('isEligibleForToolGrouping', () => {
})
})
+describe('Codex activity headings', () => {
+ it('associates only an immediately preceding reasoning heading', () => {
+ const reasoning = makeToolBlock('reasoning-1', 'CodexReasoning', { title: 'Inspecting authentication' })
+ const visible = buildVisibleChatBlocks([
+ reasoning,
+ makeToolBlock('read-1', 'Read', { file_path: 'auth.ts' }),
+ makeToolBlock('read-2', 'Read', { file_path: 'session.ts' }),
+ ], { hasMoreMessages: false })
+
+ expect(visible).toHaveLength(2)
+ expect(isToolGroupBlock(visible[1])).toBe(true)
+ expect(isToolGroupBlock(visible[1]) ? visible[1].activityTitle : null).toBe('Inspecting authentication')
+ })
+
+ it('does not carry a heading across a text boundary', () => {
+ const visible = buildVisibleChatBlocks([
+ makeToolBlock('reasoning-1', 'CodexReasoning', { title: 'Inspecting authentication' }),
+ makeTextBlock('text-boundary'),
+ makeToolBlock('read-1', 'Read', { file_path: 'auth.ts' }),
+ makeToolBlock('read-2', 'Read', { file_path: 'session.ts' }),
+ ], { hasMoreMessages: false })
+
+ const group = visible.find(isToolGroupBlock)
+ expect(group?.activityTitle).toBeNull()
+ })
+})
+
describe('buildVisibleChatBlocks', () => {
it('groups contiguous eligible root tool cards', () => {
const visible = buildVisibleChatBlocks([
diff --git a/web/src/chat/toolGroups.ts b/web/src/chat/toolGroups.ts
index aac6c675..1fe5dd78 100644
--- a/web/src/chat/toolGroups.ts
+++ b/web/src/chat/toolGroups.ts
@@ -30,6 +30,7 @@ export type ToolGroupBlock = {
defaultOpen: boolean
historyState: 'complete' | 'needs-older-history'
needsOlderHistory: boolean
+ activityTitle?: string | null
summary: ToolGroupSummary
}
@@ -258,6 +259,11 @@ export function buildVisibleChatBlocks(
const startsAtOldestVisibleBoundary = visibleBlocks.length === 0
const needsOlderHistory = options.hasMoreMessages && startsAtOldestVisibleBoundary
+ const previousBlock = visibleBlocks.at(-1)
+ const activityTitle = previousBlock?.kind === 'tool-call'
+ && previousBlock.tool.name === 'CodexReasoning'
+ ? getInputStringAny(previousBlock.tool.input, ['title'])
+ : null
visibleBlocks.push({
kind: 'tool-group',
id: createToolGroupId(tools, needsOlderHistory, previousGroups),
@@ -269,6 +275,7 @@ export function buildVisibleChatBlocks(
defaultOpen: false,
historyState: needsOlderHistory ? 'needs-older-history' : 'complete',
needsOlderHistory,
+ activityTitle,
summary: summarizeToolGroup(tools)
})
index = cursor - 1
diff --git a/web/src/components/ToolCard/ToolGroupCard.test.tsx b/web/src/components/ToolCard/ToolGroupCard.test.tsx
index df3e66e6..32e2f188 100644
--- a/web/src/components/ToolCard/ToolGroupCard.test.tsx
+++ b/web/src/components/ToolCard/ToolGroupCard.test.tsx
@@ -100,7 +100,7 @@ describe('ToolGroupCard', () => {
it('renders a collapsed target-first header', () => {
const view = renderCard(makeGroup())
- expect(screen.getByRole('button', { name: /inspect project files/i })).toHaveAttribute('aria-expanded', 'false')
+ expect(screen.getByRole('button', { name: /inspect a\.ts/i })).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByText('Run 1 · Read 1')).toBeInTheDocument()
expect(screen.getByText('2 actions')).toBeInTheDocument()
expect(screen.queryByText('src/a.ts')).not.toBeInTheDocument()
@@ -111,7 +111,7 @@ describe('ToolGroupCard', () => {
it('expands to show compact rows and opens a detail dialog per row', async () => {
const view = renderCard(makeGroup())
- const groupToggle = within(view.container).getByRole('button', { name: /inspect project files/i })
+ const groupToggle = within(view.container).getByRole('button', { name: /inspect a\.ts/i })
expect(view.container.querySelector('svg[data-state="closed"]')).toBeInTheDocument()
fireEvent.click(groupToggle)
@@ -212,7 +212,7 @@ describe('ToolGroupCard', () => {
}
const view = render()
- const groupToggle = within(view.container).getByRole('button', { name: /inspect project files/i })
+ const groupToggle = within(view.container).getByRole('button', { name: /inspect a\.ts/i })
fireEvent.click(groupToggle)
@@ -272,7 +272,7 @@ describe('ToolGroupCard', () => {
}
const view = render()
- const groupToggle = within(view.container).getByRole('button', { name: /inspect project files/i })
+ const groupToggle = within(view.container).getByRole('button', { name: /inspect a\.ts/i })
fireEvent.click(groupToggle)
@@ -327,7 +327,7 @@ describe('ToolGroupCard', () => {
}
const view = render()
- const groupToggle = within(view.container).getByRole('button', { name: /inspect project files/i })
+ const groupToggle = within(view.container).getByRole('button', { name: /inspect a\.ts/i })
fireEvent.click(groupToggle)
diff --git a/web/src/components/ToolCard/groupedPresentation.test.ts b/web/src/components/ToolCard/groupedPresentation.test.ts
index 6301d07f..f3135381 100644
--- a/web/src/components/ToolCard/groupedPresentation.test.ts
+++ b/web/src/components/ToolCard/groupedPresentation.test.ts
@@ -114,6 +114,87 @@ describe('formatGroupedRowLabel', () => {
})
describe('formatGroupedHeaderTitle', () => {
+ it('uses an immediately preceding Codex activity heading', () => {
+ const group = makeGroup([
+ makeTool('read-activity-1', 'Read', { file_path: 'auth.ts' }),
+ makeTool('read-activity-2', 'Read', { file_path: 'session.ts' }),
+ ])
+ group.activityTitle = 'Inspecting the authentication flow'
+
+ expect(formatGroupedHeaderTitle(group, tEn)).toBe('Inspecting the authentication flow')
+ })
+
+ it('uses a specific file target instead of the generic inspection label', () => {
+ const group = makeGroup([
+ makeTool('read-1', 'Read', { file_path: '/repo/src/auth.ts' }),
+ makeTool('read-2', 'Read', { file_path: '/repo/src/session.ts' }),
+ ])
+
+ expect(formatGroupedHeaderTitle(group, tEn)).toBe('Inspect auth.ts')
+ })
+
+ it('uses a specific search pattern', () => {
+ const group = makeGroup([
+ makeTool('grep-1', 'Grep', { pattern: 'authToken' }),
+ makeTool('grep-2', 'Grep', { pattern: 'authToken' }),
+ ])
+
+ expect(formatGroupedHeaderTitle(group, tEn)).toBe('Search “authToken”')
+ })
+
+ it('extracts safe targets from Codex inspection and search commands', () => {
+ const inspect = makeGroup([
+ makeTool('inspect-command-1', 'shell_command', { command: "sed -n '1,120p' web/src/auth.ts" }),
+ makeTool('inspect-command-2', 'shell_command', { command: 'cat web/src/session.ts' }),
+ ])
+ const search = makeGroup([
+ makeTool('search-command-1', 'shell_command', { command: "rg 'authToken' web/src" }),
+ makeTool('search-command-2', 'shell_command', { command: "grep 'authToken' cli/src/index.ts" }),
+ ])
+
+ expect(formatGroupedHeaderTitle(inspect, tEn)).toBe('Inspect auth.ts')
+ expect(formatGroupedHeaderTitle(search, tEn)).toBe('Search “authToken”')
+ })
+
+ it('skips search option values and redacts common token prefixes', () => {
+ const optioned = makeGroup([
+ makeTool('search-option-1', 'shell_command', { command: "rg -g '*.ts' authToken web/src" }),
+ makeTool('search-option-2', 'shell_command', { command: "grep -m 2 authToken cli/src" }),
+ ])
+ const credential = makeGroup([
+ makeTool('search-secret-1', 'Grep', { pattern: 'ghp_1234567890abcdefghijklmnop' }),
+ makeTool('search-secret-2', 'Grep', { pattern: 'ghp_1234567890abcdefghijklmnop' }),
+ ])
+
+ expect(formatGroupedHeaderTitle(optioned, tEn)).toBe('Search “authToken”')
+ expect(formatGroupedHeaderTitle(credential, tEn)).toBe('Search project content')
+ expect(formatGroupedHeaderTitle(credential, tEn)).not.toContain('ghp_')
+ })
+
+ it('uses a safe project command but hides arbitrary command text', () => {
+ const safe = makeGroup([
+ makeTool('cmd-1', 'Bash', { command: 'bun test' }),
+ makeTool('cmd-2', 'Bash', { command: 'bun test' }),
+ ])
+ const sensitive = makeGroup([
+ makeTool('cmd-3', 'Bash', { command: 'curl -H "Authorization: Bearer abc" example.com' }),
+ makeTool('cmd-4', 'Bash', { command: 'curl example.com' }),
+ ])
+
+ expect(formatGroupedHeaderTitle(safe, tEn)).toBe('Run bun test')
+ expect(formatGroupedHeaderTitle(sensitive, tEn)).toBe('Run project commands')
+ expect(formatGroupedHeaderTitle(sensitive, tEn)).not.toContain('Bearer')
+ })
+
+ it('prefers a Claude call description and truncates long labels', () => {
+ const first = makeTool('cmd-description-1', 'Bash', { command: 'node script.js' })
+ first.tool.description = 'Check the authentication migration behavior before applying changes'
+ const second = makeTool('cmd-description-2', 'Bash', { command: 'node other.js' })
+ const group = makeGroup([first, second])
+
+ expect(formatGroupedHeaderTitle(group, tEn)).toBe('Check the authentication migration behavior before applying changes')
+ })
+
it('uses the primary activity without an inline +n suffix', () => {
const group = makeGroup([
makeTool('shell-1', 'shell_command', { command: 'Get-ChildItem src -Recurse' }),
@@ -123,7 +204,7 @@ describe('formatGroupedHeaderTitle', () => {
makeTool('shell-5', 'shell_command', { command: 'cat README.md' }),
])
- expect(formatGroupedHeaderTitle(group, tZh)).toBe('检查项目文件')
+ expect(formatGroupedHeaderTitle(group, tZh)).toBe('检查 src')
})
it('uses a neutral title for all-generic tool groups', () => {
diff --git a/web/src/components/ToolCard/groupedPresentation.ts b/web/src/components/ToolCard/groupedPresentation.ts
index 74b1731b..939c2c0b 100644
--- a/web/src/components/ToolCard/groupedPresentation.ts
+++ b/web/src/components/ToolCard/groupedPresentation.ts
@@ -13,8 +13,58 @@ export type GroupedSummaryIntent =
| 'generic-command'
| 'generic-tool'
-const FILE_INSPECTION_COMMAND_RE = /\b(get-childitem|ls|dir|get-content|cat|type|tree)\b/i
+const FILE_INSPECTION_COMMAND_RE = /\b(get-childitem|ls|dir|get-content|cat|type|tree)\b|\bsed\s+-n\b/i
const CONTENT_SEARCH_COMMAND_RE = /\b(rg|grep|select-string|findstr)\b/i
+const SAFE_PROJECT_COMMAND_RE = /^(?:(?:bun|npm|pnpm|yarn) (?:run )?(?:test|lint|build|typecheck)(?:[:\w.-]*)|git (?:status|diff|log)(?:\s+--?[\w.-]+)*|cargo (?:test|check)|go test(?:\s+\.\/\.\.\.)?|pytest(?:\s+-[\w-]+)*)$/i
+const SENSITIVE_TEXT_RE = /(?:bearer\s+\S+|(?:api[_-]?key|token|password|secret)(?:\s*[:=]\s*\S+|\s+\S{12,})|(?:gh[pousr]_|github_pat_|sk-[a-z0-9_-]*|xox[baprs]-)[a-z0-9_-]{12,}|[a-f0-9]{32,}|[a-z0-9_+/=-]{40,})/i
+const SEARCH_OPTIONS_WITH_VALUE = new Set([
+ '-g', '--glob', '-t', '--type', '-A', '-B', '-C', '--context',
+ '--before-context', '--after-context', '-m', '--max-count'
+])
+const MAX_SPECIFIC_LABEL_LENGTH = 72
+
+function truncateLabel(value: string): string {
+ const normalized = value.replace(/\s+/g, ' ').trim()
+ return normalized.length > MAX_SPECIFIC_LABEL_LENGTH
+ ? `${normalized.slice(0, MAX_SPECIFIC_LABEL_LENGTH - 1)}…`
+ : normalized
+}
+
+function safeLabelValue(value: string | null): string | null {
+ if (!value || SENSITIVE_TEXT_RE.test(value)) return null
+ return truncateLabel(value)
+}
+
+function basename(value: string): string {
+ const parts = value.replace(/\\/g, '/').split('/').filter(Boolean)
+ return parts.at(-1) ?? value
+}
+
+function simpleCommandParts(command: string): string[] | null {
+ if (/[;&|<>$`(){}\n\r]/.test(command)) return null
+ return command.trim().split(/\s+/).filter(Boolean)
+}
+
+function getInspectionCommandTarget(command: string): string | null {
+ const parts = simpleCommandParts(command)
+ if (!parts || parts.length < 2) return null
+ const target = [...parts].reverse().find((part) => !part.startsWith('-') && !/^['"]?\d+(?:,\d+)?p['"]?$/.test(part))
+ if (!target || target === parts[0]) return null
+ return safeLabelValue(target.replace(/^['"]|['"]$/g, ''))
+}
+
+function getSearchCommandPattern(command: string): string | null {
+ const parts = simpleCommandParts(command)
+ if (!parts) return null
+ const executableIndex = parts.findIndex((part) => /^(?:rg|grep|select-string|findstr)$/i.test(part))
+ if (executableIndex < 0) return null
+ for (let index = executableIndex + 1; index < parts.length; index += 1) {
+ const part = parts[index]
+ if (!part.startsWith('-')) return safeLabelValue(part.replace(/^['"]|['"]$/g, ''))
+ if (SEARCH_OPTIONS_WITH_VALUE.has(part)) index += 1
+ }
+ return null
+}
function getCommandText(input: unknown): string | null {
const direct = getInputStringAny(input, ['command', 'cmd'])
@@ -103,8 +153,57 @@ function getPrimaryIntent(block: ToolGroupBlock): GroupedSummaryIntent {
return primary
}
+function formatSpecificIntentTitle(block: ToolGroupBlock, intent: GroupedSummaryIntent, t: Translator): string | null {
+ const matching = block.tools.filter((tool) => inferGroupedSummaryIntent(tool) === intent)
+ const described = matching
+ .map((tool) => safeLabelValue(tool.tool.description))
+ .find((value): value is string => value !== null)
+ if (described) return described
+
+ if (intent === 'inspect-files' || intent === 'modify-files') {
+ for (const tool of matching) {
+ const target = safeLabelValue(getInputStringAny(tool.tool.input, ['file_path', 'path', 'file', 'filePath', 'notebook_path']))
+ if (target) {
+ return t(intent === 'modify-files' ? 'toolGroup.friendly.editTarget' : 'toolGroup.friendly.inspectTarget', {
+ target: basename(target)
+ })
+ }
+ if (intent === 'inspect-files') {
+ const command = getCommandText(tool.tool.input)
+ const commandTarget = command ? getInspectionCommandTarget(command) : null
+ if (commandTarget) return t('toolGroup.friendly.inspectTarget', { target: basename(commandTarget) })
+ }
+ }
+ }
+
+ if (intent === 'search-content') {
+ for (const tool of matching) {
+ const pattern = safeLabelValue(getInputStringAny(tool.tool.input, ['pattern', 'query']))
+ if (pattern) return t('toolGroup.friendly.searchTarget', { target: pattern })
+ const command = getCommandText(tool.tool.input)
+ const commandPattern = command ? getSearchCommandPattern(command) : null
+ if (commandPattern) return t('toolGroup.friendly.searchTarget', { target: commandPattern })
+ }
+ }
+
+ if (intent === 'run-project-command') {
+ for (const tool of matching) {
+ const command = safeLabelValue(getCommandText(tool.tool.input))
+ if (command && SAFE_PROJECT_COMMAND_RE.test(command)) {
+ return t('toolGroup.friendly.runTarget', { target: command })
+ }
+ }
+ }
+
+ return null
+}
+
export function formatGroupedHeaderTitle(block: ToolGroupBlock, t: Translator): string {
+ const activityTitle = safeLabelValue(block.activityTitle ?? null)
+ if (activityTitle) return activityTitle
const primaryIntent = getPrimaryIntent(block)
+ const specificTitle = formatSpecificIntentTitle(block, primaryIntent, t)
+ if (specificTitle) return specificTitle
if (primaryIntent === 'generic-tool') {
return t('toolGroup.title')
}
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts
index 1a656a70..e36ca7a0 100644
--- a/web/src/lib/locales/en.ts
+++ b/web/src/lib/locales/en.ts
@@ -442,6 +442,10 @@ export default {
'tool.requestUserInput.notePlaceholder': 'Add a note…',
'tool.requestUserInput.popupBlocked': 'Could not open the sign-in page. Allow popups and try again.',
'toolGroup.title': 'Tool activity',
+ 'toolGroup.friendly.inspectTarget': 'Inspect {target}',
+ 'toolGroup.friendly.searchTarget': 'Search “{target}”',
+ 'toolGroup.friendly.runTarget': 'Run {target}',
+ 'toolGroup.friendly.editTarget': 'Edit {target}',
'toolGroup.primary.fileTargets': '{target} +{n}',
'toolGroup.primary.commandTargets': '{target} +{n}',
'toolGroup.primary.searchTargets': '{target} +{n}',
diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts
index b69377d9..8ab241eb 100644
--- a/web/src/lib/locales/zh-CN.ts
+++ b/web/src/lib/locales/zh-CN.ts
@@ -446,6 +446,10 @@ export default {
'tool.requestUserInput.notePlaceholder': '添加备注…',
'tool.requestUserInput.popupBlocked': '无法打开登录页面。请允许弹出窗口后重试。',
'toolGroup.title': '工具活动',
+ 'toolGroup.friendly.inspectTarget': '检查 {target}',
+ 'toolGroup.friendly.searchTarget': '搜索“{target}”',
+ 'toolGroup.friendly.runTarget': '运行 {target}',
+ 'toolGroup.friendly.editTarget': '编辑 {target}',
'toolGroup.primary.fileTargets': '{target} 等 +{n}',
'toolGroup.primary.commandTargets': '{target} 等 +{n}',
'toolGroup.primary.searchTargets': '{target} 等 +{n}',