@steerable/agent-shell-web 0.6.51 → 0.6.52

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 (34) 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 +47 -57
  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 +1 -1
  26. package/src/components/settings/SkillsSettingsPanel.tsx +1 -1
  27. package/src/hooks/useProjects.ts +82 -0
  28. package/src/lib/electron-bridge.ts +1 -0
  29. package/src/lib/exec-policy.ts +1 -1
  30. package/src/lib/local-api.ts +1 -1
  31. package/src/pages/AgentPage.tsx +114 -64
  32. package/src/pages/SettingsPage.tsx +131 -46
  33. package/src/vite-env.d.ts +2 -0
  34. package/vite.base.js +1 -0
@@ -6,7 +6,7 @@ import type { ExecPolicy } from '@/lib/exec-policy';
6
6
  * ExecPolicyPicker — 输入框上的命令沙箱切换(类 Codex 底部权限档)。
7
7
  *
8
8
  * 切档立刻记住,下一轮才生效:当前回合的 `execSandbox` 已经随流发出。
9
- * 工作区 = Seatbelt/bwrap 只允许写项目家目录或本对话工作区;完整权限
9
+ * 工作区 = Seatbelt/bwrap 只允许写项目家目录、源文件夹(含各自子目录)或本对话工作区;完整权限
10
10
  * = 不下发命令沙箱,本机路径(如 Downloads)不再被 Operation not permitted 拦住。
11
11
  */
12
12
 
@@ -19,7 +19,7 @@ const OPTIONS: Array<{
19
19
  id: 'workspace',
20
20
  label: '工作区',
21
21
  description:
22
- '命令只能写入当前项目或本对话工作区(文稿/<应用名>/conversations/);工作区外路径会被系统拒绝。',
22
+ '只能写入当前工作区,范围外的路径会被拒绝。',
23
23
  },
24
24
  {
25
25
  id: 'full',
@@ -59,6 +59,16 @@ export interface ExecutedAction {
59
59
  * port-only egress); none = no backend on this platform.
60
60
  */
61
61
  sandbox?: { backend?: string; enforcement: string };
62
+ /**
63
+ * 工具注册时声明的卡片。`declared` 为真时行标题用 `title`,
64
+ * 不再按工具名猜测。旧事件没有这个字段,仍走原来的摘要。
65
+ */
66
+ view?: {
67
+ card: 'generic' | 'terminal' | 'diff' | 'search' | 'read' | 'web';
68
+ kind: string;
69
+ title: string;
70
+ declared: boolean;
71
+ };
62
72
  }
63
73
 
