@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
@@ -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,
@@ -33,8 +32,9 @@ import {
33
32
  import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
34
33
  import type { ExecPolicy } from '@/lib/exec-policy';
35
34
  import { getWebChatModes, hostToolCapability, settingsChrome } from '@/lib/host-tools';
36
- import { hostClipboardAvailable, requestHostPaste } from '@/lib/host-paste';
37
- import type { AttachmentFile } from '@/lib/attachments';
35
+ import { hostClipboardAvailable, requestHostPaste, type HostClipboardFile } from '@/lib/host-paste';
36
+ import { attachmentFromPath, type AttachmentFile } from '@/lib/attachments';
37
+ import { dropPointHitsRect, hostFileDropAvailable, listenHostFileDrop } from '@/lib/host-file-drop';
38
38
  import type { SteerOutcome } from '@steerable/agent-ui';
39
39
  import {
40
40
  isHiddenSlashSkill,
@@ -61,7 +61,7 @@ export type { ExecPolicy, McpToolItem, SkillItem };
61
61
  * product's `chat-input-box`).
62
62
  * - Auto-growing rich text input (1 → 8 lines) that grows as the user types.
63
63
  * - `Enter` to send, `Shift+Enter` for newline (`Cmd/Ctrl+Enter` also sends;
64
- * IME composition is guarded so confirming a candidate never submits).
64
+ * while streaming, Enter queues and Cmd/Ctrl+Enter steers the current turn).
65
65
  * - Send button on the right; turns into a Stop button when streaming.
66
66
  * - Externally-controlled value (`LocalChatPanel` lifts it so EmptyChat can
67
67
  * inject prompts via `setAndFocusInputMessage`).
@@ -107,15 +107,15 @@ export interface ChatInputProps {
107
107
  onSubmit: () => void | Promise<void>;
108
108
  onCancel?: () => void;
109
109
  /**
110
- * 轮中转向(streaming 期间 Enter):优先把文本注入运行中的回合;注入
110
+ * 轮中插队(streaming 期间 ⌘/Ctrl+Enter):优先把文本注入运行中的回合;注入
111
111
  * 失败由 hook 兜底——回合仍在跑则排入 W6-2 follow-up 队列('queued',
112
112
  * 本组件提示"已改为排队"),回合恰好已结束则作为新消息直接发出
113
113
  * ('sent')。三种结果消息都已落地,调用方据此清空草稿。
114
114
  */
115
115
  onSteer?: (text: string) => Promise<SteerOutcome>;
116
116
  /**
117
- * W6-2 follow-up 队列:streaming 期间按 ⌘/Ctrl+Enter 把当前文本排入
118
- * 待发队列,本轮结束后自动作为下一轮发出(与 Enter 的轮中转向相对)。
117
+ * W6-2 follow-up 队列:streaming 期间按 Enter 把当前文本排入待发队列,
118
+ * 本轮结束后自动作为下一轮发出(与 ⌘/Ctrl+Enter 的轮中插队相对)。
119
119
  */
120
120
  onFollowUp?: (text: string) => void;
121
121
  /** 当前排队待发的 follow-up 文本(用于在输入框上方展示待发队列)。 */
@@ -715,6 +715,23 @@ function clipboardOf(event: ClipboardEvent<HTMLDivElement>): DataTransfer | null
715
715
  return native.clipboardData ?? event.clipboardData ?? null;
716
716
  }
717
717
 
718
+ function fileFromHostClipboard(file: HostClipboardFile): File | null {
719
+ if (!file.dataBase64) return null;
720
+ let binary: string;
721
+ try {
722
+ binary = atob(file.dataBase64);
723
+ } catch {
724
+ return null;
725
+ }
726
+ const bytes = new Uint8Array(binary.length);
727
+ for (let index = 0; index < binary.length; index += 1) {
728
+ bytes[index] = binary.charCodeAt(index);
729
+ }
730
+ return new File([bytes], file.name || 'image.png', {
731
+ type: file.mime || 'application/octet-stream',
732
+ });
733
+ }
734
+
718
735
  function namePastedImage(file: File, serial: number, now: Date): File {
719
736
  if (!file.type.startsWith('image/')) return file;
720
737
  if (file.name.trim() && !GENERIC_CLIPBOARD_IMAGE_NAME.test(file.name.trim())) return file;
@@ -788,7 +805,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
788
805
  chats = [],
789
806
  selectedAgentId,
790
807
  onSelectAgent,
791
- onOpenSettings,
808
+ onOpenSettings: _onOpenSettings,
792
809
  toolbarExtras,
793
810
  leadingChrome,
794
811
  trailingChrome,
@@ -806,7 +823,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
806
823
  ref,
807
824
  ) {
808
825
  const askUserPrompt = useAskUserPrompt(chatId);
809
- const approvalPrompt = useApprovalPrompt();
826
+ const approvalPrompt = useApprovalPrompt(chatId);
810
827
  const editorRef = useRef<HTMLDivElement>(null);
811
828
  const agentMenuRef = useRef<HTMLDivElement>(null);
812
829
  const skillOptionRefs = useRef<Array<HTMLButtonElement | null>>([]);
@@ -836,7 +853,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
836
853
  const compositionEndAtRef = useRef<number>(0);
837
854
 
838
855
  const fileInputRef = useRef<HTMLInputElement>(null);
856
+ const dropBoxRef = useRef<HTMLDivElement>(null);
839
857
  const pasteSerialRef = useRef(0);
858
+ const attachDroppedRef = useRef<(paths: string[]) => void>(() => {});
859
+ const dragHoverRef = useRef<(hovering: boolean) => void>(() => {});
860
+ const hostPasteFilesRef = useRef<(files: HostClipboardFile[]) => void>(() => {});
840
861
  const adoptPastedTextRef = useRef<(text: string) => void>(() => {});
841
862
  // 工具栏"空间不足时优先隐藏快捷键提示"的测量 refs,见下方 useLayoutEffect。
842
863
  const toolbarRowRef = useRef<HTMLDivElement>(null);
@@ -863,7 +884,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
863
884
  const [mentionQuery, setMentionQuery] = useState<MentionQuery | null>(null);
864
885
  const [selectedMentionIndex, setSelectedMentionIndex] = useState(0);
865
886
  const [mentionReferences, setMentionReferences] = useState<MentionReference[]>([]);
866
- // 转向降级提示:Enter 转向失败被兜底进 W6-2 队列时告知用户"不是注入",
887
+ // 插队降级提示:⌘/Ctrl+Enter 插队失败被兜底进 W6-2 队列时告知用户,
867
888
  // 几秒后自动消失(队列横幅本身常驻,提示只是动作反馈)。
868
889
  const [steerNotice, setSteerNotice] = useState<string | null>(null);
869
890
  const steerNoticeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -1145,15 +1166,17 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1145
1166
  }
1146
1167
  }, [value, mentionReferences, agents, chats, disabled, skills, mcpTools]);
