@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
|
@@ -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 [
|
|
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
|
-
|
|
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,
|
|
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-
|
|
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={() =>
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
42
|
-
(
|
|
43
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
[
|
|
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
|
|
98
|
-
export function useApprovalPrompt():
|
|
99
|
-
|
|
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
|
/**
|