64
74
  export interface ExecutedActionsCardProps {
@@ -124,18 +134,18 @@ function actionToTool(action: ExecutedAction, idx: number): ToolExecutionPayload
124
134
  ? humanizeDelegateError(env.error)
125
135
  : env.error
126
136
  : null;
127
- // W5-2: web_search/web_fetch 用结构化中文摘要(查询词/URL + 结果计数/
128
- // 状态码),其余工具走通用参数摘要。
129
- const summary =
130
- (action.tool === 'delegate_subagent'
131
- ? summarizeDelegateSubagent(action.arguments)
132
- : null) ??
133
- (isTaskFamilyTool(action.tool)
134
- ? summarizeTaskAction(action.tool, action.arguments, action.result)
135
- : null) ??
136
- summarizeRunCodeAction(action.tool, action.arguments, action.result) ??
137
- summarizeWebAction(action.tool, action.arguments, action.result) ??
138
- summarizeArguments(action.arguments);
137
+ // 声明过的工具用 view.title。没有 view 的旧事件仍按工具名做摘要。
138
+ const summary = action.view?.declared
139
+ ? action.view.title
140
+ : ((action.tool === 'delegate_subagent'
141
+ ? summarizeDelegateSubagent(action.arguments)
142
+ : null) ??
143
+ (isTaskFamilyTool(action.tool)
144
+ ? summarizeTaskAction(action.tool, action.arguments, action.result)
145
+ : null) ??
146
+ summarizeRunCodeAction(action.tool, action.arguments, action.result) ??
147
+ summarizeWebAction(action.tool, action.arguments, action.result) ??
148
+ summarizeArguments(action.arguments));
139
149
  const badge = sandboxBadge(action.sandbox);
140
150
  return {
141
151
  id: action.id ?? `${action.tool}-${idx}`,
@@ -11,6 +11,7 @@ import {
11
11
  type MentionReference,
12
12
  } from './ChatInput';
13
13
  import { EmptyChat } from './EmptyChat';
14
+ import { EmptyChatHero } from './EmptyChatHero';
14
15
  import { MessageList } from './MessageList';
15
16
  import type { ExecutedAction } from './ExecutedActionsCard';
16
17
  import type { InspectTaskInput } from './executed-actions-model';
@@ -18,6 +19,7 @@ import type { ChildInfo } from './OrchestrationChildrenCard';
18
19
  import type { TurnBlock } from './turn-timeline';
19
20
  import type { TurnFile } from './turn-files';
20
21
  import type { LlmSpeedSnapshot } from './process-status';
22
+ import { ComposerStatusStack } from './composer-status-stack';
21
23
  import { SessionTodoList } from './SessionTodoList';
22
24
  import { resolveLatestSessionTodos } from './todo-list-model';
23
25
  import { SessionOrchestrationFlow } from './SessionOrchestrationFlow';
@@ -59,9 +61,9 @@ export interface LocalChatPanelProps {
59
61
  isStreaming?: boolean;
60
62
  onSubmit: (input: { content: string; metadata?: Record<string, unknown> }) => void | Promise<void>;
61
63
  onCancel?: () => void;
62
- /** 轮中转向(streaming 期间 Enter):注入失败由 hook 兜底为排队/直发,见 ChatInput。 */
64
+ /** 轮中插队(streaming 期间 ⌘/Ctrl+Enter):注入失败由 hook 兜底为排队/直发。 */
63
65
  onSteer?: (text: string) => Promise<SteerOutcome>;
64
- /** W6-2 follow-up 排队:streaming 期间 ⌘/Ctrl+Enter 把文本排入待发队列。 */
66
+ /** W6-2 follow-up 排队:streaming 期间 Enter 把文本排入待发队列。 */
65
67
  onFollowUp?: (text: string) => void;
66
68
  /** 排队待发的 follow-up 文本列表。 */
67
69
  pendingFollowUps?: string[];
@@ -78,11 +80,11 @@ export interface LocalChatPanelProps {
78
80
  /** Slot rendered above the message list (header / agent picker / …). */
79
81
  header?: ReactNode;
80
82
  /**
81
- * 空会话 hero 布局:提供后,当 messages 为空且未在流式时,整个面板渲染为
82
- * 居中首屏(品牌标题 + 居中输入框,无 header)——与 /agent 落地页
83
- * (EmptyChatGate)同款视觉。首条消息落地后自动切回常规布局。
83
+ * 空会话 hero 布局:为 true 时,当 messages 为空且未在流式时,整个面板渲染为
84
+ * 居中首屏(品牌 logo + brand.homeHint + 居中输入框,无 header)——与 /agent
85
+ * 落地页(EmptyChatGate)同款视觉。首条消息落地后自动切回常规布局。
84
86
  */
85
- emptyHero?: { title: string; subtitle?: string };
87
+ emptyHero?: boolean;
86
88
  /** Override the default `EmptyChat` empty-state. */
87
89
  emptyState?: ReactNode;
88
90
  /** Disable the input (e.g. during initial chat-history load). */
@@ -339,6 +341,7 @@ export function LocalChatPanel({
339
341
  orchestrationChildrenByMessageId,
340
342
  timelineByMessageId,
341
343
  currentTurnTimeline,
344
+ turnActive: isStreaming,
342
345
  }),
343
346
  [
344
347
  messages,
@@ -348,6 +351,7 @@ export function LocalChatPanel({
348
351
  orchestrationChildrenByMessageId,
349
352
  timelineByMessageId,
350
353
  currentTurnTimeline,
354
+ isStreaming,
351
355
  ],
352
356
  );
353
357
 
@@ -378,6 +382,17 @@ export function LocalChatPanel({
378
382
  // inputBox 并重新 observe,否则气泡宽度变量停在旧节点上。
379
383
  }, [showEmptyHero]);
380
384
 
385
+ const handleCancel = useCallback(() => {
386
+ if (pendingFollowUps && pendingFollowUps.length > 0) {
387
+ setInputValue((draft) =>
388
+ [...pendingFollowUps, draft.trim()]
389
+ .filter(Boolean)
390
+ .join('\n\n'),
391
+ );
392
+ }
393
+ onCancel?.();
394
+ }, [onCancel, pendingFollowUps]);
395
+
381
396
  const chatInputNode = (
382
397
  <>
383
398
  {attachmentError && (
@@ -394,7 +409,7 @@ export function LocalChatPanel({
394
409
  value={inputValue}
395
410
  onChange={setInputValue}
396
411
  onSubmit={handleSubmit}
397
- onCancel={onCancel}
412
+ onCancel={handleCancel}
398
413
  onSteer={onSteer}
399
414
  onFollowUp={onFollowUp}
400
415
  pendingFollowUps={pendingFollowUps}
@@ -416,7 +431,7 @@ export function LocalChatPanel({
416
431
  leadingChrome={inputLeadingChrome}
417
432
  trailingChrome={
418
433
  sessionOrchestrationFlow || sessionTodos ? (
419
- <div className="flex items-center gap-1.5 min-w-0">
434
+ <ComposerStatusStack>
420
435
  {sessionOrchestrationFlow ? (
421
436
  <SessionOrchestrationFlow
422
437
  flow={sessionOrchestrationFlow}
@@ -425,7 +440,7 @@ export function LocalChatPanel({
425
440
  />
426
441
  ) : null}
427
442
  {sessionTodos ? <SessionTodoList todos={sessionTodos} /> : null}
428
- </div>
443
+ </ComposerStatusStack>
429
444
  ) : undefined
430
445
  }
431
446
  mode={mode}
@@ -453,16 +468,7 @@ export function LocalChatPanel({
453
468
  {showEmptyHero ? (
454
469
  <div className="flex min-h-0 flex-1 items-center justify-center p-3 sm:p-5">
455
470
  <div className="flex w-full max-w-3xl flex-col items-center gap-4">
456
- <div className="text-center">
457
- <h1 className="text-xl font-semibold tracking-tight text-agent-foreground">
458
- {emptyHero?.title}
459
- </h1>
460
- {emptyHero?.subtitle && (
461
- <p className="mt-1.5 text-xs text-agent-muted-foreground">
462
- {emptyHero.subtitle}
463
- </p>
464
- )}
465
- </div>
471
+ <EmptyChatHero />
466
472
  <div className="w-full">
467
473
  {inputBanner}
468
474
  {chatInputNode}
@@ -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