@steerable/agent-shell-web 0.6.26 → 0.6.28

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 (49) hide show
  1. package/package.json +4 -4
  2. package/src/components/AgentSidebar.tsx +24 -24
  3. package/src/components/BrandLockup.tsx +1 -1
  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 +41 -13
  10. package/src/components/chat/ChatInput.tsx +24 -24
  11. package/src/components/chat/ExecPolicyPicker.tsx +4 -4
  12. package/src/components/chat/ExecutedActionsCard.tsx +10 -3
  13. package/src/components/chat/FilePathCode.tsx +2 -1
  14. package/src/components/chat/LocalChatPanel.tsx +39 -4
  15. package/src/components/chat/Markdown.tsx +15 -14
  16. package/src/components/chat/MessageList.tsx +39 -16
  17. package/src/components/chat/ModelPicker.tsx +6 -6
  18. package/src/components/chat/SessionTodoList.tsx +137 -0
  19. package/src/components/chat/SessionTreeModal.tsx +3 -3
  20. package/src/components/chat/SuggestedReplies.tsx +5 -4
  21. package/src/components/chat/TaskPanelModal.tsx +3 -3
  22. package/src/components/chat/TaskProcessPanel.tsx +17 -11
  23. package/src/components/chat/TurnFilesCard.tsx +2 -2
  24. package/src/components/chat/TurnProcessGroup.tsx +281 -117
  25. package/src/components/chat/UserMessage.tsx +4 -4
  26. package/src/components/chat/next-steps-tags.ts +28 -0
  27. package/src/components/chat/process-status.ts +125 -32
  28. package/src/components/chat/todo-list-model.ts +167 -0
  29. package/src/components/chat/turn-timeline.ts +100 -11
  30. package/src/components/settings/AgentsSettingsPanel.tsx +2 -2
  31. package/src/components/settings/AppearanceSettingsPanel.tsx +43 -30
  32. package/src/components/settings/DiagnoseSettingsPanel.tsx +2 -2
  33. package/src/components/settings/InsightsSettingsPanel.tsx +4 -4
  34. package/src/components/settings/LlmSettingsPanel.tsx +10 -10
  35. package/src/components/settings/McpSettingsPanel.tsx +3 -3
  36. package/src/components/settings/ModelIdCombobox.tsx +2 -2
  37. package/src/components/settings/SecuritySettingsPanel.tsx +1 -1
  38. package/src/components/settings/SettingsSaveButton.tsx +1 -1
  39. package/src/components/settings/SkillsSettingsPanel.tsx +2 -2
  40. package/src/components/settings/TelemetrySettingsPanel.tsx +2 -2
  41. package/src/components/settings/UsagePanel.tsx +14 -10
  42. package/src/components/settings/WebSearchSettingsPanel.tsx +2 -2
  43. package/src/layouts/AgentLayout.tsx +3 -3
  44. package/src/lib/chat-transport.ts +68 -12
  45. package/src/lib/show-thinking-content.ts +40 -15
  46. package/src/pages/AgentPage.tsx +56 -6
  47. package/src/pages/ChatPanelPreviewPage.tsx +88 -8
  48. package/src/pages/SettingsPage.tsx +39 -39
  49. package/src/styles/index.css +12 -2
@@ -117,7 +117,7 @@ export function ModelPicker({
117
117
  if (next && status !== 'live' && !loading) void refresh();
118
118
  }}
119
119
  disabled={disabled}
