@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
@@ -0,0 +1,79 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react';
11
+
12
+ export type ComposerStatusPanelId = 'orchestration' | 'todos';
13
+
14
+ type ComposerStatusContextValue = {
15
+ activeId: ComposerStatusPanelId | null;
16
+ claim: (id: ComposerStatusPanelId, force: boolean) => void;
17
+ release: (id: ComposerStatusPanelId) => void;
18
+ };
19
+
20
+ const ComposerStatusContext = createContext<ComposerStatusContextValue | null>(null);
21
+
22
+ /**
23
+ * Right-aligned column for the composer status pills (orchestration + todos).
24
+ * At most one panel is expanded; a user click steals the open slot, while an
25
+ * automatic open only claims it when nothing else is showing.
26
+ */
27
+ export function ComposerStatusStack({ children }: { children: ReactNode }) {
28
+ const [activeId, setActiveId] = useState<ComposerStatusPanelId | null>(null);
29
+ const claim = useCallback((id: ComposerStatusPanelId, force: boolean) => {
30
+ setActiveId((current) => {
31
+ if (force || current == null || current === id) return id;
32
+ return current;
33
+ });
34
+ }, []);
35
+ const release = useCallback((id: ComposerStatusPanelId) => {
36
+ setActiveId((current) => (current === id ? null : current));
37
+ }, []);
38
+ const value = useMemo(
39
+ () => ({ activeId, claim, release }),
40
+ [activeId, claim, release],
41
+ );
42
+
43
+ return (
44
+ <ComposerStatusContext.Provider value={value}>
45
+ <div
46
+ className="flex min-w-0 flex-col items-end gap-1.5"
47
+ data-testid="composer-status-stack"
48
+ >
49
+ {children}
50
+ </div>
51
+ </ComposerStatusContext.Provider>
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Fold a panel's own open preference into the stack.
57
+ * Outside a stack, `expanded` follows `desired` directly.
58
+ */
59
+ export function useExclusiveExpand(
60
+ id: ComposerStatusPanelId,
61
+ desired: boolean,
62
+ ): { expanded: boolean; requestOpen: () => void } {
63
+ const ctx = useContext(ComposerStatusContext);
64
+ const wasDesired = useRef(false);
65
+
66
+ useLayoutEffect(() => {
67
+ if (!ctx) return;
68
+ if (desired && !wasDesired.current) ctx.claim(id, false);
69
+ if (!desired && wasDesired.current) ctx.release(id);
70
+ wasDesired.current = desired;
71
+ }, [ctx, desired, id]);
72
+
73
+ const requestOpen = useCallback(() => {
74
+ ctx?.claim(id, true);
75
+ }, [ctx, id]);
76
+
77
+ const expanded = ctx ? desired && ctx.activeId === id : desired;
78
+ return { expanded, requestOpen };
79
+ }
@@ -29,7 +29,8 @@ export type OrchestrationNodeStatus =
29
29
  | 'completed'
30
30
  | 'failed'
31
31
  | 'interrupted'
32
- | 'closed';
32
+ | 'closed'
33
+ | 'cancelled';
33
34
 
34
35
  export interface OrchestrationSteer {
35
36
  message: string;
@@ -70,6 +71,77 @@ interface ActionLike {
70
71
  result?: unknown;
71
72
  }
72
73
 
74
+ function toNodeStatus(raw: string | undefined): OrchestrationNodeStatus {
75
+ switch (raw) {
76
+ case 'pending':
77
+ case 'running':
78
+ case 'completed':
79
+ case 'failed':
80
+ case 'interrupted':
81
+ case 'closed':
82
+ case 'cancelled':
83
+ return raw;
84
+ case 'error':
85
+ return 'failed';
86
+ default:
87
+ return 'running';
88
+ }
89
+ }
90
+
91
+ function buildOrchestrationFlow(nodes: OrchestrationChildNode[]): OrchestrationFlowData | null {
92
+ if (nodes.length === 0) return null;
93
+
94
+ const totalCount = nodes.length;
95
+ const completedCount = nodes.filter((n) => n.status === 'completed').length;
96
+ const runningCount = nodes.filter((n) => n.status === 'running' || n.status === 'pending').length;
97
+ const failedCount = nodes.filter((n) => n.status === 'failed').length;
98
+ const interruptedCount = nodes.filter((n) => n.status === 'interrupted').length;
99
+ const closedCount = nodes.filter((n) => n.status === 'closed' || n.status === 'cancelled').length;
100
+ const isAllCompleted = totalCount > 0 && completedCount === totalCount;
101
+ const hasActive = runningCount > 0;
102
+
103
+ let summaryCopy = '';
104
+ if (isAllCompleted) {
105
+ summaryCopy = `${completedCount}/${totalCount} 全部完成`;
106
+ } else if (hasActive) {
107
+ summaryCopy = `协同执行中 (${completedCount}/${totalCount} 完成)...`;
108
+ } else {
109
+ summaryCopy = `${completedCount} 完成 · ${totalCount - completedCount} 结束 (${totalCount} 个子任务)`;
110
+ }
111
+
112
+ return {
113
+ nodes,
114
+ totalCount,
115
+ completedCount,
116
+ runningCount,
117
+ failedCount,
118
+ interruptedCount,
119
+ closedCount,
120
+ isAllCompleted,
121
+ hasActive,
122
+ summaryCopy,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * 父回合已经结束时,还标着 running/pending 的分支不会再收到子代理终态
128
+ * (停止发生在流关闭之后)。把它们收成 cancelled,避免胶囊一直停在「协同执行中」。
129
+ */
130
+ export function settleStoppedOrchestration(flow: OrchestrationFlowData): OrchestrationFlowData {
131
+ if (!flow.nodes.some((node) => node.status === 'running' || node.status === 'pending')) {
132
+ return flow;
133
+ }
134
+ return (
135
+ buildOrchestrationFlow(
136
+ flow.nodes.map((node) =>
137
+ node.status === 'running' || node.status === 'pending'
138
+ ? { ...node, status: 'cancelled' }
139
+ : node,
140
+ ),
141
+ ) ?? flow
142
+ );
143
+ }
144
+
73
145
  function safeParseJson(value: unknown): Record<string, unknown> | null {
74
146
  if (!value) return null;
75
147
  if (typeof value === 'object' && !Array.isArray(value)) {
@@ -104,7 +176,7 @@ export function extractOrchestrationFlow(
104
176
  nodeMap.set(c.childId, {
105
177
  childId: c.childId,
106
178
  task: c.task || '',
107
- status: (c.status as OrchestrationNodeStatus) || 'running',
179
+ status: toNodeStatus(c.status),
108
180
  recordId: c.recordId,
109
181
  steers: [],
110
182
  });
@@ -189,6 +261,14 @@ export function extractOrchestrationFlow(
189
261
  } else if (waitStatus === 'failed' || waitStatus === 'error' || envelope.success === false) {
190
262
  node.status = 'failed';
191
263
  node.error = envelope.error || '执行失败';
264
+ } else if (waitStatus === 'cancelled') {
265
+ node.status = 'cancelled';
266
+ } else if (waitStatus === 'interrupted') {
267
+ node.interrupted = true;
268
+ node.status = 'interrupted';
269
+ } else if (waitStatus === 'closed') {
270
+ node.closed = true;
271
+ if (node.status !== 'completed') node.status = 'closed';
192
272
  } else if (waitStatus === 'running') {
193
273
  node.status = 'running';
194
274
  }
@@ -232,19 +312,26 @@ export function extractOrchestrationFlow(
232
312
  if (rawChild && typeof rawChild === 'object') {
233
313
  const cid = typeof rawChild.childId === 'string' ? rawChild.childId : '';
234
314
  if (!cid) continue;
315
+ const listedStatus =
316
+ typeof rawChild.status === 'string' ? toNodeStatus(rawChild.status) : undefined;
317
+ const listedAnswer =
318
+ (typeof rawChild.answer === 'string' && rawChild.answer) ||
319
+ (typeof rawChild.answerPreview === 'string' && rawChild.answerPreview) ||
320
+ '';
235
321
  let node = nodeMap.get(cid);
236
322
  if (!node) {
237
323
  node = {
238
324
  childId: cid,
239
325
  task: typeof rawChild.task === 'string' ? rawChild.task : '',
240
- status: (rawChild.status as OrchestrationNodeStatus) || 'running',
241
- answer: typeof rawChild.answer === 'string' ? rawChild.answer : undefined,
326
+ status: listedStatus ?? 'running',
327
+ answer: listedAnswer || undefined,
242
328
  steers: [],
243
329
  };
244
330
  nodeMap.set(cid, node);
245
331
  } else {
246
332
  if (!node.task && rawChild.task) node.task = String(rawChild.task);
247
- if (!node.answer && rawChild.answer) node.answer = String(rawChild.answer);
333
+ if (!node.answer && listedAnswer) node.answer = listedAnswer;
334
+ if (listedStatus) node.status = listedStatus;
248
335
  }
249
336
  }
250
337
  }
@@ -256,39 +343,7 @@ export function extractOrchestrationFlow(
256
343
  return null;
257
344
  }
258
345
 
259
- const nodes = Array.from(nodeMap.values());
260
- if (nodes.length === 0) return null;
261
-
262
- const totalCount = nodes.length;
263
- const completedCount = nodes.filter((n) => n.status === 'completed').length;
264
- const runningCount = nodes.filter((n) => n.status === 'running' || n.status === 'pending').length;
265
- const failedCount = nodes.filter((n) => n.status === 'failed').length;
266
- const interruptedCount = nodes.filter((n) => n.status === 'interrupted').length;
267
- const closedCount = nodes.filter((n) => n.status === 'closed').length;
268
- const isAllCompleted = totalCount > 0 && completedCount === totalCount;
269
- const hasActive = runningCount > 0;
270
-
271
- let summaryCopy = '';
272
- if (isAllCompleted) {
273
- summaryCopy = `${completedCount}/${totalCount} 全部完成`;
274
- } else if (hasActive) {
275
- summaryCopy = `协同执行中 (${completedCount}/${totalCount} 完成)...`;
276
- } else {
277
- summaryCopy = `${completedCount} 完成 · ${totalCount - completedCount} 结束 (${totalCount} 个子任务)`;
278
- }
279
-
280
- return {
281
- nodes,
282
- totalCount,
283
- completedCount,
284
- runningCount,
285
- failedCount,
286
- interruptedCount,
287
- closedCount,
288
- isAllCompleted,
289
- hasActive,
290
- summaryCopy,
291
- };
346
+ return buildOrchestrationFlow(Array.from(nodeMap.values()));
292
347
  }
293
348
 
294
349
  function actionsFromTimeline(blocks?: ReadonlyArray<{ type: string; actions?: unknown[] }>): ActionLike[] {
@@ -314,22 +369,32 @@ export function resolveLatestSessionOrchestrationFlow(input: {
314
369
  orchestrationChildrenByMessageId?: Record<string, ChildInfo[]>;
315
370
  timelineByMessageId?: Record<string, Array<{ type: string; actions?: unknown[] }>>;
316
371
  currentTurnTimeline?: Array<{ type: string; actions?: unknown[] }>;
372
+ /** 父回合仍在流式输出。结束后残留的 running 分支收成已停止。 */
373
+ turnActive?: boolean;
317
374
  }): OrchestrationFlowData | null {
375
+ const finish = (flow: OrchestrationFlowData | null, active: boolean) => {
376
+ if (!flow || flow.totalCount === 0) return null;
377
+ return active ? flow : settleStoppedOrchestration(flow);
378
+ };
379
+
318
380
  // 1. 优先实时回合
319
381
  const liveActions =
320
382
  input.currentTurnActions ?? actionsFromTimeline(input.currentTurnTimeline);
321
- const liveFlow = extractOrchestrationFlow(liveActions, input.currentTurnChildren);
322
- if (liveFlow && liveFlow.totalCount > 0) return liveFlow;
383
+ const liveFlow = finish(
384
+ extractOrchestrationFlow(liveActions, input.currentTurnChildren),
385
+ input.turnActive !== false,
386
+ );
387
+ if (liveFlow) return liveFlow;
323
388
 
324
- // 2. 倒序历史回合
389
+ // 2. 倒序历史回合。落库的回合已经结束,残留 running 同样收成已停止。
325
390
  for (let i = input.messages.length - 1; i >= 0; i -= 1) {
326
391
  const id = input.messages[i].id;
327
392
  const actions =
328
393
  input.executedActionsByMessageId?.[id] ??
329
394
  actionsFromTimeline(input.timelineByMessageId?.[id]);
330
395
  const children = input.orchestrationChildrenByMessageId?.[id];
331
- const flow = extractOrchestrationFlow(actions, children);
332
- if (flow && flow.totalCount > 0) return flow;
396
+ const flow = finish(extractOrchestrationFlow(actions, children), false);
397
+ if (flow) return flow;
333
398
  }
334
399
 
335
400
  return null;
@@ -0,0 +1,33 @@
1
+ /** Chat id from `#/agent/:chatId` (hash router) or `/agent/:chatId`. */
2
+ export function chatIdFromLocation(location: { pathname: string; hash: string }): string | null {
3
+ const hashed = location.hash.startsWith('#') ? location.hash.slice(1) : '';
4
+ const raw = hashed || location.pathname;
5
+ const match = raw.match(/\/agent\/([^/?#]+)/);
6
+ if (!match?.[1]) return null;
7
+ try {
8
+ return decodeURIComponent(match[1]);
9
+ } catch {
10
+ return match[1];
11
+ }
12
+ }
13
+
14
+ /** Stamp an unscoped prompt with the chat open when it arrived. */
15
+ export function bindPromptToActiveChat<T extends { chatId?: string }>(request: T): T {
16
+ if (request.chatId) return request;
17
+ const chatId = chatIdFromLocation(window.location);
18
+ if (!chatId) return request;
19
+ return { ...request, chatId };
20
+ }
21
+
22
+ /**
23
+ * A prompt with a chat id belongs only to that chat.
24
+ * A prompt without one stays visible only when the composer itself has no chat,
25
+ * so an older request is still answerable.
26
+ */
27
+ export function promptVisibleInChat(
28
+ request: { chatId?: string },
29
+ chatId: string | null | undefined,
30
+ ): boolean {
31
+ if (!request.chatId) return !chatId;
32
+ return request.chatId === chatId;
33
+ }
@@ -5,6 +5,7 @@ import {
5
5
  LuPlus,
6
6
  LuTrash2,
7
7
  } from 'react-icons/lu';
8
+ import { ConfirmDialog } from '@/components/ConfirmDialog';
8
9
  import { isElectron } from '@/lib/electron-bridge';
9
10
  import {
10
11
  archiveChatAgent,
@@ -22,7 +23,7 @@ import {
22
23
  /**
23
24
  * AgentsSettingsPanel — 智能体目录(列表 / 新建 / 编辑 / 归档)。
24
25
  *
25
- * 渲染在 `/settings?section=agents` 独立页。自定义智能体出现在输入框
26
+ * 渲染在 `/settings?section=plugins` 的智能体分类。自定义智能体出现在输入框
26
27
  * 上方的专家选择器里。内置智能体可改文案与能力,不能归档。
27
28
  *
28
29
  * 除文案外,每个智能体还持有真实生效的能力面(勾选技能 / 技能范围 /
@@ -91,7 +92,10 @@ export function AgentsSettingsPanel({
91
92
  const [capability, setCapability] = useState<AgentCapabilityDraft>(
92
93
  DEFAULT_CAPABILITY_DRAFT,
93
94
  );
94
- const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null);
95
+ const [pendingArchive, setPendingArchive] = useState<{
96
+ id: string;
97
+ name: string;
98
+ } | null>(null);
95
99
  const [archivingId, setArchivingId] = useState<string | null>(null);
96
100
  const [error, setError] = useState<string | null>(null);
97
101
 
@@ -179,17 +183,14 @@ export function AgentsSettingsPanel({
179
183
  }
180
184
  };
181
185
 
182
- const handleArchive = async (agent: LocalChatAgent) => {
183
- if (agent.isBuiltin) return;
184
- if (confirmArchiveId !== agent.id) {
185
- setConfirmArchiveId(agent.id);
186
- return;
187
- }
188
- setArchivingId(agent.id);
186
+ const handleArchive = async () => {
187
+ if (!pendingArchive || archivingId === pendingArchive.id) return;
188
+ const id = pendingArchive.id;
189
+ setArchivingId(id);
189
190
  setError(null);
190
191
  try {
191
- await archiveChatAgent(agent.id);
192
- setConfirmArchiveId(null);
192
+ await archiveChatAgent(id);
193
+ setPendingArchive(null);
193
194
  await fetchAgents();
194
195
  await onCatalogChange?.();
195
196
  } catch (err) {
@@ -371,18 +372,12 @@ export function AgentsSettingsPanel({
371
372
  {!agent.isBuiltin && (
372
373
  <button
373
374
  type="button"
374
- onClick={() => void handleArchive(agent)}
375
- disabled={archivingId === agent.id}
376
- className={`rounded-full p-1 transition-colors ${
377
- confirmArchiveId === agent.id
378
- ? 'bg-agent-destructive/10 text-agent-destructive hover:bg-agent-destructive/20'
379
- : 'text-agent-muted-foreground hover:bg-agent-muted hover:text-agent-destructive'
380
- }`}
381
- title={
382
- confirmArchiveId === agent.id
383
- ? '再次点击确认删除'
384
- : '删除'
375
+ onClick={() =>
376
+ setPendingArchive({ id: agent.id, name: agent.name })
385
377
  }
378
+ disabled={archivingId === agent.id}
379
+ className="rounded-full p-1 text-agent-muted-foreground transition-colors hover:bg-agent-muted hover:text-agent-destructive"
380
+ title="删除"
386
381
  data-testid={`agent-archive-${agent.id}`}
387
382
  >
388
383
  {archivingId === agent.id ? (
@@ -407,6 +402,22 @@ export function AgentsSettingsPanel({
407
402
  {error}
408
403
  </div>
409
404
  )}
405
+ <ConfirmDialog
406
+ open={pendingArchive !== null}
407
+ title="删除智能体"
408
+ description={
409
+ pendingArchive
410
+ ? `确定删除「${pendingArchive.name}」?它会从专家选择器中移除,已有会话不受影响。`
411
+ : ''
412
+ }
413
+ pending={pendingArchive !== null && archivingId === pendingArchive.id}
414
+ onCancel={() => {
415
+ if (archivingId) return;
416
+ setPendingArchive(null);
417
+ }}
418
+ onConfirm={() => void handleArchive()}
419
+ testId="agent-archive-dialog"
420
+ />
410
421
  </div>
411
422
  );
412
423
  }