@steerable/agent-shell-web 0.6.51 → 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.
Files changed (34) 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 +47 -57
  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 +1 -1
  26. package/src/components/settings/SkillsSettingsPanel.tsx +1 -1
  27. package/src/hooks/useProjects.ts +82 -0
  28. package/src/lib/electron-bridge.ts +1 -0
  29. package/src/lib/exec-policy.ts +1 -1
  30. package/src/lib/local-api.ts +1 -1
  31. package/src/pages/AgentPage.tsx +114 -64
  32. package/src/pages/SettingsPage.tsx +131 -46
  33. package/src/vite-env.d.ts +2 -0
  34. 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
  /**
@@ -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
- * IME composition is guarded so confirming a candidate never submits).
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
- * 轮中转向(streaming 期间 Enter):优先把文本注入运行中的回合;注入
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 期间按 ⌘/Ctrl+Enter 把当前文本排入
118
- * 待发队列,本轮结束后自动作为下一轮发出(与 Enter 的轮中转向相对)。
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
- // 转向降级提示:Enter 转向失败被兜底进 W6-2 队列时告知用户"不是注入",
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
- // `Cmd+.` / `Ctrl+.` cancels the in-flight stream — matches macOS's
1149
- // conventional "interrupt" chord (Xcode, Terminal). Bound at the window
1150
- // level so users can hit it without the textarea being focused.
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
- if ((event.metaKey || event.ctrlKey) && event.key === '.') {
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
- // Honor IME composition (`isComposing` true while Chinese input is mid-
1501
- // selection) — only intercept on plain key events. Enter is exempt
1502
- // because WKWebView reports it with isComposing/keyCode 229 even after
1503
- // the candidate is committed.
1504
- if (event.key !== 'Enter' && (isComposingRef.current || event.nativeEvent.isComposing)) return;
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 发送,Shift+Enter 换行(⌘/Ctrl+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
- // hook 兜底为 W6-2 排队或新回合直发,消息不会丢);⌘/Ctrl+Enter =
1599
- // follow-up 排队(本轮结束后自动作为下一轮发出)。
1601
+ // streaming 期间:Enter = follow-up 排队(本轮结束后自动作为下一轮
1602
+ // 发出);⌘/Ctrl+Enter = 轮中插队(注入运行中的回合;注入不了时
1603
+ // hook 兜底为 W6-2 排队或新回合直发,消息不会丢)。
1600
1604
  if (isStreaming) {
1601
- if ((event.metaKey || event.ctrlKey) && onFollowUp && trimmed) {
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-center gap-1.5"
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
- <kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
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">追加</span>
2016
+ <span className="ml-1">排队</span>
2033
2017
  </span>
2034
2018
  )}
2035
- {onFollowUp && (
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">排队</span>
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
- ? `停止生成 (${MOD_KEY_LABEL}+.) · 将丢弃 ${pendingFollowUps.length} 条排队消息`
2083
- : `停止生成 (${MOD_KEY_LABEL}+.)`
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
  >
@@ -241,7 +241,7 @@ export function ChatProjectBadge({
241
241
  title={
242
242
  project
243
243
  ? `${project.folderPath}\n点击管理项目归属 / 修改绑定目录`
244
- : '把当前对话关联到一个项目(Agent 的文件操作将限制在项目目录内)'
244
+ : '把当前对话关联到一个项目(文件写入限制在项目家目录、源文件夹,以及这些目录的子目录内)'
245
245
  }
246
246
  />
247
247
 
@@ -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
+ }