120
- className="inline-flex h-7 min-w-0 max-w-[160px] items-center gap-1.5 rounded-full border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-70 @sm:max-w-[220px]"
120
+ className="inline-flex h-6 min-w-0 max-w-[160px] items-center gap-1 rounded-full border border-agent-border bg-agent-canvas px-1.5 text-[12px] leading-[1.45] text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-70 @sm:max-w-[220px]"
121
121
  title={
122
122
  status === 'offline'
123
123
  ? `模型目录不可用${catalog?.error ? `:${catalog.error}` : ''}(仍可手动指定模型)`
@@ -165,7 +165,7 @@ export function ModelPicker({
165
165
  setModelMenuOpen(false);
166
166
  }}
167
167
  className={[
168
- 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors',
168
+ 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] leading-[1.45] transition-colors',
169
169
  model == null
170
170
  ? 'bg-agent-foreground/10 text-agent-foreground'
171
171
  : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
@@ -192,7 +192,7 @@ export function ModelPicker({
192
192
  setModelMenuOpen(false);
193
193
  }}
194
194
  className={[
195
- 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors',
195
+ 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] leading-[1.45] transition-colors',
196
196
  isActive
197
197
  ? 'bg-agent-foreground/10 text-agent-foreground'
198
198
  : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
@@ -232,7 +232,7 @@ export function ModelPicker({
232
232
  setModelMenuOpen(false);
233
233
  }}
234
234
  disabled={disabled}
235
- className="inline-flex h-7 items-center gap-1 rounded-full border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-70"
235
+ className="inline-flex h-6 items-center gap-1 rounded-full border border-agent-border bg-agent-canvas px-1.5 text-[12px] leading-[1.45] text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-70"
236
236
  title="推理档位(随每轮请求下发;模型不支持会直接报错)"
237
237
  aria-haspopup="menu"
238
238
  aria-expanded={effortMenuOpen}
@@ -253,7 +253,7 @@ export function ModelPicker({
253
253
  setEffortMenuOpen(false);
254
254
  }}
255
255
  className={[
256
- 'flex w-full rounded px-2 py-1.5 text-left text-xs transition-colors',
256
+ 'flex w-full rounded px-2 py-1.5 text-left text-[12px] leading-[1.45] transition-colors',
257
257
  reasoningEffort == null
258
258
  ? 'bg-agent-foreground/10 text-agent-foreground'
259
259
  : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
@@ -271,7 +271,7 @@ export function ModelPicker({
271
271
  setEffortMenuOpen(false);
272
272
  }}
273
273
  className={[
274
- 'flex w-full rounded px-2 py-1.5 text-left text-xs transition-colors',
274
+ 'flex w-full rounded px-2 py-1.5 text-left text-[12px] leading-[1.45] transition-colors',
275
275
  reasoningEffort === level
276
276
  ? 'bg-agent-foreground/10 text-agent-foreground'
277
277
  : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
@@ -0,0 +1,137 @@
1
+ import { useMemo, useState } from 'react';
2
+ import {
3
+ LuCircle,
4
+ LuCircleCheck,
5
+ LuCircleDot,
6
+ LuChevronDown,
7
+ LuChevronRight,
8
+ LuListChecks,
9
+ } from 'react-icons/lu';
10
+ import {
11
+ summarizeTodos,
12
+ type SessionTodo,
13
+ type TodoStatus,
14
+ } from './todo-list-model';
15
+
16
+ function StatusIcon({
17
+ status,
18
+ className,
19
+ }: {
20
+ status: TodoStatus;
21
+ className?: string;
22
+ }) {
23
+ const cls = ['h-3.5 w-3.5 shrink-0', className].filter(Boolean).join(' ');
24
+ if (status === 'completed') {
25
+ return <LuCircleCheck className={`${cls} text-emerald-600 dark:text-emerald-400`} />;
26
+ }
27
+ if (status === 'in_progress') {
28
+ return <LuCircleDot className={`${cls} text-blue-600 dark:text-blue-400`} />;
29
+ }
30
+ return <LuCircle className={`${cls} text-agent-muted-foreground/70`} />;
31
+ }
32
+
33
+ export function currentTodoStep(todos: SessionTodo[]): SessionTodo | null {
34
+ return (
35
+ todos.find((todo) => todo.status === 'in_progress') ??
36
+ todos.find((todo) => todo.status === 'pending') ??
37
+ todos[todos.length - 1] ??
38
+ null
39
+ );
40
+ }
41
+
42
+ export function todoProgressCopy(todos: SessionTodo[]): string {
43
+ const summary = summarizeTodos(todos);
44
+ if (summary.total === 0) return '';
45
+ if (summary.completed === summary.total) {
46
+ return `${summary.completed}/${summary.total} 已完成`;
47
+ }
48
+ const currentIndex = todos.findIndex((todo) => todo.status === 'in_progress');
49
+ const step =
50
+ currentIndex >= 0 ? currentIndex + 1 : Math.min(summary.completed + 1, summary.total);
51
+ return `执行到 ${step}/${summary.total}`;
52
+ }
53
+
54
+ export function TodoItems({
55
+ todos,
56
+ compact = false,
57
+ }: {
58
+ todos: SessionTodo[];
59
+ compact?: boolean;
60
+ }) {
61
+ return (
62
+ <ol
63
+ className={`m-0 list-none ${compact ? 'max-h-40 space-y-1 overflow-y-auto px-2.5 py-1.5' : 'space-y-1 px-2.5 py-1.5'}`}
64
+ >
65
+ {todos.map((todo) => {
66
+ const done = todo.status === 'completed';
67
+ const active = todo.status === 'in_progress';
68
+ return (
69
+ <li
70
+ key={todo.id}
71
+ data-testid={`todo-item-${todo.id}`}
72
+ data-status={todo.status}
73
+ className="flex items-start gap-2 text-[12px] leading-relaxed"
74
+ >
75
+ <StatusIcon status={todo.status} className="mt-[2px]" />
76
+ <span
77
+ className={
78
+ done
79
+ ? 'text-agent-muted-foreground line-through'
80
+ : active
81
+ ? 'font-medium text-agent-foreground'
82
+ : 'text-agent-foreground'
83
+ }
84
+ >
85
+ {todo.content}
86
+ </span>
87
+ </li>
88
+ );
89
+ })}
90
+ </ol>
91
+ );
92
+ }
93
+
94
+ export function SessionTodoList({ todos }: { todos: SessionTodo[] }) {
95
+ const [expanded, setExpanded] = useState(false);
96
+ const current = useMemo(() => currentTodoStep(todos), [todos]);
97
+ const progressLabel = useMemo(() => todoProgressCopy(todos), [todos]);
98
+
99
+ return (
100
+ <div
101
+ className="mb-1 overflow-hidden rounded-agent-lg border border-agent-border bg-agent-canvas shadow-sm"
102
+ data-testid="session-todo-list"
103
+ >
104
+ <button
105
+ type="button"
106
+ onClick={() => setExpanded((value) => !value)}
107
+ className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left"
108
+ aria-expanded={expanded}
109
+ >
110
+ {expanded ? (
111
+ <LuChevronDown className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
112
+ ) : (
113
+ <LuChevronRight className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
114
+ )}
115
+ {expanded || !current ? (
116
+ <LuListChecks className="h-3.5 w-3.5 shrink-0 text-agent-foreground" />
117
+ ) : (
118
+ <StatusIcon status={current.status} />
119
+ )}
120
+ <span className="min-w-0 flex-1 truncate text-[12px] font-medium text-agent-foreground">
121
+ {expanded ? '任务清单' : (current?.content ?? '任务清单')}
122
+ </span>
123
+ {progressLabel ? (
124
+ <span
125
+ className="shrink-0 rounded-full bg-agent-muted px-1.5 py-0.5 text-[10px] font-medium text-agent-muted-foreground"
126
+ data-testid="todo-progress"
127
+ >
128
+ {progressLabel}
129
+ </span>
130
+ ) : null}
131
+ </button>
132
+ {expanded ? <TodoItems todos={todos} compact /> : null}
133
+ </div>
134
+ );
135
+ }
136
+
137
+ export default SessionTodoList;
@@ -136,9 +136,9 @@ export function SessionTreeModal({ chatId, onClose, onBranchSwitched }: SessionT
136
136
  data-session-tree-modal
137
137
  >
138
138
  <div className="flex max-h-[85vh] w-full max-w-lg flex-col overflow-hidden rounded-agent-lg border border-agent-border bg-agent-canvas shadow-xl">
139
- <div className="flex items-center gap-2 border-b border-agent-border px-4 py-2.5">
139
+ <div className="flex items-center gap-2 border-b border-agent-border px-3 py-2">
140
140
  <LuGitBranch className="h-4 w-4 shrink-0 text-agent-muted-foreground" />
141
- <span className="min-w-0 flex-1 truncate text-sm font-medium text-agent-foreground">
141
+ <span className="min-w-0 flex-1 truncate text-xs font-medium text-agent-foreground">
142
142
  会话分支树
143
143
  {data && data.nodeCount > 1 && (
144
144
  <span className="ml-1.5 text-xs font-normal text-agent-muted-foreground">
@@ -206,7 +206,7 @@ export function SessionTreeModal({ chatId, onClose, onBranchSwitched }: SessionT
206
206
  </div>
207
207
 
208
208
  {!loading && !isEmpty && (
209
- <div className="border-t border-agent-border px-4 py-2 text-[10px] text-agent-muted-foreground/80">
209
+ <div className="border-t border-agent-border px-3 py-1.5 text-[10px] text-agent-muted-foreground/80">
210
210
  {data?.truncated
211
211
  ? '分支过多,树已被截断(仅显示部分节点)。'
212
212
  : '点击切换分支;↑↓ 移动,Enter 切换,Esc 关闭。'}
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * WorkBuddy-style follow-up chips: next-turn user inputs rendered under the
3
- * latest assistant reply. Count follows the turn (skill next steps), not a
4
- * fixed three. Clicking one sends that text immediately.
3
+ * latest assistant reply. Source is the model's judgment of `[next_steps]`
4
+ * or the last paragraph — count follows that, not a fixed three. Clicking
5
+ * one sends that text immediately.
5
6
  */
6
7
  export function SuggestedReplies({
7
8
  suggestions,
@@ -14,7 +15,7 @@ export function SuggestedReplies({
14
15
 
15
16
  return (
16
17
  <div
17
- className="mx-auto flex w-full max-w-[var(--chat-input-box-width)] flex-col items-start gap-1.5 px-1 pt-1"
18
+ className="mx-auto flex w-full max-w-[var(--chat-input-box-width)] flex-col items-start gap-1 px-1 pt-0.5 text-[12px]"
18
19
  data-testid="suggested-replies"
19
20
  >
20
21
  {suggestions.map((text) => (
@@ -23,7 +24,7 @@ export function SuggestedReplies({
23
24
  type="button"
24
25
  data-testid="suggested-reply"
25
26
  onClick={() => onSelect(text)}
26
- className="max-w-full rounded-full border border-agent-border bg-agent-muted/40 px-3 py-1.5 text-left text-[13px] leading-snug text-agent-foreground transition-colors hover:bg-agent-foreground/5"
27
+ className="max-w-full rounded-full border border-agent-border bg-agent-muted/40 px-2 py-0.5 text-left text-[12px] leading-[1.45] text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
27
28
  >
28
29
  {text}
29
30
  </button>
@@ -135,9 +135,9 @@ export function TaskPanelModal({
135
135
  data-task-panel-modal
136
136
  >
137
137
  <div className="flex max-h-[85vh] w-full max-w-xl flex-col overflow-hidden rounded-agent-lg border border-agent-border bg-agent-canvas shadow-xl">
138
- <div className="flex items-center gap-2 border-b border-agent-border px-4 py-2.5">
138
+ <div className="flex items-center gap-2 border-b border-agent-border px-3 py-2">
139
139
  <LuListTodo className="h-4 w-4 shrink-0 text-agent-muted-foreground" />
140
- <span className="min-w-0 flex-1 truncate text-sm font-medium text-agent-foreground">
140
+ <span className="min-w-0 flex-1 truncate text-xs font-medium text-agent-foreground">
141
141
  后台任务
142
142
  {tasks && tasks.length > 0 && (
143
143
  <span className="ml-1.5 text-xs font-normal text-agent-muted-foreground">
@@ -298,7 +298,7 @@ export function TaskPanelModal({
298
298
  )}
299
299
  </div>
300
300
 
301
- <div className="border-t border-agent-border px-4 py-2 text-[10px] text-agent-muted-foreground/80">
301
+ <div className="border-t border-agent-border px-3 py-1.5 text-[10px] text-agent-muted-foreground/80">
302
302
  任务由后台智能体独立执行。点任务名可在右侧看推理过程;Esc 关闭。
303
303
  </div>
304
304
  </div>
@@ -35,16 +35,18 @@ export function TaskProcessPanel({
35
35
  const [error, setError] = useState<string | null>(null);
36
36
  const containerRef = useRef<HTMLDivElement>(null);
37
37
  const [isAtBottom, setIsAtBottom] = useState(true);
38
+ const isAtBottomRef = useRef(true);
38
39
 
39
40
  const checkAtBottom = useCallback(() => {
40
41
  const el = containerRef.current;
41
42
  if (!el) return;
42
43
  const atBottom =
43
44
  el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD_PX;
45
+ isAtBottomRef.current = atBottom;
44
46
  setIsAtBottom(atBottom);
45
47
  }, []);
46
48
 
47
- const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
49
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
48
50
  const el = containerRef.current;
49
51
  if (!el) return;
50
52
  if (behavior === 'smooth') {
@@ -65,20 +67,22 @@ export function TaskProcessPanel({
65
67
 
66
68
  // 换任务 / 首屏:没有「之前在不在底部」可言,直接钉到底。
67
69
  useLayoutEffect(() => {
68
- scrollToBottom('instant' as ScrollBehavior);
70
+ isAtBottomRef.current = true;
71
+ scrollToBottom('auto');
69
72
  setIsAtBottom(true);
70
73
  }, [inspected.id, scrollToBottom]);
71
74
 
72
- // live 增量只在用户还贴着底部时跟滚——往上翻看前面的工具调用时不要拽走。
75
+ // live 增量在 paint 前钉住底部。smooth scroll 会先露出一帧半截内容再往下
76
+ // 滑,Windows 经典滚动条就会上下跳。
73
77
  const timelineSig = blocks
74
78
  .map((block) =>
75
79
  block.type === 'tools' ? `t${block.actions.length}` : `c${block.content.length}`,
76
80
  )
77
81
  .join('|');
78
- useEffect(() => {
79
- if (!isAtBottom) return;
80
- scrollToBottom('smooth');
81
- }, [timelineSig, live, isAtBottom, scrollToBottom]);
82
+ useLayoutEffect(() => {
83
+ if (!isAtBottomRef.current) return;
84
+ scrollToBottom('auto');
85
+ }, [timelineSig, live, scrollToBottom]);
82
86
 
83
87
  useEffect(() => {
84
88
  let cancelled = false;
@@ -145,7 +149,7 @@ export function TaskProcessPanel({
145
149
  )
146
150
  }
147
151
  />
148
- <div className="border-b border-agent-border/60 px-3 py-2">
152
+ <div className="border-b border-agent-border/60 px-2.5 py-1.5">
149
153
  <div className="line-clamp-3 text-xs leading-relaxed text-agent-foreground">
150
154
  {inspected.title}
151
155
  </div>
@@ -153,7 +157,7 @@ export function TaskProcessPanel({
153
157
  <div className="relative min-h-0 flex-1 overflow-hidden">
154
158
  <div
155
159
  ref={containerRef}
156
- className="h-full overflow-y-auto p-3"
160
+ className="h-full overflow-y-auto overflow-anchor-none p-2.5"
157
161
  data-testid="task-process-scroll"
158
162
  >
159
163
  {loading ? (
@@ -168,6 +172,7 @@ export function TaskProcessPanel({
168
172
  blocks={blocks}
169
173
  isStreaming={live}
170
174
  showThinkingContent
175
+ collapseWhenFinished={false}
171
176
  agents={[]}
172
177
  chats={[]}
173
178
  emptyFallback={
@@ -180,7 +185,7 @@ export function TaskProcessPanel({
180
185
  </div>
181
186
  }
182
187
  renderAnswer={(block) => (
183
- <div className="text-sm leading-relaxed text-agent-foreground">
188
+ <div className="text-xs leading-relaxed text-agent-foreground">
184
189
  <Markdown agents={[]} chats={[]}>{block.content}</Markdown>
185
190
  </div>
186
191
  )}
@@ -191,10 +196,11 @@ export function TaskProcessPanel({
191
196
  <button
192
197
  type="button"
193
198
  onClick={() => {
199
+ isAtBottomRef.current = true;
194
200
  setIsAtBottom(true);
195
201
  scrollToBottom('smooth');
196
202
  }}
197
- className="absolute bottom-3 right-3 flex h-8 w-8 items-center justify-center rounded-full border border-agent-border bg-agent-canvas text-agent-foreground shadow-md transition-colors hover:bg-agent-foreground/5"
203
+ className="absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center rounded-full border border-agent-border bg-agent-canvas text-agent-foreground shadow-md transition-colors hover:bg-agent-foreground/5"
198
204
  title="回到底部"
199
205
  aria-label="回到底部"
200
206
  >
@@ -57,7 +57,7 @@ export function TurnFilesCard({ files }: TurnFilesCardProps) {
57
57
  className="rounded-agent-lg border border-agent-border bg-agent-canvas shadow-sm"
58
58
  data-turn-files=""
59
59
  >
60
- <div className="flex items-center gap-1.5 border-b border-agent-border/60 px-3 py-1.5 text-[11px] text-agent-muted-foreground">
60
+ <div className="flex items-center gap-1.5 border-b border-agent-border/60 px-2.5 py-1 text-[11px] text-agent-muted-foreground">
61
61
  <LuFiles className="h-3.5 w-3.5" />
62
62
  <span>本轮产生了 {files.length} 个文件,点击打开</span>
63
63
  </div>
@@ -74,7 +74,7 @@ export function TurnFilesCard({ files }: TurnFilesCardProps) {
74
74
  onClick={() => void handleOpen(file)}
75
75
  disabled={openingPath !== null}
76
76
  title={file.path}
77
- className="flex w-full items-center gap-2 px-3 py-1 text-left text-xs transition-colors hover:bg-agent-foreground/5 disabled:cursor-wait"
77
+ className="flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs transition-colors hover:bg-agent-foreground/5 disabled:cursor-wait"
78
78
  data-turn-file=""
79
79
  data-kind={file.kind}
80
80
  >