@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.
- package/package.json +4 -4
- package/src/brand.ts +7 -0
- package/src/components/AgentSidebar.tsx +284 -188
- package/src/components/ConfirmDialog.tsx +90 -0
- package/src/components/CreateProjectModal.tsx +32 -16
- package/src/components/chat/ApprovalModal.tsx +2 -5
- package/src/components/chat/ApprovalPromptProvider.tsx +38 -22
- package/src/components/chat/AskUserPromptProvider.tsx +1 -32
- package/src/components/chat/ChatInput.tsx +47 -57
- package/src/components/chat/ChatProjectBadge.tsx +1 -1
- package/src/components/chat/EmptyChatHero.tsx +22 -0
- package/src/components/chat/ExecPolicyPicker.tsx +2 -2
- package/src/components/chat/ExecutedActionsCard.tsx +22 -12
- package/src/components/chat/LocalChatPanel.tsx +25 -19
- package/src/components/chat/MessageList.tsx +77 -5
- package/src/components/chat/ModelPicker.tsx +21 -1
- package/src/components/chat/OrchestrationFlowCard.tsx +13 -3
- package/src/components/chat/SessionOrchestrationFlow.tsx +66 -19
- package/src/components/chat/SessionTodoList.tsx +12 -3
- package/src/components/chat/UserMessage.tsx +116 -25
- package/src/components/chat/composer-status-stack.tsx +79 -0
- package/src/components/chat/orchestration-flow-model.ts +108 -43
- package/src/components/chat/prompt-chat-scope.ts +33 -0
- package/src/components/settings/AgentsSettingsPanel.tsx +33 -22
- package/src/components/settings/McpSettingsPanel.tsx +1 -1
- package/src/components/settings/SkillsSettingsPanel.tsx +1 -1
- package/src/hooks/useProjects.ts +82 -0
- package/src/lib/electron-bridge.ts +1 -0
- package/src/lib/exec-policy.ts +1 -1
- package/src/lib/local-api.ts +1 -1
- package/src/pages/AgentPage.tsx +114 -64
- package/src/pages/SettingsPage.tsx +131 -46
- package/src/vite-env.d.ts +2 -0
- 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
|
-
'
|
|
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
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
(action.tool === 'delegate_subagent'
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
/**
|
|
64
|
+
/** 轮中插队(streaming 期间 ⌘/Ctrl+Enter):注入失败由 hook 兜底为排队/直发。 */
|
|
63
65
|
onSteer?: (text: string) => Promise<SteerOutcome>;
|
|
64
|
-
/** W6-2 follow-up 排队:streaming 期间
|
|
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
|
|
82
|
-
*
|
|
83
|
-
*
|
|
83
|
+
* 空会话 hero 布局:为 true 时,当 messages 为空且未在流式时,整个面板渲染为
|
|
84
|
+
* 居中首屏(品牌 logo + brand.homeHint + 居中输入框,无 header)——与 /agent
|
|
85
|
+
* 落地页(EmptyChatGate)同款视觉。首条消息落地后自动切回常规布局。
|
|
84
86
|
*/
|
|
85
|
-
emptyHero?:
|
|
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={
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
{
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 =
|
|
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-
|
|
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-
|
|
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
|
|
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={() =>
|
|
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-
|
|
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
|
-
:
|
|
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-
|
|
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=
|
|
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
|
-
:
|
|
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
|
|
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
|
-
:
|
|
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
|
-
{
|
|
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-
|
|
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-
|
|
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
|
|
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={() =>
|
|
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
|