@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,90 @@
1
+ /**
2
+ * 破坏性操作的二次确认弹窗。点遮罩或按 Esc 取消;确认进行中不关闭。
3
+ */
4
+ import { useEffect, useId } from 'react';
5
+ import { createPortal } from 'react-dom';
6
+
7
+ export interface ConfirmDialogProps {
8
+ open: boolean;
9
+ title: string;
10
+ description: string;
11
+ confirmLabel?: string;
12
+ pending?: boolean;
13
+ onCancel: () => void;
14
+ onConfirm: () => void;
15
+ testId?: string;
16
+ }
17
+
18
+ export function ConfirmDialog({
19
+ open,
20
+ title,
21
+ description,
22
+ confirmLabel = '删除',
23
+ pending = false,
24
+ onCancel,
25
+ onConfirm,
26
+ testId = 'confirm-dialog',
27
+ }: ConfirmDialogProps) {
28
+ const titleId = useId();
29
+ const descriptionId = useId();
30
+
31
+ useEffect(() => {
32
+ if (!open) return;
33
+ const onKey = (event: KeyboardEvent) => {
34
+ if (event.key !== 'Escape' || pending) return;
35
+ onCancel();
36
+ };
37
+ window.addEventListener('keydown', onKey);
38
+ return () => window.removeEventListener('keydown', onKey);
39
+ }, [open, pending, onCancel]);
40
+
41
+ if (!open || typeof document === 'undefined') return null;
42
+
43
+ return createPortal(
44
+ <div
45
+ className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
46
+ onMouseDown={(event) => {
47
+ if (event.target === event.currentTarget && !pending) onCancel();
48
+ }}
49
+ role="alertdialog"
50
+ aria-modal="true"
51
+ aria-labelledby={titleId}
52
+ aria-describedby={descriptionId}
53
+ data-testid={testId}
54
+ >
55
+ <div className="w-[380px] max-w-[92vw] rounded-2xl border border-agent-border bg-agent-canvas p-5 shadow-2xl">
56
+ <h2 id={titleId} className="text-base font-semibold text-agent-foreground">
57
+ {title}
58
+ </h2>
59
+ <p
60
+ id={descriptionId}
61
+ className="mt-2 text-sm leading-relaxed text-agent-muted-foreground"
62
+ >
63
+ {description}
64
+ </p>
65
+ <div className="mt-5 flex justify-end gap-2">
66
+ <button
67
+ type="button"
68
+ onClick={onCancel}
69
+ disabled={pending}
70
+ autoFocus
71
+ className="h-9 rounded-full px-4 text-sm text-agent-muted-foreground transition-colors hover:bg-agent-muted hover:text-agent-foreground disabled:opacity-40"
72
+ data-testid={`${testId}-cancel`}
73
+ >
74
+ 取消
75
+ </button>
76
+ <button
77
+ type="button"
78
+ onClick={onConfirm}
79
+ disabled={pending}
80
+ className="h-9 rounded-full bg-agent-destructive px-4 text-sm font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-40"
81
+ data-testid={`${testId}-confirm`}
82
+ >
83
+ {pending ? '删除中…' : confirmLabel}
84
+ </button>
85
+ </div>
86
+ </div>
87
+ </div>,
88
+ document.body,
89
+ );
90
+ }
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * 新建 / 编辑项目弹窗(Codex 式):项目是带名字的容器,可附加多个源文件夹。
3
- * 家目录由后端建在 Documents/<应用名>/<项目名>/;源文件夹只放宽读取。
3
+ * 家目录由后端建在 Documents/<应用名>/<项目名>/;源文件夹及其子目录也可读写。
4
4
  */
5
5
  import { useEffect, useState } from 'react';
6
6
  import { createPortal } from 'react-dom';
7
7
  import { LuFolder, LuFolderPlus, LuX } from 'react-icons/lu';
8
8
  import { BRAND_NAME } from '@/brand';
9
+ import { ConfirmDialog } from '@/components/ConfirmDialog';
9
10
  import { getElectronBridge } from '@/lib/electron-bridge';
10
11
 
