diff --git a/src/components/AIChat/chat/LiveFollowText.vue b/src/components/AIChat/chat/LiveFollowText.vue
new file mode 100644
index 00000000..4a2a182d
--- /dev/null
+++ b/src/components/AIChat/chat/LiveFollowText.vue
@@ -0,0 +1,61 @@
+
+
+
+
+ {{ props.text }}
+
+
diff --git a/src/components/AIChat/chat/chatMessageProcessSegments.test.ts b/src/components/AIChat/chat/chatMessageProcessSegments.test.ts
index 29e1cd6d..b1583869 100644
--- a/src/components/AIChat/chat/chatMessageProcessSegments.test.ts
+++ b/src/components/AIChat/chat/chatMessageProcessSegments.test.ts
@@ -2,8 +2,10 @@ import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
buildProcessSegments,
+ findRepresentativeProcessThought,
getProcessSegmentStatusLabel,
getVisibleSegmentBlocks,
+ resolveProcessHeaderActivity,
} from './chatMessageProcessSegments'
type TestBlock =
@@ -67,6 +69,30 @@ describe('chat message process segments', () => {
assert.deepEqual(getVisibleSegmentBlocks(segments), [blocks[1], blocks[6], blocks[7]])
})
+ it('keeps live thinking in the process row while the answer streams visibly', () => {
+ const blocks: TestBlock[] = [
+ { type: 'think', text: '先看这周在聊什么' },
+ { type: 'tool', name: 'search_messages' },
+ { type: 'text', text: '证据拿到了,我再核对统计。' },
+ ]
+
+ const segments = buildProcessSegments(blocks, { isFoldableProcessBlock, isTextBlock })
+
+ assert.deepEqual(segments, [
+ { type: 'process', blocks: blocks.slice(0, 2) },
+ { type: 'visible', block: blocks[2] },
+ ])
+ assert.deepEqual(getVisibleSegmentBlocks(segments), [blocks[2]])
+ })
+
+ it('uses natural-language thinking for the collapsed preview instead of plan JSON', () => {
+ const reasoning = { type: 'think' as const, tag: 'reasoning', text: '先梳理长期变化' }
+ const planValidation = { type: 'think' as const, tag: 'plan_validation', text: '{\n "title": "分析计划"' }
+
+ assert.equal(findRepresentativeProcessThought([reasoning, planValidation]), reasoning)
+ assert.equal(findRepresentativeProcessThought([planValidation]), undefined)
+ })
+
it('keeps text visible when no later process block exists', () => {
const blocks: TestBlock[] = [
{ type: 'tool', name: 'get_members' },
@@ -118,4 +144,23 @@ describe('chat message process segments', () => {
assert.equal(label, '已处理 1分05秒')
})
+
+ it('prefers the live tool over the last foldable block while processing', () => {
+ assert.deepEqual(
+ resolveProcessHeaderActivity({
+ isActive: true,
+ activeToolName: 'retrieve_chat_evidence',
+ activeToolProgressPhase: 'semantic_search',
+ lastFoldable: { kind: 'think' },
+ }),
+ { type: 'tool', name: 'retrieve_chat_evidence', progressPhase: 'semantic_search' }
+ )
+ assert.deepEqual(
+ resolveProcessHeaderActivity({
+ isActive: false,
+ lastFoldable: { kind: 'tool', name: 'search_messages' },
+ }),
+ { type: 'generic' }
+ )
+ })
})
diff --git a/src/components/AIChat/chat/chatMessageProcessSegments.ts b/src/components/AIChat/chat/chatMessageProcessSegments.ts
index 705ba768..d651d4c5 100644
--- a/src/components/AIChat/chat/chatMessageProcessSegments.ts
+++ b/src/components/AIChat/chat/chatMessageProcessSegments.ts
@@ -15,6 +15,12 @@ export interface ProcessSegmentStatusLabelOptions
{
locale: string
}
+interface ProcessThoughtCandidate {
+ type: string
+ tag?: string
+ text?: string
+}
+
function hasLaterFoldableProcessBlock(
blocks: T[],
startIndex: number,
@@ -53,6 +59,18 @@ export function getVisibleSegmentBlocks(segments: ProcessSegment[]): T[] {
return segments.flatMap((segment) => (segment.type === 'visible' ? [segment.block] : []))
}
+export function findRepresentativeProcessThought(
+ blocks: readonly T[]
+): Extract | undefined {
+ for (let index = blocks.length - 1; index >= 0; index -= 1) {
+ const block = blocks[index]
+ if (block.type === 'think' && block.tag?.toLowerCase() !== 'plan_validation' && Boolean(block.text?.trim())) {
+ return block as Extract
+ }
+ }
+ return undefined
+}
+
export function formatProcessDuration(durationMs: number, locale: string): string {
const totalSeconds = Math.max(0, Math.round(durationMs / 1000))
if (locale.startsWith('zh') || locale.startsWith('ja')) {
@@ -98,3 +116,43 @@ export function getProcessSegmentStatusLabel(
const duration = durationMs > 0 ? ` ${formatProcessDuration(durationMs, options.locale)}` : ''
return `${options.labels.processed}${duration}`
}
+
+export type ProcessHeaderActivity =
+ | { type: 'tool'; name: string; progressPhase?: string }
+ | { type: 'think' }
+ | { type: 'skill'; name: string }
+ | { type: 'plan' }
+ | { type: 'generic' }
+
+export function resolveProcessHeaderActivity(input: {
+ isActive: boolean
+ activeToolName?: string
+ activeToolProgressPhase?: string
+ lastFoldable?: {
+ kind: 'tool' | 'think' | 'skill' | 'plan' | 'plan_draft' | 'error'
+ name?: string
+ }
+}): ProcessHeaderActivity {
+ if (input.isActive && input.activeToolName) {
+ return {
+ type: 'tool',
+ name: input.activeToolName,
+ progressPhase: input.activeToolProgressPhase,
+ }
+ }
+
+ if (input.isActive && input.lastFoldable) {
+ if (input.lastFoldable.kind === 'think') return { type: 'think' }
+ if (input.lastFoldable.kind === 'skill' && input.lastFoldable.name) {
+ return { type: 'skill', name: input.lastFoldable.name }
+ }
+ if (input.lastFoldable.kind === 'plan' || input.lastFoldable.kind === 'plan_draft') {
+ return { type: 'plan' }
+ }
+ if (input.lastFoldable.kind === 'tool' && input.lastFoldable.name) {
+ return { type: 'tool', name: input.lastFoldable.name }
+ }
+ }
+
+ return { type: 'generic' }
+}
diff --git a/src/components/AIChat/chat/liveFollowText.test.ts b/src/components/AIChat/chat/liveFollowText.test.ts
new file mode 100644
index 00000000..28b7fb8e
--- /dev/null
+++ b/src/components/AIChat/chat/liveFollowText.test.ts
@@ -0,0 +1,22 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+import { getFirstLine, getLatestLine } from './liveFollowText'
+
+describe('live follow text', () => {
+ it('uses the first line after thinking settles', () => {
+ assert.equal(getFirstLine('Inspect the session\nNewest reasoning tokens'), 'Inspect the session')
+ assert.equal(getFirstLine('one-liner'), 'one-liner')
+ })
+
+ it('follows the latest streaming line, including a line that is still growing', () => {
+ assert.equal(getLatestLine('Inspect the session\nNewest reasoning tokens'), 'Newest reasoning tokens')
+ assert.equal(
+ getLatestLine('Inspect the session\nNewest reasoning tokens keep arriving'),
+ 'Newest reasoning tokens keep arriving'
+ )
+ assert.equal(
+ getLatestLine('Inspect the session\nNewest reasoning tokens keep arriving\n'),
+ 'Newest reasoning tokens keep arriving'
+ )
+ })
+})
diff --git a/src/components/AIChat/chat/liveFollowText.ts b/src/components/AIChat/chat/liveFollowText.ts
new file mode 100644
index 00000000..58c92768
--- /dev/null
+++ b/src/components/AIChat/chat/liveFollowText.ts
@@ -0,0 +1,10 @@
+export function getFirstLine(text: string): string {
+ const newline = text.indexOf('\n')
+ return newline === -1 ? text : text.slice(0, newline)
+}
+
+export function getLatestLine(text: string): string {
+ const visible = text.trimEnd()
+ const newline = visible.lastIndexOf('\n')
+ return newline === -1 ? visible : visible.slice(newline + 1)
+}
diff --git a/src/components/AIChat/input/AIChatComposer.vue b/src/components/AIChat/input/AIChatComposer.vue
index 8fc7361a..7190c7b9 100644
--- a/src/components/AIChat/input/AIChatComposer.vue
+++ b/src/components/AIChat/input/AIChatComposer.vue
@@ -82,11 +82,10 @@ defineExpose({ focus, getSelection, setSelectionRange, syncHeight })
embedded
? 'bg-transparent'
: 'rounded-2xl bg-white shadow-[0_2px_14px_rgba(0,0,0,0.04)] ring-1 ring-gray-200/60 dark:bg-page-dark dark:ring-white/5',
- disabled
- ? 'bg-gray-50/50 dark:bg-page-dark/50'
- : embedded
- ? ''
- : 'focus-within:ring-primary-500/40 focus-within:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:focus-within:ring-primary-500/40',
+ !embedded && disabled ? 'bg-gray-50/50 dark:bg-page-dark/50' : '',
+ !embedded && !disabled
+ ? 'focus-within:ring-primary-500/40 focus-within:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:focus-within:ring-primary-500/40'
+ : '',
]"
>
diff --git a/src/i18n/locales/en-US/ai.json b/src/i18n/locales/en-US/ai.json
index ee1fda4b..721a6660 100644
--- a/src/i18n/locales/en-US/ai.json
+++ b/src/i18n/locales/en-US/ai.json
@@ -46,6 +46,9 @@
"label": "Process",
"processing": "Processing",
"processed": "Processed",
+ "working": "Working",
+ "planning": "Planning",
+ "failed": "Incomplete",
"step": "{count} step",
"steps": "{count} steps"
},
diff --git a/src/i18n/locales/ja-JP/ai.json b/src/i18n/locales/ja-JP/ai.json
index 325b532f..8bfab08c 100644
--- a/src/i18n/locales/ja-JP/ai.json
+++ b/src/i18n/locales/ja-JP/ai.json
@@ -46,6 +46,9 @@
"label": "実行過程",
"processing": "処理中",
"processed": "処理済み",
+ "working": "処理中",
+ "planning": "計画中",
+ "failed": "未完了",
"step": "{count} ステップ",
"steps": "{count} ステップ"
},
diff --git a/src/i18n/locales/zh-CN/ai.json b/src/i18n/locales/zh-CN/ai.json
index 279db1a8..7c40bafd 100644
--- a/src/i18n/locales/zh-CN/ai.json
+++ b/src/i18n/locales/zh-CN/ai.json
@@ -46,6 +46,9 @@
"label": "执行过程",
"processing": "处理中",
"processed": "已处理",
+ "working": "正在处理",
+ "planning": "正在规划",
+ "failed": "未完成",
"step": "{count} 步",
"steps": "{count} 步"
},
diff --git a/src/i18n/locales/zh-TW/ai.json b/src/i18n/locales/zh-TW/ai.json
index ccc80060..624d6c30 100644
--- a/src/i18n/locales/zh-TW/ai.json
+++ b/src/i18n/locales/zh-TW/ai.json
@@ -46,6 +46,9 @@
"label": "執行過程",
"processing": "處理中",
"processed": "已處理",
+ "working": "正在處理",
+ "planning": "正在規劃",
+ "failed": "未完成",
"step": "{count} 步",
"steps": "{count} 步"
},