@steerable/agent-shell-web 0.6.51 → 0.6.53

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 (38) hide show
  1. package/package.json +4 -4
  2. package/src/brand.ts +7 -0
  3. package/src/components/AgentSidebar.tsx +284 -188
  4. package/src/components/ConfirmDialog.tsx +90 -0
  5. package/src/components/CreateProjectModal.tsx +32 -16
  6. package/src/components/chat/ApprovalModal.tsx +2 -5
  7. package/src/components/chat/ApprovalPromptProvider.tsx +38 -22
  8. package/src/components/chat/AskUserPromptProvider.tsx +1 -32
  9. package/src/components/chat/ChatInput.tsx +129 -59
  10. package/src/components/chat/ChatProjectBadge.tsx +1 -1
  11. package/src/components/chat/EmptyChatHero.tsx +22 -0
  12. package/src/components/chat/ExecPolicyPicker.tsx +2 -2
  13. package/src/components/chat/ExecutedActionsCard.tsx +22 -12
  14. package/src/components/chat/LocalChatPanel.tsx +25 -19
  15. package/src/components/chat/MessageList.tsx +77 -5
  16. package/src/components/chat/ModelPicker.tsx +21 -1
  17. package/src/components/chat/OrchestrationFlowCard.tsx +13 -3
  18. package/src/components/chat/SessionOrchestrationFlow.tsx +66 -19
  19. package/src/components/chat/SessionTodoList.tsx +12 -3
  20. package/src/components/chat/UserMessage.tsx +116 -25
  21. package/src/components/chat/composer-status-stack.tsx +79 -0
  22. package/src/components/chat/orchestration-flow-model.ts +108 -43
  23. package/src/components/chat/prompt-chat-scope.ts +33 -0
  24. package/src/components/settings/AgentsSettingsPanel.tsx +33 -22
  25. package/src/components/settings/McpSettingsPanel.tsx +144 -48
  26. package/src/components/settings/SkillsSettingsPanel.tsx +1 -1
  27. package/src/hooks/useProjects.ts +82 -0
  28. package/src/lib/attachments.ts +8 -0
  29. package/src/lib/electron-bridge.ts +16 -1
  30. package/src/lib/exec-policy.ts +1 -1
  31. package/src/lib/host-file-drop.ts +68 -0
  32. package/src/lib/host-paste.ts +38 -9
  33. package/src/lib/local-api.ts +1 -1
  34. package/src/lib/tauri-bridge.ts +5 -0
  35. package/src/pages/AgentPage.tsx +114 -64
  36. package/src/pages/SettingsPage.tsx +131 -46
  37. package/src/vite-env.d.ts +2 -0
  38. package/vite.base.js +1 -0