11
12
  export interface ProjectFormValues {
@@ -36,7 +37,7 @@ export function CreateProjectModal({
36
37
  const [folderDraft, setFolderDraft] = useState('');
37
38
  const [submitting, setSubmitting] = useState(false);
38
39
  const [deleting, setDeleting] = useState(false);
39
- const [confirmDelete, setConfirmDelete] = useState(false);
40
+ const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
40
41
  const [error, setError] = useState<string | null>(null);
41
42
  const initialFoldersKey = (initial?.sourceFolders ?? []).join('\0');
42
43
 
@@ -47,14 +48,18 @@ export function CreateProjectModal({
47
48
  setFolderDraft('');
48
49
  setSubmitting(false);
49
50
  setDeleting(false);
50
- setConfirmDelete(false);
51
+ setDeleteConfirmOpen(false);
51
52
  setError(null);
53
+ }, [open, initial?.name, initialFoldersKey]);
54
+
55
+ useEffect(() => {
56
+ if (!open || deleteConfirmOpen) return;
52
57
  const onKey = (event: KeyboardEvent) => {
53
58
  if (event.key === 'Escape') onClose();
54
59
  };
55
60
  window.addEventListener('keydown', onKey);
56
61
  return () => window.removeEventListener('keydown', onKey);
57
- }, [open, onClose, initial?.name, initialFoldersKey]);
62
+ }, [open, onClose, deleteConfirmOpen]);
58
63
 
59
64
  if (!open) return null;
60
65
  if (typeof document === 'undefined') return null;
@@ -106,10 +111,6 @@ export function CreateProjectModal({
106
111
 
107
112
  const handleDelete = async () => {
108
113
  if (!onDelete || deleting || submitting) return;
109
- if (!confirmDelete) {
110
- setConfirmDelete(true);
111
- return;
112
- }
113
114
  setDeleting(true);
114
115
  setError(null);
115
116
  try {
@@ -118,6 +119,7 @@ export function CreateProjectModal({
118
119
  } catch (err) {
119
120
  setError(err instanceof Error ? err.message : String(err));
120
121
  setDeleting(false);
122
+ setDeleteConfirmOpen(false);
121
123
  }
122
124
  };
123
125
 
@@ -170,7 +172,10 @@ export function CreateProjectModal({
170
172
  )}
171
173
 
172
174
  <div>
173
- <div className="mb-2 text-sm text-agent-foreground">源文件夹</div>
175
+ <div className="mb-1 text-sm text-agent-foreground">源文件夹</div>
176
+ <p className="mb-2 text-[11px] text-agent-muted-foreground">
177
+ 这些目录及其子目录可读写。
178
+ </p>
174
179
  <div className="overflow-hidden rounded-2xl border border-agent-border">
175
180
  {sourceFolders.map((folder) => (
176
181
  <div
@@ -232,17 +237,13 @@ export function CreateProjectModal({
232
237
  {onDelete && (
233
238
  <button
234
239
  type="button"
235
- onClick={() => void handleDelete()}
240
+ onClick={() => setDeleteConfirmOpen(true)}
236
241
  disabled={deleting || submitting}
237
- title={
238
- confirmDelete
239
- ? '再次点击确认删除(会话会保留为无项目对话)'
240
- : '删除项目(会话保留为无项目对话)'
241
- }
242
+ title="删除项目(会话保留为无项目对话)"
242
243
  className="mr-auto h-9 rounded-full px-3 text-sm text-agent-destructive transition-colors hover:bg-agent-destructive/10 disabled:opacity-40"
243
244
  data-testid="edit-project-delete"
244
245
  >
245
- {confirmDelete ? '再次点击确认删除' : '删除本地项目'}
246
+ 删除本地项目
246
247
  </button>
247
248
  )}
248
249
  <button
@@ -265,6 +266,21 @@ export function CreateProjectModal({
265
266
  </button>
266
267
  </div>
267
268
  </div>
269
+ {onDelete && (
270
+ <ConfirmDialog
271
+ open={deleteConfirmOpen}
272
+ title="删除项目"
273
+ description={`确定删除项目「${trimmed || '未命名'}」?会话会保留为无项目对话。`}
274
+ confirmLabel="删除项目"
275
+ pending={deleting}
276
+ onCancel={() => {
277
+ if (deleting) return;
278
+ setDeleteConfirmOpen(false);
279
+ }}
280
+ onConfirm={() => void handleDelete()}
281
+ testId="edit-project-delete-dialog"
282
+ />
283
+ )}
268
284
  </div>,
269
285
  document.body,
270
286
  );
@@ -158,8 +158,7 @@ export function ApprovalPromptMenu({
158
158
  </p>
159
159
  ) : isSandboxEscalation ? (
160
160
  <p className="mt-2 text-[11px] leading-relaxed text-agent-muted-foreground">
161
- 命令已在工作区沙箱中尝试,但被系统拒绝。允许后会仅重试这条原始命令,
162
- 不再限制它只能写入当前项目或本对话工作区。
161
+ 命令已在工作区沙箱中被拒绝。允许后只重试这条命令,不再限制写入范围。
163
162
  </p>
164
163
  ) : (
165
164
  <>
@@ -169,9 +168,7 @@ export function ApprovalPromptMenu({
169
168
  </p>
170
169
  )}
171
170
  <p className="mt-2 text-[11px] leading-relaxed text-agent-muted-foreground">
172
- 默认在工作区沙箱中执行(只能写入当前项目或本对话工作区)。写入
173
- Downloads 等工作区外路径会被系统拒绝;需要时在输入框把沙箱切到「完整权限」后再让
174
- Agent 重试。
171
+ 在工作区沙箱中执行,范围外的写入会被拒绝。需要写到外面时,把沙箱切到「完整权限」后再试。
175
172
  </p>
176
173
  </>
177
174
  )}
@@ -13,9 +13,14 @@ import {
13
13
  type ApprovalDecisionKind,
14
14
  type ApprovalPromptRequest,
15
15
  } from '@/lib/electron-bridge';
16
+ import { bindPromptToActiveChat, promptVisibleInChat } from './prompt-chat-scope';
16
17
 
17
18
  interface ApprovalPromptContextValue {
18
19
  queue: ApprovalPromptRequest[];
20
+ decide: (requestId: string, kind: ApprovalDecisionKind) => void;
21
+ }
22
+
23
+ interface ApprovalPromptView {
19
24
  current: ApprovalPromptRequest | null;
20
25
  pendingCount: number;
21
26
  decide: (kind: ApprovalDecisionKind) => void;
@@ -23,7 +28,10 @@ interface ApprovalPromptContextValue {
23
28
 
24
29
  const ApprovalPromptContext = createContext<ApprovalPromptContextValue | null>(null);
25
30
 
26
- /** Keeps approval prompts alive while the active composer presents them. */
31
+ /**
32
+ * Keeps approval prompts alive across chat navigation. Each composer
33
+ * presents only the prompt that belongs to its chat.
34
+ */
27
35
  export function ApprovalPromptProvider({ children }: { children: ReactNode }) {
28
36
  const [queue, setQueue] = useState<ApprovalPromptRequest[]>([]);
29
37
  const decidedRequestIds = useRef(new Set<string>());
@@ -38,10 +46,12 @@ export function ApprovalPromptProvider({ children }: { children: ReactNode }) {
38
46
  ) => {
39
47
  setQueue((previous) => {
40
48
  const known = new Set(previous.map(({ requestId }) => requestId));
41
- const additions = requests.filter(
42
- ({ requestId }) =>
43
- !known.has(requestId) && !decidedRequestIds.current.has(requestId),
44
- );
49
+ const additions = requests
50
+ .filter(
51
+ ({ requestId }) =>
52
+ !known.has(requestId) && !decidedRequestIds.current.has(requestId),
53
+ )
54
+ .map(bindPromptToActiveChat);
45
55
  if (additions.length === 0) return previous;
46
56
  return placement === 'prepend'
47
57
  ? [...additions, ...previous]
@@ -65,26 +75,19 @@ export function ApprovalPromptProvider({ children }: { children: ReactNode }) {
65
75
  };
66
76
  }, []);
67
77
 
68
- const current = queue[0] ?? null;
69
- const decide = useCallback(
70
- (kind: ApprovalDecisionKind) => {
71
- if (!current) return;
72
- const bridge = getElectronBridge();
73
- decidedRequestIds.current.add(current.requestId);
74
- setQueue((previous) => previous.slice(1));
75
- void bridge?.approval?.decide({ requestId: current.requestId, kind });
76
- },
77
- [current],
78
- );
78
+ const decide = useCallback((requestId: string, kind: ApprovalDecisionKind) => {
79
+ const bridge = getElectronBridge();
80
+ decidedRequestIds.current.add(requestId);
81
+ setQueue((previous) => previous.filter((request) => request.requestId !== requestId));
82
+ void bridge?.approval?.decide({ requestId, kind });
83
+ }, []);
79
84
 
80
85
  const value = useMemo(
81
86
  () => ({
82
87
  queue,
83
- current,
84
- pendingCount: Math.max(0, queue.length - 1),
85
88
  decide,
86
89
  }),
87
- [queue, current, decide],
90
+ [decide, queue],
88
91
  );
89
92
 
90
93
  return (
@@ -94,9 +97,22 @@ export function ApprovalPromptProvider({ children }: { children: ReactNode }) {
94
97
  );
95
98
  }
96
99
 
97
- /** Returns the approval prompt currently assigned to the chat composer. */
98
- export function useApprovalPrompt(): ApprovalPromptContextValue | null {
99
- return useContext(ApprovalPromptContext);
100
+ /** Returns the approval prompt for this composer’s chat, if one is waiting. */
101
+ export function useApprovalPrompt(chatId?: string | null): ApprovalPromptView | null {
102
+ const context = useContext(ApprovalPromptContext);
103
+ return useMemo(() => {
104
+ if (!context) return null;
105
+ const mine = context.queue.filter((request) => promptVisibleInChat(request, chatId));
106
+ const current = mine[0] ?? null;
107
+ return {
108
+ current,
109
+ pendingCount: Math.max(0, mine.length - 1),
110
+ decide: (kind) => {
111
+ if (!current) return;
112
+ context.decide(current.requestId, kind);
113
+ },
114
+ };
115
+ }, [chatId, context]);
100
116
  }
101
117
 
102
118
  /** 返回存在未处理工具权限审批的会话 ID 集合。 */
@@ -9,6 +9,7 @@ import {
9
9
  type ReactNode,
10
10
  } from 'react';
11
11
  import { getElectronBridge, type AskUserPromptRequest } from '@/lib/electron-bridge';
12
+ import { bindPromptToActiveChat, promptVisibleInChat } from './prompt-chat-scope';
12
13
 
13
14
  type AnswerValue = string | string[];
14
15
 
@@ -23,38 +24,6 @@ interface AskUserPromptView {
23
24
  answer: (answers: Record<string, AnswerValue>) => void;
24
25
  }
25
26
 
26
- /**
27
- * Chat id from `#/agent/:chatId` (hash router) or `/agent/:chatId`.
28
- * The running server build drops `chatId` on the ask-user payload.
29
- */
30
- function chatIdFromLocation(location: { pathname: string; hash: string }): string | null {
31
- const hashed = location.hash.startsWith('#') ? location.hash.slice(1) : '';
32
- const raw = hashed || location.pathname;
33
- const match = raw.match(/\/agent\/([^/?#]+)/);
34
- if (!match?.[1]) return null;
35
- try {
36
- return decodeURIComponent(match[1]);
37
- } catch {
38
- return match[1];
39
- }
40
- }
41
-
42
- function bindPromptToActiveChat(request: AskUserPromptRequest): AskUserPromptRequest {
43
- if (request.chatId) return request;
44
- const chatId = chatIdFromLocation(window.location);
45
- if (!chatId) return request;
46
- return { ...request, chatId };
47
- }
48
-
49
- /** A prompt with a chat id belongs only to that chat. A prompt without one stays visible so an older request is still answerable. */
50
- function promptVisibleInChat(
51
- request: AskUserPromptRequest,
52
- chatId: string | null | undefined,
53
- ): boolean {
54
- if (!request.chatId) return !chatId;
55
- return request.chatId === chatId;
56
- }
57
-
58
27
  const AskUserPromptContext = createContext<AskUserPromptContextValue | null>(null);
59
28
 
60
29
  /**