1147
1168
 
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.
1169
+ // Escape stops the current turn, matching Codex's graphical composer.
1170
+ // Cmd/Ctrl+. remains as a compatibility alias. Bound at the window level
1171
+ // so users can stop without the editor being focused.
1151
1172
  // Confined to the `isStreaming` window so it never fights with other
1152
1173
  // app-level shortcuts when the chat is idle.
1153
1174
  useEffect(() => {
1154
1175
  if (!isStreaming || !onCancel) return;
1155
1176
  const onKey = (event: globalThis.KeyboardEvent) => {
1156
- if ((event.metaKey || event.ctrlKey) && event.key === '.') {
1177
+ const isStopAlias = (event.metaKey || event.ctrlKey) && event.key === '.';
1178
+ const isEscape = event.key === 'Escape' && !event.metaKey && !event.ctrlKey && !event.altKey;
1179
+ if (!event.defaultPrevented && (isStopAlias || isEscape)) {
1157
1180
  event.preventDefault();
1158
1181
  onCancel();
1159
1182
  }
@@ -1239,6 +1262,33 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1239
1262
  path: (file as { path?: string }).path || '',
1240
1263
  file,
1241
1264
  });
1265
+ attachDroppedRef.current = (paths) => {
1266
+ if (disabled || !allowFileAttach) return;
1267
+ addFiles(paths.map(attachmentFromPath));
1268
+ };
1269
+ dragHoverRef.current = (hovering) => {
1270
+ if (disabled || !allowFileAttach) {
1271
+ setIsDragging(false);
1272
+ return;
1273
+ }
1274
+ setIsDragging(hovering);
1275
+ };
1276
+ hostPasteFilesRef.current = (incoming) => {
1277
+ if (disabled || !allowFileAttach) return;
1278
+ const now = new Date();
1279
+ const next: AttachmentFile[] = [];
1280
+ for (const item of incoming) {
1281
+ if (item.path) {
1282
+ next.push(attachmentFromPath(item.path));
1283
+ continue;
1284
+ }
1285
+ const blob = fileFromHostClipboard(item);
1286
+ if (!blob) continue;
1287
+ pasteSerialRef.current += 1;
1288
+ next.push(toAttachment(namePastedImage(blob, pasteSerialRef.current, now)));
1289
+ }
1290
+ if (next.length > 0) addFiles(next);
1291
+ };
1242
1292
 
1243
1293
  // 按钮点击 → 触发隐藏的 <input type="file" multiple>,由浏览器/Electron
1244
1294
  // 直接弹出系统文件选择框(不依赖 IPC,避免「点击无反应」)。
@@ -1448,14 +1498,44 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1448
1498
  event.preventDefault();
1449
1499
  adoptPastedTextRef.current(text);
1450
1500
  };
1501
+ const onHostPasteFiles = (event: Event) => {
1502
+ const files = (event as CustomEvent<HostClipboardFile[]>).detail;
1503
+ if (!Array.isArray(files) || files.length === 0) return;
1504
+ event.preventDefault();
1505
+ hostPasteFilesRef.current(files);
1506
+ };
1451
1507
  editor.addEventListener('compositionstart', onCompositionStart, true);
1452
1508
  editor.addEventListener('hostpaste', onHostPaste);
1509
+ editor.addEventListener('hostpastefiles', onHostPasteFiles);
1453
1510
  return () => {
1454
1511
  editor.removeEventListener('compositionstart', onCompositionStart, true);
1455
1512
  editor.removeEventListener('hostpaste', onHostPaste);
1513
+ editor.removeEventListener('hostpastefiles', onHostPasteFiles);
1456
1514
  };
1457
1515
  }, []);