@@ -2,6 +2,7 @@ import {
2
2
  useCallback,
3
3
  useEffect,
4
4
  useLayoutEffect,
5
+ useMemo,
5
6
  useRef,
6
7
  useState,
7
8
  type ReactNode,
@@ -245,6 +246,43 @@ export function MessageList({
245
246
  scrollToBottom('auto');
246
247
  }, [lastMessageId, lastContentLen, lastTimelineSig, suggestedSig, scrollToBottom]);
247
248
 
249
+ // Group consecutive user messages into turns (方案 A).
250
+ // A group of 2+ user messages renders as a single connected card with
251
+ // dividers and "↳ 追加" badges, instead of separate disconnected bubbles.
252
+ type MessageItem =
253
+ | { type: 'user-group'; messages: ChatMessage[]; key: string }
254
+ | { type: 'assistant'; message: ChatMessage; originalIndex: number };
255
+
256
+ const groupedItems = useMemo<MessageItem[]>(() => {
257
+ const items: MessageItem[] = [];
258
+ let currentUserGroup: ChatMessage[] = [];
259
+
260
+ for (let i = 0; i < visibleMessages.length; i++) {
261
+ const msg = visibleMessages[i];
262
+ if (msg.role === 'user') {
263
+ currentUserGroup.push(msg);
264
+ } else {
265
+ if (currentUserGroup.length > 0) {
266
+ items.push({
267
+ type: 'user-group',
268
+ messages: currentUserGroup,
269
+ key: `ug-${currentUserGroup[0].id}`,
270
+ });
271
+ currentUserGroup = [];
272
+ }
273
+ items.push({ type: 'assistant', message: msg, originalIndex: i });
274
+ }
275
+ }
276
+ if (currentUserGroup.length > 0) {
277
+ items.push({
278
+ type: 'user-group',
279
+ messages: currentUserGroup,
280
+ key: `ug-${currentUserGroup[0].id}`,
281
+ });
282
+ }
283
+ return items;
284
+ }, [visibleMessages]);
285
+
248
286
  return (
249
287
  <div className="relative flex-1 overflow-hidden">
250
288
  <div
@@ -255,11 +293,45 @@ export function MessageList({
255
293
  emptyState ?? null
256
294
  ) : (
257
295
  <div className="mx-auto w-full max-w-3xl space-y-4">
258
- {visibleMessages.map((message, index) => {
259
- const isLast = index === visibleMessages.length - 1;
260
- if (message.role === 'user') {
261
- return <UserMessage key={message.id} message={message} agents={agents} chats={chats} />;
296
+ {groupedItems.map((item) => {
297
+ if (item.type === 'user-group') {
298
+ if (item.messages.length === 1) {
299
+ return (
300
+ <UserMessage
301
+ key={item.messages[0].id}
302
+ message={item.messages[0]}
303
+ agents={agents}
304
+ chats={chats}
305
+ />
306
+ );
307
+ }
308
+ // Multiple consecutive user messages: render as a unified combined card.
309
+ return (
310
+ <div
311
+ key={item.key}
312
+ className="group/user-group"
313
+ data-message-role="user-group"
314
+ >
315
+ <div className="flex justify-start">
316
+ <div className="mx-auto w-full max-w-[var(--chat-input-box-width)] overflow-hidden rounded-agent-lg border border-agent-border/80 bg-agent-muted/70 shadow-sm">
317
+ {item.messages.map((msg, idx) => (
318
+ <UserMessage
319
+ key={msg.id}
320
+ message={msg}
321
+ agents={agents}
322
+ chats={chats}
323
+ isGrouped
324
+ isAppended={idx > 0}
325
+ />
326
+ ))}
327
+ </div>
328
+ </div>
329
+ </div>
330
+ );
262
331
  }
332
+
333
+ const message = item.message;
334
+ const isLast = item.originalIndex === visibleMessages.length - 1;
263
335
  // Action attribution: prefer the per-message map (set when the
264
336
  // backend's `message_id` event reconciles the stream-time queue
265
337
  // with the DB id). The framework keeps the in-flight assistant's
@@ -309,7 +381,7 @@ export function MessageList({
309
381
  : undefined;
310
382
  let previousUser: ChatMessage | undefined;
311
383
  let previousUserCreatedAt: string | undefined;
312
- for (let i = index - 1; i >= 0; i -= 1) {
384
+ for (let i = item.originalIndex - 1; i >= 0; i -= 1) {
313
385
  if (visibleMessages[i].role === 'user') {
314
386
  previousUser = visibleMessages[i];
315
387
  previousUserCreatedAt = previousUser.createdAt;
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import { LuChevronDown, LuRefreshCw } from 'react-icons/lu';
2
+ import { LuChevronDown, LuRefreshCw, LuSettings } from 'react-icons/lu';
3
3
  import { ModelCapabilityChips } from '@/components/settings/ModelCapabilityChips';
4
4
  import {
5
5
  getLlmModels,
@@ -32,6 +32,8 @@ export interface ModelPickerProps {
32
32
  reasoningEffort: string | null;
33
33
  onSelectModel: (model: string | null) => void;
34
34
  onSelectEffort: (effort: string | null) => void;
35
+ /** 打开 LLM 设置弹窗。 */
36
+ onOpenSettings?: () => void;
35
37
  disabled?: boolean;
36
38
  }
37
39
 
@@ -40,6 +42,7 @@ export function ModelPicker({
40
42
  reasoningEffort,
41
43
  onSelectModel,
42
44
  onSelectEffort,
45
+ onOpenSettings,
43
46
  disabled = false,
44
47
  }: ModelPickerProps) {
45
48
  const [catalog, setCatalog] = useState<GatewayModelCatalog | null>(null);
@@ -109,6 +112,7 @@ export function ModelPicker({
109
112
  <div className="relative flex min-w-0">
110
113
  <button
111
114
  type="button"
115
+ data-testid="model-picker"
112
116
  onClick={() => {
113
117
  const next = !modelMenuOpen;
114
118
  setModelMenuOpen(next);
@@ -155,6 +159,22 @@ export function ModelPicker({
155
159
  >
156
160
  <LuRefreshCw className={`h-3 w-3 ${loading ? 'animate-spin' : ''}`} />
157
161
  </button>
162
+ {onOpenSettings && (
163
+ <button
164
+ type="button"
165
+ onClick={() => {
166
+ onOpenSettings();
167
+ setModelMenuOpen(false);
168
+ setEffortMenuOpen(false);
169
+ }}
170
+ className="flex h-4 w-4 items-center justify-center rounded text-agent-muted-foreground transition-colors hover:text-agent-foreground"
171
+ title="LLM 设置"
172
+ aria-label="LLM 设置"
173
+ data-testid="chat-llm-settings"
174
+ >
175
+ <LuSettings className="h-3 w-3" />
176
+ </button>
177
+ )}
158
178
  </span>
159
179
  </div>
160
180
  <button
@@ -35,6 +35,8 @@ function NodeStatusIcon({
35
35
  return <LuCircleDot className={`${cls} animate-pulse text-blue-600 dark:text-blue-400`} />;
36
36
  case 'interrupted':
37
37
  return <LuCirclePause className={`${cls} text-amber-600 dark:text-amber-400`} />;
38
+ case 'cancelled':
39
+ return <LuCircleX className={`${cls} text-agent-foreground`} />;
38
40
  case 'closed':
39
41
  return <LuCircleX className={`${cls} text-agent-muted-foreground`} />;
40
42
  case 'failed':
@@ -53,6 +55,8 @@ function statusBadgeText(status: OrchestrationNodeStatus): string {
53
55
  return '执行中';
54
56
  case 'interrupted':
55
57
  return '已暂停';
58
+ case 'cancelled':
59
+ return '已停止';
56
60
  case 'closed':
57
61
  return '已关闭';
58
62
  case 'failed':
@@ -71,6 +75,8 @@ function statusBadgeStyle(status: OrchestrationNodeStatus): string {
71
75
  return 'bg-blue-500/10 text-blue-700 dark:text-blue-300 border-blue-500/20';
72
76
  case 'interrupted':
73
77
  return 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/20';
78
+ case 'cancelled':
79
+ return 'bg-agent-muted text-agent-foreground border-agent-border';
74
80
  case 'closed':
75
81
  return 'bg-agent-muted text-agent-muted-foreground border-agent-border';
76
82
  case 'failed':
@@ -415,10 +421,10 @@ export function OrchestrationFlowCard({
415
421
 
416
422
  {/* 追加指令 */}
417
423
  {node.steers.length > 0 ? (
418
- <div className="mt-1.5 space-y-0.5 rounded bg-amber-500/5 p-1 text-[10px] text-amber-800 dark:text-amber-200">
424
+ <div className="mt-1.5 space-y-0.5 rounded border border-amber-800/20 bg-amber-100 px-1.5 py-1 text-[10px] text-amber-950">
419
425
  {node.steers.map((s, sIdx) => (
420
426
  <div key={sIdx} className="flex items-start gap-1">
421
- <LuMessageSquare className="mt-0.5 h-2.5 w-2.5 shrink-0 text-amber-600" />
427
+ <LuMessageSquare className="mt-0.5 h-2.5 w-2.5 shrink-0 text-amber-900" />
422
428
  <span className="truncate">{s.message}</span>
423
429
  </div>
424
430
  ))}
@@ -461,7 +467,11 @@ export function OrchestrationFlowCard({
461
467
  <LuCircleCheck className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
462
468
  <span>结果汇聚 (Join)</span>
463
469
  <span className="text-[10px] opacity-80">
464
- {isAllCompleted ? '所有分支已汇聚并完成回答' : '等待分支就绪...'}
470
+ {isAllCompleted
471
+ ? '所有分支已汇聚并完成回答'
472
+ : hasActive
473
+ ? '等待分支就绪...'
474
+ : '已停止'}
465
475
  </span>
466
476
  </div>
467
477
  </div>
@@ -20,6 +20,7 @@ import {
20
20
  type OrchestrationNodeStatus,
21
21
  } from './orchestration-flow-model';
22
22
  import type { InspectTaskInput } from './executed-actions-model';
23
+ import { useExclusiveExpand } from './composer-status-stack';
23
24
 
24
25
  function FlowNodeStatusIcon({
25
26
  status,
@@ -36,6 +37,8 @@ function FlowNodeStatusIcon({
36
37
  return <LuCircleDot className={`${cls} animate-pulse text-blue-600 dark:text-blue-400`} />;
37
38
  case 'interrupted':
38
39
  return <LuCirclePause className={`${cls} text-amber-600 dark:text-amber-400`} />;
40
+ case 'cancelled':
41
+ return <LuCircleX className={`${cls} text-agent-foreground`} />;
39
42
  case 'closed':
40
43
  case 'failed':
41
44
  return <LuCircleX className={`${cls} text-agent-destructive`} />;
@@ -45,6 +48,27 @@ function FlowNodeStatusIcon({
45
48
  }
46
49
  }
47
50
 
51
+ function statusBadgeText(status: OrchestrationNodeStatus): string {
52
+ switch (status) {
53
+ case 'completed':
54
+ return '已完成';
55
+ case 'running':
56
+ return '执行中';
57
+ case 'interrupted':
58
+ return '已暂停';
59
+ case 'cancelled':
60
+ return '已停止';
61
+ case 'closed':
62
+ return '已关闭';
63
+ case 'failed':
64
+ return '失败';
65
+ case 'pending':
66
+ return '等待中';
67
+ default:
68
+ return '等待中';
69
+ }
70
+ }
71
+
48
72
  export function currentActiveNode(nodes: OrchestrationChildNode[]): OrchestrationChildNode | null {
49
73
  return (
50
74
  nodes.find((n) => n.status === 'running' || n.status === 'pending') ??
@@ -245,9 +269,11 @@ export function SessionOrchestrationFlow({
245
269
  const { nodes, isAllCompleted, hasActive, summaryCopy } = flow;
246
270
  const activeNode = useMemo(() => currentActiveNode(nodes), [nodes]);
247
271
 
248
- // 执行中默认展开,全部完成默认收起为胶囊;支持用户手动点击切换展开状态
272
+ // 执行中默认展开,全部完成默认收起为胶囊;支持用户手动点击切换展开状态。
273
+ // 与任务清单同列时,展开浮层互斥。
249
274
  const [userToggled, setUserToggled] = useState<boolean | null>(null);
250
- const expanded = userToggled ?? hasActive;
275
+ const desired = userToggled ?? hasActive;
276
+ const { expanded, requestOpen } = useExclusiveExpand('orchestration', desired);
251
277
 
252
278
  // 任务全部执行完成时自动收拢为胶囊
253
279
  useEffect(() => {
@@ -294,14 +320,20 @@ export function SessionOrchestrationFlow({
294
320
  {/* 输入框上方的胶囊触发器按钮 (类似 SessionTodoList) */}
295
321
  <button
296
322
  type="button"
297
- onClick={() => setUserToggled(!expanded)}
323
+ onClick={() => {
324
+ if (expanded) setUserToggled(false);
325
+ else {
326
+ setUserToggled(true);
327
+ requestOpen();
328
+ }
329
+ }}
298
330
  className={[
299
331
  'flex h-6 max-w-[260px] sm:max-w-[340px] items-center gap-1.5 rounded-full px-2 text-left text-[11px] transition-all duration-200 select-none shadow-2xs',
300
332
  isAllCompleted
301
333
  ? 'border border-emerald-500/25 bg-emerald-500/[0.04] text-emerald-700 dark:text-emerald-300 hover:bg-emerald-500/[0.08]'
302
334
  : hasActive
303
335
  ? 'border border-violet-500/30 bg-violet-500/[0.06] text-violet-700 dark:text-violet-300 hover:bg-violet-500/10'
304
- : 'border border-agent-border/80 bg-agent-canvas text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
336
+ : 'border border-agent-border/80 bg-agent-canvas text-agent-foreground hover:bg-agent-foreground/5',
305
337
  ].join(' ')}
306
338
  aria-expanded={expanded}
307
339
  >
@@ -318,7 +350,9 @@ export function SessionOrchestrationFlow({
318
350
  <span className="min-w-0 flex-1 truncate font-medium">
319
351
  {isAllCompleted
320
352
  ? '协同编排已就绪'
321
- : (activeNode?.task ? activeNode.task : '协同编排流程')}
353
+ : hasActive
354
+ ? (activeNode?.task ? activeNode.task : '协同编排流程')
355
+ : '协同编排已停止'}
322
356
  </span>
323
357
 
324
358
  {summaryCopy ? (
@@ -328,7 +362,7 @@ export function SessionOrchestrationFlow({
328
362
  ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
329
363
  : hasActive
330
364
  ? 'bg-violet-500/15 text-violet-700 dark:text-violet-300'
331
- : 'bg-agent-muted text-agent-muted-foreground'
365
+ : 'bg-agent-muted text-agent-foreground'
332
366
  }`}
333
367
  >
334
368
  {summaryCopy}
@@ -345,7 +379,11 @@ export function SessionOrchestrationFlow({
345
379
  {/* 展开浮层:呈现真正的多智能体 Fork-Join 流程图 (DAG Flowchart) */}
346
380
  {expanded && (
347
381
  <div
348
- className="absolute bottom-full right-0 mb-1.5 w-[390px] sm:w-[480px] overflow-hidden rounded-agent-lg border border-agent-border bg-agent-canvas/98 backdrop-blur-md shadow-xl z-40 animate-in fade-in slide-in-from-bottom-1 duration-150"
382
+ className={`absolute bottom-full right-0 mb-1.5 overflow-hidden rounded-agent-lg border border-agent-border bg-agent-canvas/98 backdrop-blur-md shadow-xl z-40 animate-in fade-in slide-in-from-bottom-1 duration-150 ${
383
+ nodes.length >= 3
384
+ ? 'w-[440px] sm:w-[560px] max-w-[calc(100vw-2rem)]'
385
+ : 'w-[390px] sm:w-[480px] max-w-[calc(100vw-2rem)]'
386
+ }`}
349
387
  >
350
388
  {/* Header */}
351
389
  <div className="flex items-center justify-between border-b border-agent-border/60 bg-agent-muted/30 px-3 py-2">
@@ -361,7 +399,9 @@ export function SessionOrchestrationFlow({
361
399
  className={`rounded-full px-1.5 py-0.2 text-[10px] font-medium ${
362
400
  isAllCompleted
363
401
  ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
364
- : 'bg-violet-500/10 text-violet-600 dark:text-violet-400'
402
+ : hasActive
403
+ ? 'bg-violet-500/10 text-violet-600 dark:text-violet-400'
404
+ : 'bg-agent-muted text-agent-foreground'
365
405
  }`}
366
406
  >
367
407
  {summaryCopy}
@@ -397,6 +437,7 @@ export function SessionOrchestrationFlow({
397
437
  {nodes.map((node, index) => {
398
438
  const done = node.status === 'completed';
399
439
  const active = node.status === 'running' || node.status === 'pending';
440
+ const stopped = node.status === 'cancelled' || node.status === 'interrupted';
400
441
  const canInspect = Boolean(chatId && onInspectTask && node.recordId);
401
442
 
402
443
  return (
@@ -407,24 +448,26 @@ export function SessionOrchestrationFlow({
407
448
  <div>
408
449
  {/* 节点顶栏:Todo 式勾选/圆点 + 分支编号 + 状态 */}
409
450
  <div className="flex items-center justify-between gap-1 border-b border-agent-border/40 pb-1.5">
410
- <div className="flex items-center gap-1.5 min-w-0">
451
+ <div className="flex items-center gap-1 min-w-0 flex-1">
411
452
  <FlowNodeStatusIcon status={node.status} />
412
- <span className="font-semibold text-agent-foreground text-[11px] truncate">
453
+ <span className="font-semibold text-agent-foreground text-[11px] truncate whitespace-nowrap">
413
454
  分支 #{index + 1}
414
455
  </span>
415
456
  </div>
416
457
 
417
- <div className="flex items-center gap-1">
458
+ <div className="flex items-center gap-1 shrink-0">
418
459
  <span
419
- className={`rounded-full border px-1.5 py-0.2 text-[10px] font-medium ${
460
+ className={`shrink-0 whitespace-nowrap rounded-full border px-1.5 py-0.2 text-[10px] font-medium leading-tight ${
420
461
  done
421
462
  ? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
422
463
  : active
423
464
  ? 'border-blue-500/20 bg-blue-500/10 text-blue-700 dark:text-blue-300'
424
- : 'border-agent-border bg-agent-muted text-agent-muted-foreground'
465
+ : stopped
466
+ ? 'border-agent-border bg-agent-muted text-agent-foreground'
467
+ : 'border-agent-border bg-agent-muted text-agent-muted-foreground'
425
468
  }`}
426
469
  >
427
- {done ? '已完成' : active ? '执行中' : '等待中'}
470
+ {statusBadgeText(node.status)}
428
471
  </span>
429
472
  {canInspect && chatId ? (
430
473
  <button
@@ -438,10 +481,10 @@ export function SessionOrchestrationFlow({
438
481
  title: node.task || '子代理执行过程',
439
482
  })
440
483
  }
441
- className="inline-flex shrink-0 items-center gap-0.5 rounded px-1 text-[10px] text-agent-muted-foreground hover:bg-agent-muted hover:text-agent-foreground"
484
+ className="inline-flex shrink-0 whitespace-nowrap items-center gap-0.5 rounded px-1 text-[10px] text-agent-muted-foreground hover:bg-agent-muted hover:text-agent-foreground"
442
485
  title="查看过程"
443
486
  >
444
- <LuExternalLink className="h-2.5 w-2.5" />
487
+ <LuExternalLink className="h-2.5 w-2.5 shrink-0" />
445
488
  <span>过程</span>
446
489
  </button>
447
490
  ) : null}
@@ -466,10 +509,10 @@ export function SessionOrchestrationFlow({
466
509
 
467
510
  {/* 追加指令 (Steer) */}
468
511
  {node.steers.length > 0 ? (
469
- <div className="mt-1.5 space-y-0.5 rounded bg-amber-500/5 p-1 text-[10px] text-amber-800 dark:text-amber-200">
512
+ <div className="mt-1.5 space-y-0.5 rounded border border-amber-800/20 bg-amber-100 px-1.5 py-1 text-[10px] text-amber-950">
470
513
  {node.steers.map((s, sIdx) => (
471
514
  <div key={sIdx} className="flex items-start gap-1">
472
- <LuMessageSquare className="mt-0.5 h-2.5 w-2.5 shrink-0 text-amber-600" />
515
+ <LuMessageSquare className="mt-0.5 h-2.5 w-2.5 shrink-0 text-amber-900" />
473
516
  <span className="truncate">{s.message}</span>
474
517
  </div>
475
518
  ))}
@@ -512,7 +555,11 @@ export function SessionOrchestrationFlow({
512
555
  <LuCircleCheck className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
513
556
  <span>结果汇聚 (Join)</span>
514
557
  <span className="text-[10px] opacity-80">
515
- {isAllCompleted ? '所有分支已汇聚并完成回答' : '等待分支就绪...'}
558
+ {isAllCompleted
559
+ ? '所有分支已汇聚并完成回答'
560
+ : hasActive
561
+ ? '等待分支就绪...'
562
+ : '已停止'}
516
563
  </span>
517
564
  </div>
518
565
  </div>
@@ -8,6 +8,7 @@ import {
8
8
  LuLoaderCircle,
9
9
  LuX,
10
10
  } from 'react-icons/lu';
11
+ import { useExclusiveExpand } from './composer-status-stack';
11
12
  import {
12
13
  summarizeTodos,
13
14
  type SessionTodo,
@@ -106,9 +107,11 @@ export function SessionTodoList({ todos }: { todos: SessionTodo[] }) {
106
107
  return summary.total > 0 && summary.completed === summary.total;
107
108
  }, [todos]);
108
109
 
109
- // 执行中默认展开,全部完成默认折叠;允许用户手动点击收起或展开
110
+ // 执行中默认展开,全部完成默认折叠;允许用户手动点击收起或展开。
111
+ // 与编排胶囊同列时,展开浮层互斥。
110
112
  const [userToggled, setUserToggled] = useState<boolean | null>(null);
111
- const expanded = userToggled ?? hasActive;
113
+ const desired = userToggled ?? hasActive;
114
+ const { expanded, requestOpen } = useExclusiveExpand('todos', desired);
112
115
 
113
116
  // 当任务全部执行完毕时,自动收起为紧凑胶囊
114
117
  useEffect(() => {
@@ -147,7 +150,13 @@ export function SessionTodoList({ todos }: { todos: SessionTodo[] }) {
147
150
  >
148
151
  <button
149
152
  type="button"
150
- onClick={() => setUserToggled(!expanded)}
153
+ onClick={() => {
154
+ if (expanded) setUserToggled(false);
155
+ else {
156
+ setUserToggled(true);
157
+ requestOpen();
158
+ }
159
+ }}
151
160
  className={[
152
161
  'flex h-6 max-w-[240px] sm:max-w-[320px] items-center gap-1.5 rounded-full px-2 text-left text-[11px] transition-all duration-200 select-none shadow-2xs',
153
162
  isAllCompleted
@@ -1,6 +1,6 @@
1
1
  import { useLayoutEffect, useRef, useState } from 'react';
2
2
  import { motion } from 'framer-motion';
3
- import { LuChevronDown, LuChevronUp, LuCheck, LuCopy } from 'react-icons/lu';
3
+ import { LuChevronDown, LuChevronUp, LuCheck, LuCopy, LuCornerDownRight } from 'react-icons/lu';
4
4
  import type { ChatMessage } from '@steerable/agent-protocol';
5
5
  import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
6
6
  import { useSlashSources } from '@/lib/slash-sources';
@@ -12,38 +12,32 @@ import { useCopy } from './useCopy';
12
12
  * UserMessage — port of `deeppath`'s user-bubble component, trimmed to the
13
13
  * agent-only surface.
14
14
  *
15
- * Differences vs the original:
16
- * - **No RefCards.** Local-backend messages don't carry the `messageMetadata.refs`
17
- * array used by the cloud product to attach goal/task/event/document refs;
18
- * when we eventually want this for agent (e.g. uploaded files), it'll go
19
- * here.
20
- * - **No edit button.** The cloud product's `editChatMessage` rewrites the
21
- * SSE turn in place — we don't have that machinery yet. Tracking under
22
- * phase 2c.
23
- * - **No inline `ref://` / `tool://` / `agent://` parsing.** Pinned-ref
24
- * shortcuts stay cloud-only. `@智能体名` is a Markdown chip painted from
25
- * the agent list + message text (refresh restores from content).
26
- *
27
- * What we KEEP from the old version (and why it matters for parity):
28
- * - Left-aligned bubble (not right-aligned). The product chat layout stacks
29
- * both user + assistant bubbles flush-left in a vertical column — this is
30
- * the most visible "feel" difference vs a typical chat UI.
31
- * - Bubble background uses the secondary muted token so it sits a notch
32
- * above the canvas without competing with assistant bubbles.
33
- * - 4.5em fold + gradient mask + 「展开全部 / 收起」 toggle so a wall-of-text
34
- * user prompt doesn't steal the whole viewport before the assistant
35
- * replies.
36
- * - Friendly timestamp at bottom-right.
37
- * - `framer-motion` fade/slide-in on mount.
15
+ * Grouping (方案 A):
16
+ * When multiple user messages occur consecutively without an assistant reply
17
+ * in between, MessageList renders them as a single connected card:
18
+ * - The first message has `isGroupStart = true`
19
+ * - Mid-turn / follow-up steered additions have `isAppended = true`
20
+ * - The outer container groups them with a subtle dashed divider and a
21
+ * 「↳ 追加」 badge.
38
22
  */
39
23
 
40
24
  interface UserMessageProps {
41
25
  message: ChatMessage;
42
26
  agents?: LocalChatAgent[];
43
27
  chats?: LocalChat[];
28
+ /** Whether this message is part of a consecutive user message group. */
29
+ isGrouped?: boolean;
30
+ /** Whether this message is an appended follow-up inside the group (not the first one). */
31
+ isAppended?: boolean;
44
32
  }
45
33
 
46
- export function UserMessage({ message, agents = [], chats = [] }: UserMessageProps) {
34
+ export function UserMessage({
35
+ message,
36
+ agents = [],
37
+ chats = [],
38
+ isGrouped = false,
39
+ isAppended = false,
40
+ }: UserMessageProps) {
47
41
  const content = message.content || '';
48
42
  // 发出去的消息里 `/技能` `/mcp__srv__tool` 要和输入框里同样渲染成工具卡片。
49
43
  // 目录来自共享缓存,整屏消息只拉一次。
@@ -60,6 +54,103 @@ export function UserMessage({ message, agents = [], chats = [] }: UserMessagePro
60
54
  setIsOverflowing(el.scrollHeight > el.clientHeight + 1);
61
55
  }, [content, isExpanded]);
62
56
 
57
+ // When rendered as an item inside a group, the parent handles the outer card.
58
+ if (isGrouped) {
59
+ return (
60
+ <div
61
+ className={`group/message relative px-3 py-2 text-xs text-agent-foreground ${
62
+ isAppended ? 'border-t border-dashed border-agent-border/60 bg-agent-muted/30' : ''
63
+ }`}
64
+ data-message-role="user"
65
+ data-message-id={message.id}
66
+ >
67
+ {isAppended && (
68
+ <div className="mb-1 flex items-center gap-1 text-[11px] font-medium text-agent-muted-foreground select-none">
69
+ <LuCornerDownRight className="h-3 w-3 text-agent-primary/80" />
70
+ <span className="rounded bg-agent-primary/10 px-1 py-0.5 text-[10px] text-agent-primary font-medium">
71
+ 追加
72
+ </span>
73
+ </div>
74
+ )}
75
+ <div className="relative">
76
+ <div
77
+ ref={contentRef}
78
+ className={`markdown-content pl-1 ${
79
+ !isExpanded ? 'overflow-hidden' : ''
80
+ }`}
81
+ style={!isExpanded ? { maxHeight: '4.5em' } : undefined}
82
+ >
83
+ {content ? (
84
+ <Markdown
85
+ inlineParagraph
86
+ agents={agents}
87
+ chats={chats}
88
+ skills={skills}
89
+ mcpTools={mcpTools}
90
+ >
91
+ {content}
92
+ </Markdown>
93
+ ) : (
94
+ <div className="h-4" />
95
+ )}
96
+ </div>
97
+ {!isExpanded && isOverflowing && (
98
+ <div className="pointer-events-none absolute bottom-0 left-0 right-0 h-5 bg-gradient-to-t from-agent-muted to-transparent" />
99
+ )}
100
+ </div>
101
+
102
+ {isOverflowing && (
103
+ <button
104
+ type="button"
105
+ onClick={(event) => {
106
+ event.stopPropagation();
107
+ setIsExpanded((prev) => !prev);
108
+ }}
109
+ className="mt-1 inline-flex items-center gap-0.5 text-xs text-agent-muted-foreground transition-colors hover:text-agent-foreground"
110
+ >
111
+ {isExpanded ? (
112
+ <>
113
+ 收起
114
+ <LuChevronUp className="h-3 w-3" />
115
+ </>
116
+ ) : (
117
+ <>
118
+ 展开全部
119
+ <LuChevronDown className="h-3 w-3" />
120
+ </>
121
+ )}
122
+ </button>
123
+ )}
124
+
125
+ <div className="mt-1 flex items-center justify-between">
126
+ <button
127
+ type="button"
128
+ onClick={() => void copy()}
129
+ className="inline-flex items-center gap-0.5 rounded text-xs text-agent-muted-foreground opacity-0 transition-all duration-200 hover:text-agent-foreground focus:opacity-100 group-hover/message:opacity-100"
130
+ title={copied ? '已复制' : '复制消息'}
131
+ aria-label={copied ? '已复制' : '复制消息'}
132
+ >
133
+ {copied ? (
134
+ <>
135
+ <LuCheck className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
136
+ <span className="text-emerald-600 dark:text-emerald-400">
137
+ 已复制
138
+ </span>
139
+ </>
140
+ ) : (
141
+ <LuCopy className="h-3 w-3" />
142
+ )}
143
+ </button>
144
+ <div className="text-[11px] text-agent-muted-foreground">
145
+ {message.createdAt
146
+ ? getFriendlyDate(new Date(message.createdAt))
147
+ : getFriendlyDate(new Date())}
148
+ </div>
149
+ </div>
150
+ </div>
151
+ );
152
+ }
153
+
63
154
  return (
64
155
  <motion.div
65
156
  className="group/message"