diff --git a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx
index e8471da9a3..57cb01b16c 100644
--- a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx
+++ b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx
@@ -23,6 +23,7 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({
return () => { clearInterval(timer) }
}, [ticking])
if (!turnProcess.foldable) return null
+ const canCollapse = turnProcess.hasContent && !turnProcessAlwaysOpen(node)
const running = turn?.status === 'open'
const reason = turn?.end?.data.reason.kind
const duration = turn?.start === undefined ? undefined
@@ -41,7 +42,7 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({
data-turn-process-messages={node.data.messageCount}
data-turn-process-tool-calls={node.data.toolCallCount}
data-turn-process-subagents={node.data.subagentCount}
- disabled={!turnProcess.hasContent || turnProcessAlwaysOpen(node)}
+ disabled={!canCollapse}
aria-expanded={turnProcess.hasContent ? open : undefined}
onClick={(event) => {
event.currentTarget.focus()
@@ -49,7 +50,7 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({
}}
>
{label}
- {turnProcess.hasContent &&
}
+ {canCollapse &&
}
)
})
diff --git a/packages/client/ui-chat/src/client/chat/step-process.ts b/packages/client/ui-chat/src/client/chat/step-process.ts
index de76522235..32b7f0bddd 100644
--- a/packages/client/ui-chat/src/client/chat/step-process.ts
+++ b/packages/client/ui-chat/src/client/chat/step-process.ts
@@ -90,18 +90,42 @@ function activity(name: string): ProcessActivity {
return 'tools'
}
+function liveToolDetail(argsRaw: string): string {
+ let args: unknown
+ try {
+ args = JSON.parse(argsRaw)
+ } catch {
+ // Partial argument JSON has no complete detail to preview.
+ return ''
+ }
+ if (args === null || typeof args !== 'object') return ''
+ for (const key of ['command', 'cmd', 'queries', 'query', 'pattern', 'url', 'file_path', 'path', 'description']) {
+ if (key in args) {
+ const value: unknown = Reflect.get(args, key)
+ if (Array.isArray(value) && value.every(item => typeof item === 'string')) {
+ const detail = value.join(', ').replace(/\s+/g, ' ').trim()
+ if (detail !== '') return detail
+ }
+ if (typeof value === 'string' && value.trim() !== '') return value.replace(/\s+/g, ' ').trim()
+ }
+ }
+ return ''
+}
+
/**
* Rank categories by distinct call count, breaking ties by first appearance.
* @param nodes - process members, including recursive tools.
- * @returns all ranked categories and the latest running tool category.
+ * @returns all ranked categories and the latest running tool category and argument preview.
*/
export function processActivity(nodes: readonly ChatNode[]): {
counts: readonly { kind: ProcessActivity; count: number }[]
running: ProcessActivity | undefined
+ runningDetail: string
} {
const counts = new Map
()
const seen = new Set()
let running: ProcessActivity | undefined
+ let runningDetail = ''
let runningTime = -Infinity
const visit = (tool: ToolCallBlock): void => {
if (seen.has(tool.callId)) return
@@ -111,6 +135,7 @@ export function processActivity(nodes: readonly ChatNode[]): {
const kind = activity(call.name)
if (isRunningTool(tool) && tool.time >= runningTime) {
running = kind
+ runningDetail = liveToolDetail(tool.argsRaw)
runningTime = tool.time
}
counts.set(kind, (counts.get(kind) ?? 0) + 1)
@@ -123,6 +148,7 @@ export function processActivity(nodes: readonly ChatNode[]): {
return {
counts: [...counts].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count),
running,
+ runningDetail,
}
}
diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts
index 38265db921..de1250d3e1 100644
--- a/packages/client/ui-chat/src/client/contract/slots.ts
+++ b/packages/client/ui-chat/src/client/contract/slots.ts
@@ -78,6 +78,9 @@ export interface ChatNodeTurnDataInjected {
/** Stable owner currency delivered to a keyed Chat renderer. */
export interface ChatNodeOwnerProps {
+ /** Hide settled reasoning previews in Compact work details. */
+ readonly compactTranscript?: boolean
+
cwd?: string | undefined
/** Open the current source file of a skill referenced by a sent message. */
openSkill: (name: string) => void
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index 9c551e1566..b070aedc4c 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -629,6 +629,7 @@ describe('ChatView', () => {
const view = render()
const toggle = view.getByRole('button', { name: chinese }) as HTMLButtonElement
expect(toggle.disabled).toBe(true)
+ expect(toggle.querySelector('svg')).toBeNull()
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(view.container.querySelector('[data-step-process]')?.hasAttribute('hidden')).toBe(false)
@@ -655,6 +656,36 @@ describe('ChatView', () => {
expect(view.getByRole('button', { name: plural })).toBeTruthy()
})
+ it.each([
+ ['bash', { command: 'git status', description: 'Inspect changes' }, '正在运行命令 git status'],
+ ['web_search', { queries: ['Cordis plugins', 'DSH tools'] }, '正在搜索网页 Cordis plugins, DSH tools'],
+ ['web_fetch', { url: 'https://example.com/docs' }, '正在访问网页 https://example.com/docs'],
+ ['read', { file_path: '/src/main.ts' }, '正在读取文件 /src/main.ts'],
+ ])('shows live %s details only in Detailed secondary titles', (name, args, expected) => {
+ const h = makeHarness({
+ nodes: [userInTurn(1, 'inspect', 2), reasoningAssistant(2, 'private reasoning', 2)],
+ runningCalls: [{ ...runningCall('work', name), argsRaw: JSON.stringify(args) }],
+ running: true,
+ })
+ const view = render()
+ const title = () => view.container.querySelector('[data-step-process] > button')?.textContent
+ const abstract = title()
+ act(() => { h.setTranscriptView('detailed') })
+ expect(title()).toBe(expected)
+ expect(title()).not.toContain('private reasoning')
+ act(() => { h.setTranscriptView('expanded') })
+ expect(title()).toBeUndefined()
+ act(() => { h.setTranscriptView('compact') })
+ expect(title()).toBe(abstract)
+ act(() => {
+ h.setTranscriptView('detailed')
+ h.setChat({ runningCalls: [], partial: { turn: 2, step: 2, blocks: [
+ { kind: 'reasoning', text: 'private reasoning' },
+ ] } })
+ })
+ expect(title()).toBe('正在分析请求')
+ })
+
it('closes a work range on streamed response text and keeps its expansion while more work runs', () => {
const h = makeHarness({
nodes: [userInTurn(1, 'inspect', 2), context(2, 'private runtime context', 2), reasoningAssistant(2.5, 'initial reasoning', 2)],
@@ -1722,6 +1753,7 @@ describe('ChatView', () => {
expect(toggle.getAttribute('aria-expanded')).toBeNull()
act(() => { h.set({ nodes: [user(1, 'question'), context(2, 'working context', 1)] }) })
expect(toggle.getAttribute('aria-expanded')).toBe('true')
+ expect(toggle.querySelector('svg')).toBeNull()
const group = view.container.querySelector('[data-step-process]')!
expect(group.getAttribute('hidden')).toBeNull()
act(() => { h.set({ nodes: [user(1, 'question'), context(2, 'working context', 1), ...(hasAnswer ? [assistant(3, 'answer')] : [])],
@@ -1730,6 +1762,7 @@ describe('ChatView', () => {
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(group.getAttribute('hidden')).toBe('until-found')
expect((toggle as HTMLButtonElement).disabled).toBe(false)
+ expect(toggle.querySelector('svg')).not.toBeNull()
act(() => { vi.advanceTimersByTime(3_000) })
expect(toggle.textContent).toBe('Took 2s')
view.unmount()
@@ -1748,7 +1781,7 @@ describe('ChatView', () => {
})
const view = render()
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true')
- const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
+ const processRow = () => view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
act(() => {
h.set({
@@ -1760,7 +1793,7 @@ describe('ChatView', () => {
})
const toggle = turnProcessControl(view.container)!
expect(toggle.getAttribute('aria-expanded')).toBe('false')
- expect(processRow.getAttribute('hidden')).toBe('until-found')
+ expect(processRow().getAttribute('hidden')).toBe('until-found')
})
it('switches completed Turns between the persisted Expanded and Compact modes', () => {
@@ -1770,18 +1803,21 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 5]]),
})
const view = render()
- const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
+ const processRow = () => view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
- expect(processRow.getAttribute('hidden')).toBe('until-found')
+ expect(processRow().getAttribute('hidden')).toBe('until-found')
act(() => { h.setTranscriptView('expanded') })
- expect(turnProcessControl(view.container)).toBeNull()
- expect(processRow.getAttribute('hidden')).toBeNull()
+ expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
+ expect(processRow().getAttribute('hidden')).toBe('until-found')
+ fireEvent.click(turnProcessControl(view.container)!)
+ expect(processRow().getAttribute('hidden')).toBeNull()
+ fireEvent.click(turnProcessControl(view.container)!)
act(() => { h.setTranscriptView('compact') })
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
- expect(processRow.getAttribute('hidden')).toBe('until-found')
+ expect(processRow().getAttribute('hidden')).toBe('until-found')
})
it('resets nested process disclosures when the whole Turn closes', () => {
@@ -1798,21 +1834,46 @@ describe('ChatView', () => {
expect(inner.getAttribute('aria-expanded')).toBe('false')
})
- it('switches secondary defaults between Detailed and Expanded and permits manual toggles', () => {
+ it('previews settled reasoning only in Detailed and Expanded work details', () => {
+ const final = {
+ ...assistant(3, 'answer', 1, 1),
+ blocks: [
+ { kind: 'reasoning' as const, text: '**Inspect the session**\nCheck persistence' },
+ { kind: 'text' as const, text: 'answer' },
+ ],
+ }
+ const h = makeHarness({ nodes: [user(1, 'question'), final], turnEnds: new Map([[1, 4]]) })
+ const view = render()
+ expect(view.queryByText('Inspect the session')).toBeNull()
+ for (const mode of ['detailed', 'expanded'] as const) {
+ act(() => { h.setTranscriptView(mode) })
+ expect(view.getByText('Inspect the session')).toBeTruthy()
+ expect(view.queryByText('Check persistence')).toBeNull()
+ }
+ act(() => { h.setTranscriptView('compact') })
+ expect(view.queryByText('Inspect the session')).toBeNull()
+ })
+
+ it('removes secondary groups in Expanded and restores collapsed groups in Detailed', () => {
const h = makeHarness({ nodes: [user(1, 'question'), context(2, 'work', 1), assistant(3, 'answer')],
turnEnds: new Map([[1, 4]]) })
const view = render()
act(() => { h.setTranscriptView('detailed') })
- expect(turnProcessControl(view.container)).toBeNull()
- const body = view.container.querySelector('[data-step-process-body]')!
- const toggle = view.container.querySelector('[data-step-process] > button')!
- expect(body.getAttribute('hidden')).toBe('until-found')
+ expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
+ expect(view.container.querySelector('[data-step-process-body]')?.getAttribute('hidden')).toBe('until-found')
act(() => { h.setTranscriptView('expanded') })
- expect(body.hasAttribute('hidden')).toBe(false)
- fireEvent.click(toggle)
- expect(body.getAttribute('hidden')).toBe('until-found')
+ expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
+ fireEvent.click(turnProcessControl(view.container)!)
+ expect(view.container.querySelector('[data-step-process]')).toBeNull()
+ expect(view.container.querySelector('[data-step-process-body]')).toBeNull()
+ const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]')!
+ expect(contextRow.parentElement?.hasAttribute('data-chat-flow')).toBe(true)
+ expect(contextRow.closest('[hidden]')).toBeNull()
+ expect(view.getByText('answer')).toBeTruthy()
act(() => { h.setTranscriptView('detailed') })
- fireEvent.click(toggle)
+ const body = view.container.querySelector('[data-step-process-body]')!
+ expect(body.getAttribute('hidden')).toBe('until-found')
+ fireEvent.click(view.container.querySelector('[data-step-process] > button')!)
expect(body.hasAttribute('hidden')).toBe(false)
})
diff --git a/packages/client/ui-chat/tests/step-process.client.spec.ts b/packages/client/ui-chat/tests/step-process.client.spec.ts
index f4bcd13361..bbb9bbf934 100644
--- a/packages/client/ui-chat/tests/step-process.client.spec.ts
+++ b/packages/client/ui-chat/tests/step-process.client.spec.ts
@@ -40,7 +40,9 @@ describe('process activity summary', () => {
it('reports the newest running nested call even when an older root is visited later', () => {
const root = { ...call('code', 'run_code'), subCalls: [{ ...call('read', 'read'), time: 3, argsRaw: '{' }] }
- expect(processActivity(nodes([root, { ...call('bash', 'bash'), time: 2 }])).running).toBe('read')
+ const summary = processActivity(nodes([root, { ...call('bash', 'bash'), time: 2 }]))
+ expect(summary.running).toBe('read')
+ expect(summary.runningDetail).toBe('')
})
it.each([
diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
index 932e942181..5346cd82b7 100644
--- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
@@ -176,7 +176,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:227',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:232',
},
{
key: 'conversation.chat.commandview',
@@ -224,7 +224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:215',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:220',
},
{
key: 'conversation.chat.node',
@@ -241,7 +241,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
},
],
ownerProps: [
- '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}',
+ '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Hide settled reasoning previews in Compact work details. */\n readonly compactTranscript?: boolean\n\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}',
],
ownerPropsReferences: [
'MarkdownFileMentions',
@@ -294,7 +294,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:196',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:201',
},
{
key: 'conversation.chat.turnTail',
@@ -1129,7 +1129,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:209',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:214',
},
{
key: 'conversation.plan-review.actions',