1458
1516
 
1517
+ useEffect(() => {
1518
+ if (!hostFileDropAvailable()) return undefined;
1519
+ return listenHostFileDrop((event) => {
1520
+ const box = dropBoxRef.current;
1521
+ if (event.type === 'leave' || !box || event.x == null || event.y == null) {
1522
+ dragHoverRef.current(false);
1523
+ return;
1524
+ }
1525
+ const hit = dropPointHitsRect(
1526
+ { x: event.x, y: event.y },
1527
+ box.getBoundingClientRect(),
1528
+ window.devicePixelRatio,
1529
+ );
1530
+ if (event.type === 'drop') {
1531
+ dragHoverRef.current(false);
1532
+ if (hit) attachDroppedRef.current(event.paths);
1533
+ return;
1534
+ }
1535
+ dragHoverRef.current(hit);
1536
+ });
1537
+ }, []);
1538
+
1459
1539
  const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
1460
1540
  // Read before preventDefault. WebKit clears clipboardData once the paste is canceled.
1461
1541
  const data = clipboardOf(event);
@@ -1497,11 +1577,14 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1497
1577
  requestHostPaste(event.currentTarget);
1498
1578
  return;
1499
1579
  }
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;
1580
+ // An open composition session owns the keystroke, including Enter used
1581
+ // to confirm the highlighted candidate. Returning without
1582
+ // preventDefault lets the IME commit the word. WKWebView can report
1583
+ // isComposing / keyCode 229 on Enter after the session has already
1584
+ // ended; that case is not an open session (`isComposingRef` is false)
1585
+ // and must still send.
1586
+ if (isComposingRef.current) return;
1587
+ if (event.key !== 'Enter' && event.nativeEvent.isComposing) return;
1505
1588
 
1506
1589
  if (event.key === 'Backspace' || event.key === 'Delete') {
1507
1590
  const editor = editorRef.current;
@@ -1588,31 +1671,31 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1588
1671
  }
1589
1672
  }
1590
1673
 
