@steerable/agent-shell-web 0.6.50 → 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 +23 -13
- package/src/components/chat/LocalChatPanel.tsx +61 -18
- package/src/components/chat/MessageList.tsx +77 -5
- package/src/components/chat/ModelPicker.tsx +21 -1
- package/src/components/chat/OrchestrationFlowCard.tsx +484 -0
- package/src/components/chat/SessionOrchestrationFlow.tsx +573 -0
- 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 +401 -0
- 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/OrchestrationSettingsPanel.tsx +67 -0
- package/src/components/settings/SettingsNavMenu.tsx +55 -0
- 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/lib/orchestration-settings.ts +58 -0
- package/src/pages/AgentPage.tsx +116 -64
- package/src/pages/SettingsPage.tsx +338 -55
- 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
|
/**
|
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
LuArrowUp,
|
|
20
20
|
LuBot,
|
|
21
21
|
LuChevronDown,
|
|
22
|
-
LuSettings,
|
|
23
22
|
LuSquare,
|
|
24
23
|
LuBlocks,
|
|
25
24
|
LuPlug,
|
|
@@ -61,7 +60,7 @@ export type { ExecPolicy, McpToolItem, SkillItem };
|
|
|
61
60
|
* product's `chat-input-box`).
|
|
62
61
|
* - Auto-growing rich text input (1 → 8 lines) that grows as the user types.
|
|
63
62
|
* - `Enter` to send, `Shift+Enter` for newline (`Cmd/Ctrl+Enter` also sends;
|
|
64
|
-
*
|
|
63
|
+
* while streaming, Enter queues and Cmd/Ctrl+Enter steers the current turn).
|
|
65
64
|
* - Send button on the right; turns into a Stop button when streaming.
|
|
66
65
|
* - Externally-controlled value (`LocalChatPanel` lifts it so EmptyChat can
|
|
67
66
|
* inject prompts via `setAndFocusInputMessage`).
|
|
@@ -107,15 +106,15 @@ export interface ChatInputProps {
|
|
|
107
106
|
onSubmit: () => void | Promise<void>;
|
|
108
107
|
onCancel?: () => void;
|
|
109
108
|
/**
|
|
110
|
-
*
|
|
109
|
+
* 轮中插队(streaming 期间 ⌘/Ctrl+Enter):优先把文本注入运行中的回合;注入
|
|
111
110
|
* 失败由 hook 兜底——回合仍在跑则排入 W6-2 follow-up 队列('queued',
|
|
112
111
|
* 本组件提示"已改为排队"),回合恰好已结束则作为新消息直接发出
|
|
113
112
|
* ('sent')。三种结果消息都已落地,调用方据此清空草稿。
|
|
114
113
|
*/
|
|
115
114
|
onSteer?: (text: string) => Promise<SteerOutcome>;
|
|
116
115
|
/**
|
|
117
|
-
* W6-2 follow-up 队列:streaming 期间按
|
|
118
|
-
*
|
|
116
|
+
* W6-2 follow-up 队列:streaming 期间按 Enter 把当前文本排入待发队列,
|
|
117
|
+
* 本轮结束后自动作为下一轮发出(与 ⌘/Ctrl+Enter 的轮中插队相对)。
|
|
119
118
|
*/
|
|
120
119
|
onFollowUp?: (text: string) => void;
|
|
121
120
|
/** 当前排队待发的 follow-up 文本(用于在输入框上方展示待发队列)。 */
|
|
@@ -788,7 +787,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
788
787
|
chats = [],
|
|
789
788
|
selectedAgentId,
|
|
790
789
|
onSelectAgent,
|
|
791
|
-
onOpenSettings,
|
|
790
|
+
onOpenSettings: _onOpenSettings,
|
|
792
791
|
toolbarExtras,
|
|
793
792
|
leadingChrome,
|
|
794
793
|
trailingChrome,
|
|
@@ -806,7 +805,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
806
805
|
ref,
|
|
807
806
|
) {
|
|
808
807
|
const askUserPrompt = useAskUserPrompt(chatId);
|
|
809
|
-
const approvalPrompt = useApprovalPrompt();
|
|
808
|
+
const approvalPrompt = useApprovalPrompt(chatId);
|
|
810
809
|
const editorRef = useRef<HTMLDivElement>(null);
|
|
811
810
|
const agentMenuRef = useRef<HTMLDivElement>(null);
|
|
812
811
|
const skillOptionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
@@ -863,7 +862,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
863
862
|
const [mentionQuery, setMentionQuery] = useState<MentionQuery | null>(null);
|
|
864
863
|
const [selectedMentionIndex, setSelectedMentionIndex] = useState(0);
|
|
865
864
|
const [mentionReferences, setMentionReferences] = useState<MentionReference[]>([]);
|
|
866
|
-
//
|
|
865
|
+
// 插队降级提示:⌘/Ctrl+Enter 插队失败被兜底进 W6-2 队列时告知用户,
|
|
867
866
|
// 几秒后自动消失(队列横幅本身常驻,提示只是动作反馈)。
|
|
868
867
|
const [steerNotice, setSteerNotice] = useState<string | null>(null);
|
|
869
868
|
const steerNoticeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -1145,15 +1144,17 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1145
1144
|
}
|
|
1146
1145
|
}, [value, mentionReferences, agents, chats, disabled, skills, mcpTools]);
|
|
1147
1146
|
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1147
|
+
// Escape stops the current turn, matching Codex's graphical composer.
|
|
1148
|
+
// Cmd/Ctrl+. remains as a compatibility alias. Bound at the window level
|
|
1149
|
+
// so users can stop without the editor being focused.
|
|
1151
1150
|
// Confined to the `isStreaming` window so it never fights with other
|
|
1152
1151
|
// app-level shortcuts when the chat is idle.
|
|
1153
1152
|
useEffect(() => {
|
|
1154
1153
|
if (!isStreaming || !onCancel) return;
|
|
1155
1154
|
const onKey = (event: globalThis.KeyboardEvent) => {
|
|
1156
|
-
|
|
1155
|
+
const isStopAlias = (event.metaKey || event.ctrlKey) && event.key === '.';
|
|
1156
|
+
const isEscape = event.key === 'Escape' && !event.metaKey && !event.ctrlKey && !event.altKey;
|
|
1157
|
+
if (!event.defaultPrevented && (isStopAlias || isEscape)) {
|
|
1157
1158
|
event.preventDefault();
|
|
1158
1159
|
onCancel();
|
|
1159
1160
|
}
|
|
@@ -1497,11 +1498,14 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1497
1498
|
requestHostPaste(event.currentTarget);
|
|
1498
1499
|
return;
|
|
1499
1500
|
}
|
|
1500
|
-
//
|
|
1501
|
-
//
|
|
1502
|
-
//
|
|
1503
|
-
// the
|
|
1504
|
-
|
|
1501
|
+
// An open composition session owns the keystroke, including Enter used
|
|
1502
|
+
// to confirm the highlighted candidate. Returning without
|
|
1503
|
+
// preventDefault lets the IME commit the word. WKWebView can report
|
|
1504
|
+
// isComposing / keyCode 229 on Enter after the session has already
|
|
1505
|
+
// ended; that case is not an open session (`isComposingRef` is false)
|
|
1506
|
+
// and must still send.
|
|
1507
|
+
if (isComposingRef.current) return;
|
|
1508
|
+
if (event.key !== 'Enter' && event.nativeEvent.isComposing) return;
|
|
1505
1509
|
|
|
1506
1510
|
if (event.key === 'Backspace' || event.key === 'Delete') {
|
|
1507
1511
|
const editor = editorRef.current;
|
|
@@ -1588,31 +1592,31 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1588
1592
|
}
|
|
1589
1593
|
}
|
|
1590
1594
|
|
|
1591
|
-
// Enter
|
|
1595
|
+
// 空闲时 Enter / ⌘/Ctrl+Enter 发送,Shift+Enter 换行。
|
|
1592
1596
|
// IME 候选确认的 Enter 已被上面的 composition 守卫拦截,不会误发送。
|
|
1593
1597
|
const isSendCombo =
|
|
1594
1598
|
event.key === 'Enter' && !event.shiftKey;
|
|
1595
1599
|
if (isSendCombo) {
|
|
1596
1600
|
event.preventDefault();
|
|
1597
|
-
// streaming 期间:Enter =
|
|
1598
|
-
//
|
|
1599
|
-
//
|
|
1601
|
+
// streaming 期间:Enter = follow-up 排队(本轮结束后自动作为下一轮
|
|
1602
|
+
// 发出);⌘/Ctrl+Enter = 轮中插队(注入运行中的回合;注入不了时
|
|
1603
|
+
// hook 兜底为 W6-2 排队或新回合直发,消息不会丢)。
|
|
1600
1604
|
if (isStreaming) {
|
|
1601
|
-
if ((event.metaKey || event.ctrlKey) &&
|
|
1602
|
-
onFollowUp(trimmed);
|
|
1603
|
-
onChange('');
|
|
1604
|
-
return;
|
|
1605
|
-
}
|
|
1606
|
-
if (onSteer && trimmed) {
|
|
1605
|
+
if ((event.metaKey || event.ctrlKey) && onSteer && trimmed) {
|
|
1607
1606
|
void onSteer(trimmed).then((outcome) => {
|
|
1608
1607
|
// steered / queued / sent 三种结果消息都已落地(注入当前回合 /
|
|
1609
1608
|
// 进入待发队列 / 作为新回合发出),草稿都可以清;queued 额外
|
|
1610
1609
|
// 提示用户"不是追加进当前回合"。
|
|
1611
1610
|
if (outcome === 'queued') {
|
|
1612
|
-
showSteerNotice('
|
|
1611
|
+
showSteerNotice('当前回合无法插队,已改为排队,本轮结束后自动发出');
|
|
1613
1612
|
}
|
|
1614
1613
|
onChange('');
|
|
1615
1614
|
});
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
if (onFollowUp && trimmed) {
|
|
1618
|
+
onFollowUp(trimmed);
|
|
1619
|
+
onChange('');
|
|
1616
1620
|
}
|
|
1617
1621
|
return;
|
|
1618
1622
|
}
|
|
@@ -1705,7 +1709,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1705
1709
|
<div className="chat-input-container @container relative px-2.5 pb-2.5 pt-1">
|
|
1706
1710
|
{(leadingChrome || selectedAgent || trailingChrome) && (
|
|
1707
1711
|
<div
|
|
1708
|
-
className="relative mb-1.5 flex min-w-0 items-
|
|
1712
|
+
className="relative mb-1.5 flex min-w-0 items-end gap-1.5"
|
|
1709
1713
|
data-testid="composer-meta-row"
|
|
1710
1714
|
>
|
|
1711
1715
|
{leadingChrome}
|
|
@@ -1872,7 +1876,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1872
1876
|
{pendingFollowUps.length > 0 && (
|
|
1873
1877
|
<div className="flex flex-col gap-1 border-b border-agent-border bg-agent-muted/30 px-2.5 py-1.5">
|
|
1874
1878
|
<div className="text-[11px] font-medium text-agent-muted-foreground">
|
|
1875
|
-
排队中({pendingFollowUps.length})· 本轮结束后自动发出 ·
|
|
1879
|
+
排队中({pendingFollowUps.length})· 本轮结束后自动发出 · 停止后恢复到输入框
|
|
1876
1880
|
</div>
|
|
1877
1881
|
{pendingFollowUps.map((text, i) => (
|
|
1878
1882
|
<div
|
|
@@ -1993,18 +1997,6 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
1993
1997
|
/>
|
|
1994
1998
|
</>
|
|
1995
1999
|
)}
|
|
1996
|
-
{onOpenSettings && (
|
|
1997
|
-
<button
|
|
1998
|
-
type="button"
|
|
1999
|
-
onClick={onOpenSettings}
|
|
2000
|
-
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
2001
|
-
title="LLM 设置"
|
|
2002
|
-
aria-label="LLM 设置"
|
|
2003
|
-
data-testid="chat-llm-settings"
|
|
2004
|
-
>
|
|
2005
|
-
<LuSettings className="h-3 w-3" />
|
|
2006
|
-
</button>
|
|
2007
|
-
)}
|
|
2008
2000
|
</div>
|
|
2009
2001
|
{/* shrink-0:发送键是固定尺寸圆钮,窄容器下不允许被压扁;
|
|
2010
2002
|
收缩量由左侧可截断的选择器吸收。 */}
|
|
@@ -2016,23 +2008,15 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
2016
2008
|
>
|
|
2017
2009
|
{isStreaming ? (
|
|
2018
2010
|
<>
|
|
2019
|
-
|
|
2020
|
-
{MOD_KEY_LABEL}
|
|
2021
|
-
</kbd>
|
|
2022
|
-
<span className="mx-0.5">+</span>
|
|
2023
|
-
<kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
|
|
2024
|
-
.
|
|
2025
|
-
</kbd>
|
|
2026
|
-
<span className="ml-1">停止</span>
|
|
2027
|
-
{onSteer && (
|
|
2011
|
+
{onFollowUp && (
|
|
2028
2012
|
<span className="ml-2">
|
|
2029
2013
|
<kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
|
|
2030
2014
|
Enter
|
|
2031
2015
|
</kbd>
|
|
2032
|
-
<span className="ml-1"
|
|
2016
|
+
<span className="ml-1">排队</span>
|
|
2033
2017
|
</span>
|
|
2034
2018
|
)}
|
|
2035
|
-
{
|
|
2019
|
+
{onSteer && (
|
|
2036
2020
|
<span className="ml-2">
|
|
2037
2021
|
<kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
|
|
2038
2022
|
{MOD_KEY_LABEL}
|
|
@@ -2041,9 +2025,15 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
2041
2025
|
<kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
|
|
2042
2026
|
Enter
|
|
2043
2027
|
</kbd>
|
|
2044
|
-
<span className="ml-1"
|
|
2028
|
+
<span className="ml-1">插队</span>
|
|
2045
2029
|
</span>
|
|
2046
2030
|
)}
|
|
2031
|
+
<span className="ml-2">
|
|
2032
|
+
<kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
|
|
2033
|
+
Esc
|
|
2034
|
+
</kbd>
|
|
2035
|
+
<span className="ml-1">停止</span>
|
|
2036
|
+
</span>
|
|
2047
2037
|
</>
|
|
2048
2038
|
) : (
|
|
2049
2039
|
<>
|
|
@@ -2079,8 +2069,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
|
2079
2069
|
title={
|
|
2080
2070
|
isStreaming
|
|
2081
2071
|
? pendingFollowUps.length > 0
|
|
2082
|
-
? `停止生成 (
|
|
2083
|
-
:
|
|
2072
|
+
? `停止生成 (Esc) · ${pendingFollowUps.length} 条排队消息将恢复到输入框`
|
|
2073
|
+
: '停止生成 (Esc)'
|
|
2084
2074
|
: '发送 (Enter)'
|
|
2085
2075
|
}
|
|
2086
2076
|
aria-label={isStreaming ? '停止生成' : '发送消息'}
|
|
@@ -2291,7 +2281,7 @@ function AgentSelect({
|
|
|
2291
2281
|
data-testid="agent-manage"
|
|
2292
2282
|
onClick={() => {
|
|
2293
2283
|
onOpenChange(false);
|
|
2294
|
-
navigate('/settings?section=agents');
|
|
2284
|
+
navigate('/settings?section=plugins&tab=agents');
|
|
2295
2285
|
}}
|
|
2296
2286
|
className="mt-0.5 flex w-full items-center gap-2 rounded border-t border-agent-border/50 px-2 py-2 text-left text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
2297
2287
|
>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { BRAND_HOME_HINT, BRAND_NAME, getBrandLogoUrl } from '@/brand';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 新对话落地页与空会话首屏的品牌区:侧栏同一枚 logo,加一句
|
|
5
|
+
* 产品可配的副文案(brand.homeHint)。
|
|
6
|
+
*/
|
|
7
|
+
export function EmptyChatHero() {
|
|
8
|
+
const hint = BRAND_HOME_HINT.trim();
|
|
9
|
+
return (
|
|
10
|
+
<div className="text-center">
|
|
11
|
+
<h1>
|
|
12
|
+
<img
|
|
13
|
+
src={getBrandLogoUrl()}
|
|
14
|
+
alt={BRAND_NAME}
|
|
15
|
+
className="mx-auto h-12 w-auto max-w-[12rem] select-none object-contain"
|
|
16
|
+
draggable={false}
|
|
17
|
+
/>
|
|
18
|
+
</h1>
|
|
19
|
+
{hint ? <p className="mt-1.5 text-xs text-agent-muted-foreground">{hint}</p> : null}
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|