@steerable/agent-shell-web 0.6.27 → 0.6.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/package.json +4 -4
  2. package/src/components/AgentSidebar.tsx +27 -27
  3. package/src/components/BrandLockup.tsx +20 -4
  4. package/src/components/ChatHeader.tsx +5 -15
  5. package/src/components/LocalLlmSettingsModal.tsx +5 -5
  6. package/src/components/TerminalPanel.tsx +1 -1
  7. package/src/components/chat/ApprovalModal.tsx +5 -5
  8. package/src/components/chat/AskUserQuestionMenu.tsx +13 -13
  9. package/src/components/chat/AssistantMessage.tsx +122 -36
  10. package/src/components/chat/ChatInput.tsx +34 -26
  11. package/src/components/chat/ExecPolicyPicker.tsx +4 -4
  12. package/src/components/chat/ExecutedActionsCard.tsx +398 -29
  13. package/src/components/chat/FilePathCode.tsx +2 -1
  14. package/src/components/chat/LocalChatPanel.tsx +42 -11
  15. package/src/components/chat/Markdown.tsx +15 -14
  16. package/src/components/chat/MessageList.tsx +23 -5
  17. package/src/components/chat/ModelPicker.tsx +6 -6
  18. package/src/components/chat/OrchestrationChildrenCard.tsx +69 -5
  19. package/src/components/chat/SessionTodoList.tsx +234 -0
  20. package/src/components/chat/SessionTreeModal.tsx +3 -3
  21. package/src/components/chat/SuggestedReplies.tsx +14 -5
  22. package/src/components/chat/TaskPanelModal.tsx +3 -3
  23. package/src/components/chat/TaskProcessPanel.tsx +67 -21
  24. package/src/components/chat/TurnFilesCard.tsx +2 -2
  25. package/src/components/chat/TurnProcessGroup.tsx +252 -99
  26. package/src/components/chat/UserMessage.tsx +7 -7
  27. package/src/components/chat/executed-actions-model.ts +469 -0
  28. package/src/components/chat/next-steps-tags.ts +28 -0
  29. package/src/components/chat/orchestration-children-model.ts +120 -0
  30. package/src/components/chat/process-status.ts +125 -53
  31. package/src/components/chat/todo-list-model.ts +167 -0
  32. package/src/components/chat/turn-timeline.ts +123 -11
  33. package/src/components/settings/AgentsSettingsPanel.tsx +2 -2
  34. package/src/components/settings/AppearanceSettingsPanel.tsx +3 -3
  35. package/src/components/settings/DiagnoseSettingsPanel.tsx +2 -2
  36. package/src/components/settings/InsightsSettingsPanel.tsx +4 -4
  37. package/src/components/settings/LlmSettingsPanel.tsx +10 -10
  38. package/src/components/settings/McpSettingsPanel.tsx +3 -3
  39. package/src/components/settings/ModelIdCombobox.tsx +2 -2
  40. package/src/components/settings/SecuritySettingsPanel.tsx +1 -1
  41. package/src/components/settings/SettingsSaveButton.tsx +1 -1
  42. package/src/components/settings/SkillsSettingsPanel.tsx +2 -2
  43. package/src/components/settings/TelemetrySettingsPanel.tsx +2 -2
  44. package/src/components/settings/UsagePanel.tsx +14 -10
  45. package/src/components/settings/WebSearchSettingsPanel.tsx +2 -2
  46. package/src/layouts/AgentLayout.tsx +3 -3
  47. package/src/lib/chat-transport.ts +68 -12
  48. package/src/lib/local-api.ts +11 -0
  49. package/src/lib/show-thinking-content.ts +4 -4
  50. package/src/pages/AgentPage.tsx +82 -11
  51. package/src/pages/ChatPanelPreviewPage.tsx +26 -4
  52. package/src/pages/SettingsPage.tsx +39 -39
  53. package/src/styles/index.css +12 -2
@@ -1,16 +1,23 @@
1
1
  /**
2
- * Turn-process status line: live 「思考中 / 调用工具中」 plus finished
3
- * 「N 次工具调用 · 已思考 · 工作了 …」. Token speed is a coarse estimate
4
- * from reasoning text (same CJK/other weights as the desktop compactor).
2
+ * Work-row summary vs per-thinking-fold live stats.
3
+ * Footer tok/s is the model HTTP-stream generation speed across every
4
+ * output stage (reasoning + reply), excluding tool-wait gaps.
5
5
  */