1591
- // Enter 发送,Shift+Enter 换行(⌘/Ctrl+Enter 作为老习惯保留,同样发送)。
1674
+ // 空闲时 Enter / ⌘/Ctrl+Enter 发送,Shift+Enter 换行。
1592
1675
  // IME 候选确认的 Enter 已被上面的 composition 守卫拦截,不会误发送。
1593
1676
  const isSendCombo =
1594
1677
  event.key === 'Enter' && !event.shiftKey;
1595
1678
  if (isSendCombo) {
1596
1679
  event.preventDefault();
1597
- // streaming 期间:Enter = 轮中转向(注入运行中的回合;注入不了时
1598
- // hook 兜底为 W6-2 排队或新回合直发,消息不会丢);⌘/Ctrl+Enter =
1599
- // follow-up 排队(本轮结束后自动作为下一轮发出)。
1680
+ // streaming 期间:Enter = follow-up 排队(本轮结束后自动作为下一轮
1681
+ // 发出);⌘/Ctrl+Enter = 轮中插队(注入运行中的回合;注入不了时
1682
+ // hook 兜底为 W6-2 排队或新回合直发,消息不会丢)。
1600
1683
  if (isStreaming) {
1601
- if ((event.metaKey || event.ctrlKey) && onFollowUp && trimmed) {
1602
- onFollowUp(trimmed);
1603
- onChange('');
1604
- return;
1605
- }
1606
- if (onSteer && trimmed) {
1684
+ if ((event.metaKey || event.ctrlKey) && onSteer && trimmed) {
1607
1685
  void onSteer(trimmed).then((outcome) => {
1608
1686
  // steered / queued / sent 三种结果消息都已落地(注入当前回合 /
1609
1687
  // 进入待发队列 / 作为新回合发出),草稿都可以清;queued 额外
1610
1688
  // 提示用户"不是追加进当前回合"。
1611
1689
  if (outcome === 'queued') {
1612
- showSteerNotice('当前回合无法追加,已加入排队,本轮结束后自动发出');
1690
+ showSteerNotice('当前回合无法插队,已改为排队,本轮结束后自动发出');
1613
1691
  }
1614
1692
  onChange('');
1615
1693
  });
1694
+ return;
1695
+ }
1696
+ if (onFollowUp && trimmed) {
1697
+ onFollowUp(trimmed);
1698
+ onChange('');
1616
1699
  }
1617
1700
  return;
1618
1701
  }
@@ -1705,7 +1788,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1705
1788
  <div className="chat-input-container @container relative px-2.5 pb-2.5 pt-1">
1706
1789
  {(leadingChrome || selectedAgent || trailingChrome) && (
1707
1790
  <div
1708
- className="relative mb-1.5 flex min-w-0 items-center gap-1.5"
1791
+ className="relative mb-1.5 flex min-w-0 items-end gap-1.5"
1709
1792
  data-testid="composer-meta-row"
1710
1793
  >
1711
1794
  {leadingChrome}
@@ -1858,6 +1941,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1858
1941
  </div>
1859
1942
  )}
1860
1943
  <div
1944
+ ref={dropBoxRef}
1861
1945
  className={`chat-input-box flex flex-col rounded-agent-lg border bg-agent-canvas shadow-sm transition-all duration-200 ${
1862
1946
  isDragging
1863
1947
  ? 'border-blue-500 ring-2 ring-blue-500/20'
@@ -1872,7 +1956,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1872
1956
  {pendingFollowUps.length > 0 && (
1873
1957
  <div className="flex flex-col gap-1 border-b border-agent-border bg-agent-muted/30 px-2.5 py-1.5">
1874
1958
  <div className="text-[11px] font-medium text-agent-muted-foreground">
1875
- 排队中({pendingFollowUps.length})· 本轮结束后自动发出 · 停止将丢弃
1959
+ 排队中({pendingFollowUps.length})· 本轮结束后自动发出 · 停止后恢复到输入框
1876
1960
  </div>
1877
1961
  {pendingFollowUps.map((text, i) => (
1878
1962
  <div
@@ -1993,18 +2077,6 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1993
2077
  />
1994
2078
  </>
1995
2079
  )}
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
2080
  </div>
2009
2081
  {/* shrink-0:发送键是固定尺寸圆钮,窄容器下不允许被压扁;
2010
2082
  收缩量由左侧可截断的选择器吸收。 */}
@@ -2016,23 +2088,15 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
2016
2088
  >
2017
2089
  {isStreaming ? (
2018
2090
  <>
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 && (
2091
+ {onFollowUp && (
2028
2092
  <span className="ml-2">
2029
2093
  <kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
2030
2094
  Enter
2031
2095
  </kbd>
2032
- <span className="ml-1">追加</span>
2096
+ <span className="ml-1">排队</span>
2033
2097
  </span>
2034
2098
  )}
2035
- {onFollowUp && (
2099
+ {onSteer && (
2036
2100
  <span className="ml-2">
2037
2101
  <kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
2038
2102
  {MOD_KEY_LABEL}
@@ -2041,9 +2105,15 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
2041
2105
  <kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
2042
2106
  Enter
2043
2107
  </kbd>
2044
- <span className="ml-1">排队</span>
2108
+ <span className="ml-1">插队</span>
2045
2109
  </span>
2046
2110
  )}
2111
+ <span className="ml-2">
2112
+ <kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
2113
+ Esc
2114
+ </kbd>
2115
+ <span className="ml-1">停止</span>
2116
+ </span>
2047
2117
  </>
2048
2118
  ) : (
2049
2119
  <>
@@ -2079,8 +2149,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
2079
2149
  title={
2080
2150
  isStreaming
2081
2151
  ? pendingFollowUps.length > 0
2082
- ? `停止生成 (${MOD_KEY_LABEL}+.) · 将丢弃 ${pendingFollowUps.length} 条排队消息`
2083
- : `停止生成 (${MOD_KEY_LABEL}+.)`
2152
+ ? `停止生成 (Esc) · ${pendingFollowUps.length} 条排队消息将恢复到输入框`
2153
+ : '停止生成 (Esc)'
2084
2154
  : '发送 (Enter)'
2085
2155
  }
2086
2156
  aria-label={isStreaming ? '停止生成' : '发送消息'}
@@ -2291,7 +2361,7 @@ function AgentSelect({
2291
2361
  data-testid="agent-manage"
2292
2362
  onClick={() => {
2293
2363
  onOpenChange(false);
2294
- navigate('/settings?section=agents');
2364
+ navigate('/settings?section=plugins&tab=agents');
2295
2365
  }}
2296
2366
  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
2367
  >
@@ -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
+ }
@@ -6,7 +6,7 @@ import type { ExecPolicy } from '@/lib/exec-policy';
6
6
  * ExecPolicyPicker — 输入框上的命令沙箱切换(类 Codex 底部权限档)。
7
7
  *
8
8
  * 切档立刻记住,下一轮才生效:当前回合的 `execSandbox` 已经随流发出。
9
- * 工作区 = Seatbelt/bwrap 只允许写项目家目录或本对话工作区;完整权限
9
+ * 工作区 = Seatbelt/bwrap 只允许写项目家目录、源文件夹(含各自子目录)或本对话工作区;完整权限
10
10
  * = 不下发命令沙箱,本机路径(如 Downloads)不再被 Operation not permitted 拦住。
11
11
  */
12
12
 
@@ -19,7 +19,7 @@ const OPTIONS: Array<{
19
19
  id: 'workspace',
20
20
  label: '工作区',
21
21
  description:
22
- '命令只能写入当前项目或本对话工作区(文稿/<应用名>/conversations/);工作区外路径会被系统拒绝。',
22
+ '只能写入当前工作区,范围外的路径会被拒绝。',
23
23
  },
24
24
  {
25
25
  id: 'full',
@@ -59,6 +59,16 @@ export interface ExecutedAction {
59
59
  * port-only egress); none = no backend on this platform.
60
60
  */
61
61
  sandbox?: { backend?: string; enforcement: string };
62
+ /**
63
+ * 工具注册时声明的卡片。`declared` 为真时行标题用 `title`,
64
+ * 不再按工具名猜测。旧事件没有这个字段,仍走原来的摘要。
65
+ */
66
+ view?: {
67
+ card: 'generic' | 'terminal' | 'diff' | 'search' | 'read' | 'web';
68
+ kind: string;
69
+ title: string;
70
+ declared: boolean;
71
+ };
62
72
  }
63
73
 
64
74
  export interface ExecutedActionsCardProps {
@@ -124,18 +134,18 @@ function actionToTool(action: ExecutedAction, idx: number): ToolExecutionPayload
124
134
  ? humanizeDelegateError(env.error)
125
135
  : env.error
126
136
  : null;
127
- // W5-2: web_search/web_fetch 用结构化中文摘要(查询词/URL + 结果计数/
128
- // 状态码),其余工具走通用参数摘要。
129
- const summary =
130
- (action.tool === 'delegate_subagent'
131
- ? summarizeDelegateSubagent(action.arguments)
132
- : null) ??
133
- (isTaskFamilyTool(action.tool)
134
- ? summarizeTaskAction(action.tool, action.arguments, action.result)
135
- : null) ??
136
- summarizeRunCodeAction(action.tool, action.arguments, action.result) ??
137
- summarizeWebAction(action.tool, action.arguments, action.result) ??
138
- summarizeArguments(action.arguments);
137
+ // 声明过的工具用 view.title。没有 view 的旧事件仍按工具名做摘要。
138
+ const summary = action.view?.declared
139
+ ? action.view.title
140
+ : ((action.tool === 'delegate_subagent'
141
+ ? summarizeDelegateSubagent(action.arguments)
142
+ : null) ??
143
+ (isTaskFamilyTool(action.tool)
144
+ ? summarizeTaskAction(action.tool, action.arguments, action.result)
145
+ : null) ??
146
+ summarizeRunCodeAction(action.tool, action.arguments, action.result) ??
147
+ summarizeWebAction(action.tool, action.arguments, action.result) ??
148
+ summarizeArguments(action.arguments));
139
149
  const badge = sandboxBadge(action.sandbox);
140
150
  return {
141
151
  id: action.id ?? `${action.tool}-${idx}`,
@@ -11,6 +11,7 @@ import {
11
11
  type MentionReference,
12
12
  } from './ChatInput';
13
13
  import { EmptyChat } from './EmptyChat';
14
+ import { EmptyChatHero } from './EmptyChatHero';
14
15
  import { MessageList } from './MessageList';
15
16
  import type { ExecutedAction } from './ExecutedActionsCard';
16
17
  import type { InspectTaskInput } from './executed-actions-model';
@@ -18,6 +19,7 @@ import type { ChildInfo } from './OrchestrationChildrenCard';
18
19
  import type { TurnBlock } from './turn-timeline';
19
20
  import type { TurnFile } from './turn-files';
20
21
  import type { LlmSpeedSnapshot } from './process-status';
22
+ import { ComposerStatusStack } from './composer-status-stack';
21
23
  import { SessionTodoList } from './SessionTodoList';
22
24
  import { resolveLatestSessionTodos } from './todo-list-model';
23
25
  import { SessionOrchestrationFlow } from './SessionOrchestrationFlow';
@@ -59,9 +61,9 @@ export interface LocalChatPanelProps {
59
61
  isStreaming?: boolean;
60
62
  onSubmit: (input: { content: string; metadata?: Record<string, unknown> }) => void | Promise<void>;
61
63
  onCancel?: () => void;
62
- /** 轮中转向(streaming 期间 Enter):注入失败由 hook 兜底为排队/直发,见 ChatInput。 */
64
+ /** 轮中插队(streaming 期间 ⌘/Ctrl+Enter):注入失败由 hook 兜底为排队/直发。 */
63
65
  onSteer?: (text: string) => Promise<SteerOutcome>;
64
- /** W6-2 follow-up 排队:streaming 期间 ⌘/Ctrl+Enter 把文本排入待发队列。 */
66
+ /** W6-2 follow-up 排队:streaming 期间 Enter 把文本排入待发队列。 */
65
67
  onFollowUp?: (text: string) => void;
66
68
  /** 排队待发的 follow-up 文本列表。 */
67
69
  pendingFollowUps?: string[];
@@ -78,11 +80,11 @@ export interface LocalChatPanelProps {
78
80
  /** Slot rendered above the message list (header / agent picker / …). */
79
81
  header?: ReactNode;
80
82
  /**
81
- * 空会话 hero 布局:提供后,当 messages 为空且未在流式时,整个面板渲染为
82
- * 居中首屏(品牌标题 + 居中输入框,无 header)——与 /agent 落地页
83
- * (EmptyChatGate)同款视觉。首条消息落地后自动切回常规布局。
83
+ * 空会话 hero 布局:为 true 时,当 messages 为空且未在流式时,整个面板渲染为
84
+ * 居中首屏(品牌 logo + brand.homeHint + 居中输入框,无 header)——与 /agent
85
+ * 落地页(EmptyChatGate)同款视觉。首条消息落地后自动切回常规布局。
84
86
  */
85
- emptyHero?: { title: string; subtitle?: string };
87
+ emptyHero?: boolean;
86
88
  /** Override the default `EmptyChat` empty-state. */
87
89
  emptyState?: ReactNode;
88
90
  /** Disable the input (e.g. during initial chat-history load). */
@@ -339,6 +341,7 @@ export function LocalChatPanel({
339
341
  orchestrationChildrenByMessageId,
340
342
  timelineByMessageId,
341
343
  currentTurnTimeline,
344
+ turnActive: isStreaming,
342
345
  }),
343
346
  [
344
347
  messages,
@@ -348,6 +351,7 @@ export function LocalChatPanel({
348
351
  orchestrationChildrenByMessageId,
349
352
  timelineByMessageId,
350
353
  currentTurnTimeline,
354
+ isStreaming,
351
355
  ],
352
356
  );
353
357
 
@@ -378,6 +382,17 @@ export function LocalChatPanel({
378
382
  // inputBox 并重新 observe,否则气泡宽度变量停在旧节点上。
379
383
  }, [showEmptyHero]);
380
384
 
385
+ const handleCancel = useCallback(() => {
386
+ if (pendingFollowUps && pendingFollowUps.length > 0) {
387
+ setInputValue((draft) =>
388
+ [...pendingFollowUps, draft.trim()]
389
+ .filter(Boolean)
390
+ .join('\n\n'),
391
+ );
392
+ }
393
+ onCancel?.();
394
+ }, [onCancel, pendingFollowUps]);
395
+
381
396
  const chatInputNode = (
382
397
  <>
383
398
  {attachmentError && (
@@ -394,7 +409,7 @@ export function LocalChatPanel({
394
409
  value={inputValue}
395
410
  onChange={setInputValue}
396
411
  onSubmit={handleSubmit}
397
- onCancel={onCancel}
412
+ onCancel={handleCancel}
398
413
  onSteer={onSteer}
399
414
  onFollowUp={onFollowUp}
400
415
  pendingFollowUps={pendingFollowUps}
@@ -416,7 +431,7 @@ export function LocalChatPanel({
416
431
  leadingChrome={inputLeadingChrome}
417
432
  trailingChrome={
418
433
  sessionOrchestrationFlow || sessionTodos ? (
419
- <div className="flex items-center gap-1.5 min-w-0">
434
+ <ComposerStatusStack>
420
435
  {sessionOrchestrationFlow ? (
421
436
  <SessionOrchestrationFlow
422
437
  flow={sessionOrchestrationFlow}
@@ -425,7 +440,7 @@ export function LocalChatPanel({
425
440
  />
426
441
  ) : null}
427
442
  {sessionTodos ? <SessionTodoList todos={sessionTodos} /> : null}
428
- </div>
443
+ </ComposerStatusStack>
429
444
  ) : undefined
430
445
  }
431
446
  mode={mode}
@@ -453,16 +468,7 @@ export function LocalChatPanel({
453
468
  {showEmptyHero ? (
454
469
  <div className="flex min-h-0 flex-1 items-center justify-center p-3 sm:p-5">
455
470
  <div className="flex w-full max-w-3xl flex-col items-center gap-4">
456
- <div className="text-center">
457
- <h1 className="text-xl font-semibold tracking-tight text-agent-foreground">
458
- {emptyHero?.title}
459
- </h1>
460
- {emptyHero?.subtitle && (
461
- <p className="mt-1.5 text-xs text-agent-muted-foreground">
462
- {emptyHero.subtitle}
463
- </p>
464
- )}
465
- </div>
471
+ <EmptyChatHero />
466
472
  <div className="w-full">
467
473
  {inputBanner}
468
474
  {chatInputNode}