@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.
- 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 +129 -59
- 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 +144 -48
- package/src/components/settings/SkillsSettingsPanel.tsx +1 -1
- package/src/hooks/useProjects.ts +82 -0
- package/src/lib/attachments.ts +8 -0
- package/src/lib/electron-bridge.ts +16 -1
- package/src/lib/exec-policy.ts +1 -1
- package/src/lib/host-file-drop.ts +68 -0
- package/src/lib/host-paste.ts +38 -9
- package/src/lib/local-api.ts +1 -1
- package/src/lib/tauri-bridge.ts +5 -0
- 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
|
@@ -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
|
|
@@ -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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
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({
|
|
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"
|