6
6
 
7
7
  import { formatElapsedCompact } from './elapsed';
8
8
  import {
9
+ countProcessReasoning,
9
10
  countProcessTools,
10
- processHasReasoning,
11
11
  type TurnBlock,
12
12
  } from './turn-timeline';
13
13
 
14
+ /** Fallback thinking duration for legacy timelines that never stored durationMs. */
15
+ export function estimateReasoningDurationMs(content: string): number | undefined {
16
+ const tokens = estimateTextTokens(content);
17
+ if (tokens < 4) return undefined;
18
+ return Math.max(1000, Math.round((tokens / 35) * 1000));
19
+ }
20
+
14
21
  export function estimateTextTokens(text: string): number {
15
22
  if (!text) return 0;
16
23
  let cjk = 0;
@@ -35,22 +42,6 @@ export function formatTokenSpeed(tokens: number, elapsedMs: number): string | nu
35
42
  return `${shown} tok/s`;
36
43
  }
37
44
 
38
- export function estimateProcessReasoningTokens(process: TurnBlock[]): number {
39
- let tokens = 0;
40
- for (const block of process) {
41
- if (block.type === 'reasoning') tokens += estimateTextTokens(block.content);
42
- }
43
- return tokens;
44
- }
45
-
46
- function speedElapsedMs(
47
- reasoningElapsedMs: number | undefined,
48
- elapsedMs: number | undefined,
49
- ): number {
50
- if (reasoningElapsedMs != null && reasoningElapsedMs >= 400) return reasoningElapsedMs;
51
- return elapsedMs ?? reasoningElapsedMs ?? 0;
52
- }
53
-
54
45
  export function lastToolsAreRunning(process: TurnBlock[]): boolean {
55
46
  const last = process[process.length - 1];
56
47
  if (last?.type !== 'tools' || last.actions.length === 0) return false;
@@ -69,54 +60,135 @@ export function activeToolNames(process: TurnBlock[]): string[] {
69
60
  }
70
61
 
71
62
  function formatElapsedPart(
72
- isStreaming: boolean,
63
+ isLive: boolean,
73
64
  elapsedMs: number | undefined,
74
65
  ): string | null {
75
66
  if (elapsedMs == null) return null;
76
- if (!isStreaming && elapsedMs < 1000) return null;
67
+ if (!isLive && elapsedMs < 1000) return null;
77
68
  return formatElapsedCompact(elapsedMs);
78
69
  }
79
70
 
71
+ /** Work disclosure: live is only 「工作中」; finished is a counts + duration summary. */
80
72
  export function processStatusLabel(input: {
81
73
  process: TurnBlock[];
82
74
  isStreaming: boolean;
83
75
  elapsedMs?: number;
84
- reasoningElapsedMs?: number;
85
76
  }): string {
86
- const { process, isStreaming, elapsedMs, reasoningElapsedMs } = input;
87
- const elapsed = formatElapsedPart(isStreaming, elapsedMs);
88
-
89
- if (isStreaming) {
90
- const parts: string[] = [];
91
- const last = process[process.length - 1];
92
- if (lastToolsAreRunning(process)) {
93
- parts.push('调用工具中');
94
- const names = activeToolNames(process);
95
- if (names.length > 0) parts.push(names.join('、'));
96
- } else {
97
- parts.push('思考中');
98
- const tokens =
99
- last?.type === 'reasoning'
100
- ? estimateTextTokens(last.content)
101
- : estimateProcessReasoningTokens(process);
102
- const speed = formatTokenSpeed(
103
- tokens,
104
- speedElapsedMs(
105
- last?.type === 'reasoning' ? reasoningElapsedMs : undefined,
106
- elapsedMs,
107
- ),
108
- );
109
- if (speed) parts.push(speed);
110
- }
111
- if (elapsed) parts.push(elapsed);
112
- return parts.join(' · ');
113
- }
77
+ const { process, isStreaming, elapsedMs } = input;
78
+ if (isStreaming) return '工作中';
114
79
 
115
80
  const parts: string[] = [];
81
+ const thinks = countProcessReasoning(process);
116
82
  const tools = countProcessTools(process);
117
- if (tools > 0) parts.push(`${tools} 次工具调用`);
118
- if (processHasReasoning(process)) parts.push('已思考');
83
+ const elapsed = formatElapsedPart(false, elapsedMs);
119
84
  if (elapsed) parts.push(`工作了 ${elapsed}`);
85
+ if (thinks > 0) parts.push(`思考 ${thinks} 次`);
86
+ if (tools > 0) parts.push(`工具调用 ${tools} 次`);
120
87
  if (parts.length === 0) return '执行过程';
121
88
  return parts.join(' · ');
122
89
  }
90
+
91
+ /** Per-round 思考 fold: live time, or frozen duration after that round ends. */
92
+ export function thinkingFoldLabel(input: {
93
+ content: string;
94
+ isLive: boolean;
95
+ elapsedMs?: number;
96
+ }): string {
97
+ const elapsed = formatElapsedPart(input.isLive, input.elapsedMs);
98
+ if (input.isLive) {
99
+ return elapsed ? `思考中 · ${elapsed}` : '思考中';
100
+ }
101
+ return elapsed ? `思考 · ${elapsed}` : '思考';
102
+ }
103
+
104
+ /** Reasoning + reply tokens for the message-footer tok/s. */
105
+ export function estimateTurnTokens(blocks: TurnBlock[], fallbackContent = ''): number {
106
+ if (blocks.length === 0) return estimateTextTokens(fallbackContent);
107
+ let tokens = 0;
108
+ for (const block of blocks) {
109
+ if (block.type === 'reasoning' || block.type === 'text') {
110
+ tokens += estimateTextTokens(block.content);
111
+ }
112
+ }
113
+ return tokens;
114
+ }
115
+
116
+ /** Snapshot of model-request generation speed (all LLM output stages). */
117
+ export type LlmSpeedSnapshot = {
118
+ tokens: number;
119
+ elapsedMs: number;
120
+ live: boolean;
121
+ closedMs: number;
122
+ requestStartedAt: number | null;
123
+ };
124
+
125
+ export function parseLlmSpeedPayload(payload: unknown): LlmSpeedSnapshot | null {
126
+ if (!payload || typeof payload !== 'object') return null;
127
+ const value = payload as Record<string, unknown>;
128
+ if (typeof value.tokens !== 'number' || typeof value.elapsedMs !== 'number') {
129
+ return null;
130
+ }
131
+ return {
132
+ tokens: value.tokens,
133
+ elapsedMs: value.elapsedMs,
134
+ live: value.live === true,
135
+ closedMs: typeof value.closedMs === 'number' ? value.closedMs : value.elapsedMs,
136
+ requestStartedAt:
137
+ typeof value.requestStartedAt === 'number' ? value.requestStartedAt : null,
138
+ };
139
+ }
140
+
141
+ export function llmRequestElapsedMs(
142
+ snap: LlmSpeedSnapshot | null | undefined,
143
+ now: number,
144
+ ): number {
145
+ if (!snap) return 0;
146
+ if (snap.live && snap.requestStartedAt != null) {
147
+ return snap.closedMs + Math.max(0, now - snap.requestStartedAt);
148
+ }
149
+ return snap.elapsedMs;
150
+ }
151
+
152
+ /**
153
+ * Accumulates tokens + elapsed only while the model is streaming.
154
+ * `endRequest` freezes the current burst so tool waits do not dilute tok/s.
155
+ */
156
+ export class LlmRequestSpeedTracker {
157
+ private requestStartedAt: number | null = null;
158
+ private requestText = '';
159
+ private closedText = '';
160
+ private closedMs = 0;
161
+
162
+ noteOutput(delta: string, now = Date.now()): LlmSpeedSnapshot {
163
+ if (delta) {
164
+ if (this.requestStartedAt == null) this.requestStartedAt = now;
165
+ this.requestText += delta;
166
+ }
167
+ return this.snapshot(now);
168
+ }
169
+
170
+ endRequest(now = Date.now()): LlmSpeedSnapshot {
171
+ if (this.requestStartedAt != null) {
172
+ this.closedText += this.requestText;
173
+ this.closedMs += Math.max(0, now - this.requestStartedAt);
174
+ this.requestStartedAt = null;
175
+ this.requestText = '';
176
+ }
177
+ return this.snapshot(now);
178
+ }
179
+
180
+ snapshot(now = Date.now()): LlmSpeedSnapshot {
181
+ const tokens = estimateTextTokens(this.closedText + this.requestText);
182
+ let elapsedMs = this.closedMs;
183
+ if (this.requestStartedAt != null) {
184
+ elapsedMs += Math.max(0, now - this.requestStartedAt);
185
+ }
186
+ return {
187
+ tokens,
188
+ elapsedMs,
189
+ live: this.requestStartedAt != null,
190
+ closedMs: this.closedMs,
191
+ requestStartedAt: this.requestStartedAt,
192
+ };
193
+ }
194
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Session task list — the UI half of `todo_write`.
3
+ *
4
+ * The tool is full-replace: each call carries the complete list. The latest
5
+ * successful rewrite (or the in-flight arguments, before the result lands)
6
+ * is the session header; older calls in the transcript stay as snapshots.
7
+ */
8
+
9
+ import type { ExecutedAction } from './ExecutedActionsCard';
10
+ import type { TurnBlock } from './turn-timeline';
11
+
12
+ export const TODO_STATUSES = ['pending', 'in_progress', 'completed'] as const;
13
+ export type TodoStatus = (typeof TODO_STATUSES)[number];
14
+
15
+ export interface SessionTodo {
16
+ id: string;
17
+ content: string;
18
+ status: TodoStatus;
19
+ }
20
+
21
+ export interface SessionTodoSummary {
22
+ total: number;
23
+ pending: number;
24
+ inProgress: number;
25
+ completed: number;
26
+ }
27
+
28
+ const STATUS_SET = new Set<string>(TODO_STATUSES);
29
+
30
+ function asObject(value: unknown): Record<string, unknown> | null {
31
+ if (typeof value === 'string') {
32
+ const trimmed = value.trim();
33
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null;
34
+ try {
35
+ return asObject(JSON.parse(trimmed) as unknown);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
41
+ return value as Record<string, unknown>;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ function coerceTodos(raw: unknown): SessionTodo[] | null {
47
+ if (!Array.isArray(raw) || raw.length === 0) return null;
48
+ const todos: SessionTodo[] = [];
49
+ for (const item of raw) {
50
+ if (!item || typeof item !== 'object') return null;
51
+ const row = item as Record<string, unknown>;
52
+ const id = typeof row.id === 'string' ? row.id : '';
53
+ const content = typeof row.content === 'string' ? row.content.trim() : '';
54
+ const status = row.status;
55
+ if (!id || !content || typeof status !== 'string' || !STATUS_SET.has(status)) {
56
+ return null;
57
+ }
58
+ todos.push({ id, content, status: status as TodoStatus });
59
+ }
60
+ return todos.length > 0 ? todos : null;
61
+ }
62
+
63
+ /** Pull a todos array out of args, a tool result, or the sidecar envelope. */
64
+ export function parseTodosPayload(value: unknown): SessionTodo[] | null {
65
+ if (Array.isArray(value)) return coerceTodos(value);
66
+ const obj = asObject(value);
67
+ if (!obj) return null;
68
+ if (Array.isArray(obj.todos)) return coerceTodos(obj.todos);
69
+ const data = asObject(obj.data);
70
+ if (data) {
71
+ if (Array.isArray(data.todos)) return coerceTodos(data.todos);
72
+ const inner = asObject(data.value);
73
+ if (inner && Array.isArray(inner.todos)) return coerceTodos(inner.todos);
74
+ }
75
+ const wrapped = asObject(obj.value);
76
+ if (wrapped && Array.isArray(wrapped.todos)) return coerceTodos(wrapped.todos);
77
+ return null;
78
+ }
79
+
80
+ export function extractTodosFromAction(action: {
81
+ tool: string;
82
+ arguments?: unknown;
83
+ result?: unknown;
84
+ }): SessionTodo[] | null {
85
+ if (action.tool !== 'todo_write') return null;
86
+ return parseTodosPayload(action.result) ?? parseTodosPayload(action.arguments);
87
+ }
88
+
89
+ export function summarizeTodos(todos: SessionTodo[]): SessionTodoSummary {
90
+ const summary: SessionTodoSummary = {
91
+ total: todos.length,
92
+ pending: 0,
93
+ inProgress: 0,
94
+ completed: 0,
95
+ };
96
+ for (const todo of todos) {
97
+ if (todo.status === 'pending') summary.pending += 1;
98
+ else if (todo.status === 'in_progress') summary.inProgress += 1;
99
+ else summary.completed += 1;
100
+ }
101
+ return summary;
102
+ }
103
+
104
+ export function summarizeTodoWriteAction(
105
+ tool: string,
106
+ args: unknown,
107
+ result: unknown,
108
+ ): string | null {
109
+ if (tool !== 'todo_write') return null;
110
+ const todos = extractTodosFromAction({ tool, arguments: args, result });
111
+ if (!todos) return '任务清单';
112
+ const { completed, total, inProgress } = summarizeTodos(todos);
113
+ const current = todos.find((todo) => todo.status === 'in_progress');
114
+ if (inProgress > 0 && current) {
115
+ const label =
116
+ current.content.length > 28 ? `${current.content.slice(0, 25)}…` : current.content;
117
+ return `任务清单 ${completed}/${total} · ${label}`;
118
+ }
119
+ if (completed === total) return `任务清单 ${completed}/${total} 已完成`;
120
+ return `任务清单 ${completed}/${total}`;
121
+ }
122
+
123
+ export function latestTodosFromActions(
124
+ actions: Array<{ tool: string; arguments?: unknown; result?: unknown }> | undefined,
125
+ ): SessionTodo[] | null {
126
+ if (!actions || actions.length === 0) return null;
127
+ for (let i = actions.length - 1; i >= 0; i -= 1) {
128
+ const todos = extractTodosFromAction(actions[i]);
129
+ if (todos) return todos;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function actionsFromTimeline(blocks: TurnBlock[] | undefined): ExecutedAction[] {
135
+ if (!blocks) return [];
136
+ const actions: ExecutedAction[] = [];
137
+ for (const block of blocks) {
138
+ if (block.type === 'tools') actions.push(...block.actions);
139
+ }
140
+ return actions;
141
+ }
142
+
143
+ /**
144
+ * Newest `todo_write` list for the open chat. Live turn first, then
145
+ * historical actions / timelines walking messages newest-first.
146
+ */
147
+ export function resolveLatestSessionTodos(input: {
148
+ messages: Array<{ id: string }>;
149
+ executedActionsByMessageId?: Record<string, ExecutedAction[]>;
150
+ currentTurnActions?: ExecutedAction[];
151
+ timelineByMessageId?: Record<string, TurnBlock[]>;
152
+ currentTurnTimeline?: TurnBlock[];
153
+ }): SessionTodo[] | null {
154
+ const live =
155
+ latestTodosFromActions(input.currentTurnActions) ??
156
+ latestTodosFromActions(actionsFromTimeline(input.currentTurnTimeline));
157
+ if (live) return live;
158
+
159
+ for (let i = input.messages.length - 1; i >= 0; i -= 1) {
160
+ const id = input.messages[i].id;
161
+ const historical =
162
+ latestTodosFromActions(input.executedActionsByMessageId?.[id]) ??
163
+ latestTodosFromActions(actionsFromTimeline(input.timelineByMessageId?.[id]));
164
+ if (historical) return historical;
165
+ }
166
+ return null;
167
+ }
@@ -11,35 +11,114 @@
11
11
  import type { ExecutedAction } from './ExecutedActionsCard';
12
12
 
13
13
  export type TurnBlock =
14
- | { type: 'reasoning'; content: string }
15
- | { type: 'text'; content: string }
14
+ | {
15
+ type: 'reasoning';
16
+ content: string;
17
+ sealed?: boolean;
18
+ startedAtMs?: number;
19
+ durationMs?: number;
20
+ }
21
+ | { type: 'text'; content: string; sealed?: boolean }
16
22
  | { type: 'tools'; actions: ExecutedAction[] };
17
23
 
24
+ /**
25
+ * 内容指纹:只要屏上会多出/变化一行,它就变。过程面板用它决定「要不要把
26
+ * 视口重新钉到底部」。
27
+ *
28
+ * 只数文本长度与工具条数是不够的:工具结果落回时条数不变、状态却从
29
+ * 「执行中」变「已完成」并展开出摘要,高度会长——那种增长必须也算变化,
30
+ * 否则跟随会在最常见的一种增长上失灵。
31
+ *
32
+ * @param blocks 当前时间线。
33
+ * @returns 稳定的内容指纹。
34
+ */
35
+ export function timelineContentSignature(blocks: readonly TurnBlock[]): string {
36
+ return blocks
37
+ .map((block) => {
38
+ if (block.type !== 'tools') return `c${block.content.length}`;
39
+ const rows = block.actions
40
+ .map((action) => `${action.tool}:${action.result === undefined ? 0 : 1}`)
41
+ .join(',');
42
+ return `t${block.actions.length}[${rows}]`;
43
+ })
44
+ .join('|');
45
+ }
46
+
47
+ function freezeReasoningBlock(
48
+ block: Extract<TurnBlock, { type: 'reasoning' }>,
49
+ now: number,
50
+ ): Extract<TurnBlock, { type: 'reasoning' }> {
51
+ if (block.durationMs != null || block.startedAtMs == null) return block;
52
+ return { ...block, durationMs: Math.max(0, now - block.startedAtMs) };
53
+ }
54
+
55
+ /** Stamp duration on a trailing open reasoning block so the fold can keep 思考时间. */
56
+ export function freezeReasoningDurations(
57
+ blocks: TurnBlock[],
58
+ now = Date.now(),
59
+ ): TurnBlock[] {
60
+ if (blocks.length === 0) return blocks;
61
+ const last = blocks[blocks.length - 1];
62
+ if (last.type !== 'reasoning') return blocks;
63
+ const frozen = freezeReasoningBlock(last, now);
64
+ if (frozen === last) return blocks;
65
+ const next = blocks.slice();
66
+ next[next.length - 1] = frozen;
67
+ return next;
68
+ }
69
+
18
70
  export function appendDelta(
19
71
  blocks: TurnBlock[],
20
72
  type: 'text' | 'reasoning',
21
73
  delta: string,
74
+ now = Date.now(),
22
75
  ): TurnBlock[] {
23
76
  if (!delta) return blocks;
24
77
  const last = blocks[blocks.length - 1];
25
- if (last && last.type === type) {
78
+ if (last && last.type === type && !last.sealed) {
26
79
  const next = blocks.slice();
27
- next[next.length - 1] = { type, content: last.content + delta };
80
+ next[next.length - 1] =
81
+ last.type === 'reasoning'
82
+ ? { ...last, content: last.content + delta }
83
+ : { type, content: last.content + delta };
28
84
  return next;
29
85
  }
30
- return [...blocks, { type, content: delta }];
86
+ const frozen = freezeReasoningDurations(blocks, now);
87
+ if (type === 'reasoning') {
88
+ return [...frozen, { type, content: delta, startedAtMs: now }];
89
+ }
90
+ return [...frozen, { type, content: delta }];
91
+ }
92
+
93
+ /**
94
+ * Close the current reasoning/text segment so the next same-kind delta
95
+ * starts a new block. Used at LLM-round boundaries (tool round finished,
96
+ * hook retry, auto-continue) — otherwise consecutive rounds concatenate
97
+ * into one wall of thought and hide the intermediate results between them.
98
+ */
99
+ export function sealLastBlock(blocks: TurnBlock[], now = Date.now()): TurnBlock[] {
100
+ if (blocks.length === 0) return blocks;
101
+ const last = blocks[blocks.length - 1];
102
+ if (last.type === 'tools' || last.sealed) return blocks;
103
+ const next = blocks.slice();
104
+ next[next.length - 1] =
105
+ last.type === 'reasoning'
106
+ ? { ...freezeReasoningBlock(last, now), sealed: true }
107
+ : { ...last, sealed: true };
108
+ return next;
31
109
  }
32
110
 
33
111
  export function syncTools(
34
112
  blocks: TurnBlock[],
35
113
  actions: ExecutedAction[],
114
+ now = Date.now(),
36
115
  ): TurnBlock[] {
37
116
  let placed = 0;
38
117
  for (const block of blocks) {
39
118
  if (block.type === 'tools') placed += block.actions.length;
40
119
  }
41
120
 
42
- const next: TurnBlock[] = blocks.map((block) =>
121
+ const next: TurnBlock[] = freezeReasoningDurations(blocks, now).map((block) =>
43
122
  block.type === 'tools' ? { type: 'tools', actions: [...block.actions] } : block,
44
123
  );
45
124
 
@@ -89,7 +168,25 @@ export function parseTurnBlocks(raw: unknown): TurnBlock[] | null {
89
168
  const rec = item as Record<string, unknown>;
90
169
  if (rec.type === 'text' || rec.type === 'reasoning') {
91
170
  if (typeof rec.content !== 'string') return null;
92
- blocks.push({ type: rec.type, content: rec.content });
171
+ const block: TurnBlock =
172
+ rec.type === 'reasoning'
173
+ ? {
174
+ type: 'reasoning',
175
+ content: rec.content,
176
+ ...(rec.sealed === true ? { sealed: true } : {}),
177
+ ...(typeof rec.startedAtMs === 'number' && Number.isFinite(rec.startedAtMs)
178
+ ? { startedAtMs: rec.startedAtMs }
179
+ : {}),
180
+ ...(typeof rec.durationMs === 'number' && Number.isFinite(rec.durationMs)
181
+ ? { durationMs: rec.durationMs }
182
+ : {}),
183
+ }
184
+ : {
185
+ type: 'text',
186
+ content: rec.content,
187
+ ...(rec.sealed === true ? { sealed: true } : {}),
188
+ };
189
+ blocks.push(block);
93
190
  continue;
94
191
  }
95
192
  if (rec.type === 'tools') {
@@ -103,14 +200,21 @@ export function parseTurnBlocks(raw: unknown): TurnBlock[] | null {
103
200
  }
104
201
 
105
202
  /**
106
- * Trailing text is the final summary. Reasoning, tools, and any narration
107
- * that happened before that last answer stay in the foldable process group
108
- * (Codex / DeepSeek turn-process).
203
+ * Trailing text is the final conclusion — but only after the turn finishes.
204
+ * While streaming, every text block stays in the process as a post-think
205
+ * response so the next reasoning burst cannot restyle it as thinking.
109
206
  */
110
- export function splitTurnProcess(blocks: TurnBlock[]): {
207
+ export function splitTurnProcess(
208
+ blocks: TurnBlock[],
209
+ options?: { finalize?: boolean },
210
+ ): {
111
211
  process: TurnBlock[];
112
212
  answer: Extract<TurnBlock, { type: 'text' }>[];
113
213
  } {
214
+ const finalize = options?.finalize ?? true;
215
+ if (!finalize) {
216
+ return { process: blocks, answer: [] };
217
+ }
114
218
  let split = blocks.length;
115
219
  while (split > 0 && blocks[split - 1].type === 'text') split -= 1;
116
220
  return {
@@ -127,6 +231,14 @@ export function countProcessTools(process: TurnBlock[]): number {
127
231
  return n;
128
232
  }
129
233
 
234
+ export function countProcessReasoning(process: TurnBlock[]): number {
235
+ let n = 0;
236
+ for (const block of process) {
237
+ if (block.type === 'reasoning' && block.content.trim().length > 0) n += 1;
238
+ }
239
+ return n;
240
+ }
241
+
130
242
  export function processHasReasoning(process: TurnBlock[]): boolean {
131
243
  return process.some((block) => block.type === 'reasoning' && block.content.trim().length > 0);
132
244
  }
@@ -200,14 +200,14 @@ export function AgentsSettingsPanel({
200
200
  };
201
201
 
202
202
  return (
203
- <div className="space-y-4">
203
+ <div className="space-y-3">
204
204
  <p className="text-[11px] text-agent-muted-foreground">
205
205
  自定义智能体会出现在输入框上方的专家选择器里。角色设定会作为对话的人设前言,
206
206
  勾选的技能与工具权限在每一轮真实生效。内置智能体可以改文案和能力,但不能删除。
207
207
  </p>
208
208
 
209
209
  {formOpen ? (
210
- <div className="space-y-2.5 rounded-agent-md border border-agent-border/60 bg-agent-muted/30 p-3.5">
210
+ <div className="space-y-2 rounded-agent-md border border-agent-border/60 bg-agent-muted/30 p-2.5">
211
211
  <h4 className="text-xs font-semibold text-agent-foreground">
212
212
  {editingId ? '编辑智能体' : '新建智能体'}
213
213
  </h4>
@@ -14,13 +14,13 @@ export function AppearanceSettingsPanel() {
14
14
 
15
15
  return (
16
16
  <div
17
- className="space-y-3 rounded-agent-md border border-agent-border bg-agent-card p-4"
17
+ className="space-y-2 rounded-agent-md border border-agent-border bg-agent-card p-2.5"
18
18
  data-testid="appearance-settings-panel"
19
19
  >
20
20
  <div>
21
- <p className="text-sm font-medium text-agent-foreground">显示思考内容</p>
21
+ <p className="text-xs font-medium text-agent-foreground">显示思考内容</p>
22
22
  <p className="mt-1 text-xs leading-relaxed text-agent-muted-foreground">
23
- {current?.hint} 状态行仍显示思考中、工具名、速度和用时。
23
+ {current?.hint} 这三档只控制思考正文,不影响工具卡片和工作行。工作行只显示「工作中」或结束后的次数与用时。
24
24
  </p>
25
25
  </div>
26
26
  <div
@@ -27,7 +27,7 @@ export function DiagnoseSettingsPanel() {
27
27
  };
28
28
 
29
29
  return (
30
- <div className="space-y-3 rounded-agent-md border border-agent-border bg-agent-card p-4">
30
+ <div className="space-y-2 rounded-agent-md border border-agent-border bg-agent-card p-2.5">
31
31
  <p className="text-xs text-agent-muted-foreground">
32
32
  诊断当前模型服务的网络连通性,包括 DNS 解析、TCP 连接、TLS 握手、HTTP 接口与对话补全。
33
33
  诊断在宿主进程内执行,会同时报告系统/环境代理配置。
@@ -60,7 +60,7 @@ export function DiagnoseSettingsPanel() {
60
60
 
61
61
  {result && (
62
62
  <div className="space-y-2">
63
- <div className="flex items-center gap-2 text-sm">
63
+ <div className="flex items-center gap-2 text-xs">
64
64
  {result.ok ? (
65
65
  <span className="inline-flex items-center gap-1 text-agent-success">
66
66
  <LuCircleCheck className="h-4 w-4" />
@@ -68,14 +68,14 @@ export function InsightsConsentBanner() {
68
68
 
69
69
  return (
70
70
  <div
71
- className="flex-shrink-0 border-t border-agent-border bg-agent-muted px-4 py-3"
71
+ className="flex-shrink-0 border-t border-agent-border bg-agent-muted px-2.5 py-2"
72
72
  data-testid="insights-consent-banner"
73
73
  role="region"
74
74
  aria-label="帮助改进产品同意"
75
75
  >
76
76
  <div className="flex items-center justify-between gap-3">
77
77
  <div className="min-w-0">
78
- <p className="text-sm font-medium text-agent-foreground">帮助改进{BRAND_NAME}</p>
78
+ <p className="text-xs font-medium text-agent-foreground">帮助改进{BRAND_NAME}</p>
79
79
  <p className="mt-1 text-xs leading-relaxed text-agent-muted-foreground">
80
80
  同意上传数据到服务器,帮助改进产品。之后可在设置里更改。
81
81
  </p>
@@ -198,8 +198,8 @@ export function InsightsSettingsPanel() {
198
198
  const stats = data.stats ?? empty.stats!;
199
199
 
200
200
  return (
201
- <div className="space-y-4" data-testid="insights-settings-panel">
202
- <div className="rounded-agent-md border border-agent-border/60 bg-agent-muted/30 p-3.5 space-y-3">
201
+ <div className="space-y-3" data-testid="insights-settings-panel">
202
+ <div className="rounded-agent-md border border-agent-border/60 bg-agent-muted/30 p-2.5 space-y-2">
203
203
  <h4 className="flex items-center gap-1.5 text-xs font-semibold text-agent-foreground">
204
204
  <LuChartBar className="h-3.5 w-3.5 text-agent-muted-foreground" />
205
205
  帮助改进产品(行为 / 对话 / 用户信息 分开确认)