@steerable/agent-shell-web 0.6.48 → 0.6.49

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.
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
- import { LuCheck, LuGitBranch, LuListTodo, LuListTree, LuLoaderCircle } from 'react-icons/lu';
3
+ import { LuCheck, LuDownload, LuGitBranch, LuListTodo, LuListTree, LuLoaderCircle, LuTerminal } from 'react-icons/lu';
4
4
  import type { LocalChat, LocalTask } from '@/lib/local-api';
5
5
  import {
6
6
  activateChatBranch,
@@ -11,6 +11,14 @@ import { SessionTreeModal } from './chat/SessionTreeModal';
11
11
  import { TaskPanelModal } from './chat/TaskPanelModal';
12
12
  import { actionableTasks, summarizeTasks, type ChatTaskSummary } from './chat/useChatTasks';
13
13
  import type { PackChatSlotContribution } from '@/packs/registry';
14
+ import {
15
+ fetchChatDocument,
16
+ isPortableEnabled,
17
+ portableErrorMessage,
18
+ safeDownloadName,
19
+ saveJsonFile,
20
+ } from '@/lib/portable';
21
+ import { hostToolChrome } from '@/lib/host-tools';
14
22
 
15
23
  interface ChatHeaderProps {
16
24
  chat: LocalChat | null;
@@ -30,16 +38,17 @@ interface ChatHeaderProps {
30
38
  rightPanel?: string | null;
31
39
  /** 点击槽位入口:点已打开的关闭,点另一个直接切换。 */
32
40
  onToggleChatSlot?: (slotId: string) => void;
41
+ /** 切换右侧面板(终端、包槽位等)。 */
42
+ onToggleRightPanel?: (kind: string) => void;
33
43
  }
34
44
 
35
45
  /**
36
46
  * ChatHeader — slim title bar above ChatPanel. Shows:
37
47
  * - chat title (完整显示,长标题换行不截断)
38
48
  * - shortened chat id (for support / debugging)
49
+ * - 导出 / 分支 / 后台任务 / 包槽位 / 终端 入口
39
50
  *
40
- * Terminal / 场景包调试窗 / 本地模型设置 USED to live here, but per the
41
- * P1 parity contract (`match_old`) they've been moved to the AgentSidebar
42
- * bottom strip. 刷新按钮已下线(消息流有 SSE 自动同步)。分享在最后一条
51
+ * 刷新按钮已下线(消息流有 SSE 自动同步)。分享在最后一条
43
52
  * 助手消息的时间戳行,不占标题栏。
44
53
  */
45
54
  export function ChatHeader({
@@ -50,18 +59,37 @@ export function ChatHeader({
50
59
  chatSlots = [],
51
60
  rightPanel = null,
52
61
  onToggleChatSlot,
62
+ onToggleRightPanel,
53
63
  }: ChatHeaderProps) {
54
64
  const [branchMenuOpen, setBranchMenuOpen] = useState(false);
55
65
  const [treeModalOpen, setTreeModalOpen] = useState(false);
56
66
  const [taskPanelOpen, setTaskPanelOpen] = useState(false);
57
67
  const [branches, setBranches] = useState<ChatBranchesResponse | null>(null);
58
68
  const [switching, setSwitching] = useState(false);
69
+ const [exporting, setExporting] = useState(false);
70
+ const [exportError, setExportError] = useState<string | null>(null);
59
71
  const [menuBox, setMenuBox] = useState<{ top: number; left: number; width: number } | null>(
60
72
  null,
61
73
  );
62
74
  const buttonRef = useRef<HTMLButtonElement | null>(null);
63
75
  const menuRef = useRef<HTMLDivElement | null>(null);
64
76
 
77
+ const [isMac, setIsMac] = useState(() => {
78
+ if (typeof navigator !== 'undefined') {
79
+ return /Mac|iPod|iPhone|iPad/.test(navigator.platform);
80
+ }
81
+ return false;
82
+ });
83
+
84
+ useEffect(() => {
85
+ if (typeof navigator !== 'undefined') {
86
+ setIsMac(/Mac|iPod|iPhone|iPad/.test(navigator.platform));
87
+ }
88
+ }, []);
89
+
90
+ const showTerminalChrome = hostToolChrome('terminal');
91
+ const togglePanel = onToggleRightPanel ?? onToggleChatSlot;
92
+
65
93
  const branchCount = branches ? branches.lineage.length + branches.children.length : 0;
66
94
 
67
95
  const taskSummary = useMemo(() => summarizeTasks(tasks), [tasks]);
@@ -193,6 +221,30 @@ export function ChatHeader({
193
221
  {chat?.title ?? '未选择对话'}
194
222
  </span>
195
223
  </div>
224
+ {chat && isPortableEnabled() && (
225
+ <button
226
+ type="button"
227
+ disabled={exporting}
228
+ onClick={() => {
229
+ setExporting(true);
230
+ setExportError(null);
231
+ void fetchChatDocument(chat.id)
232
+ .then((doc) => saveJsonFile(safeDownloadName(chat.title, '对话'), doc))
233
+ .then((saved) => {
234
+ if (!saved) return;
235
+ })
236
+ .catch((err: unknown) => setExportError(portableErrorMessage(err)))
237
+ .finally(() => setExporting(false));
238
+ }}
239
+ className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground disabled:opacity-60"
240
+ title={exportError ?? '导出此对话'}
241
+ aria-label="导出此对话"
242
+ data-testid="header-export-chat"
243
+ >
244
+ <LuDownload className="h-3.5 w-3.5" />
245
+ {exportError ? '导出失败' : exporting ? '导出中' : '导出'}
246
+ </button>
247
+ )}
196
248
  {chat && onBranchSwitched && (
197
249
  <div className="shrink-0">
198
250
  <button
@@ -267,6 +319,24 @@ export function ChatHeader({
267
319
  </button>
268
320
  );
269
321
  })}
322
+ {chat && showTerminalChrome && (
323
+ <button
324
+ type="button"
325
+ onClick={() => togglePanel?.('terminal')}
326
+ className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs transition-colors ${
327
+ rightPanel === 'terminal'
328
+ ? 'bg-agent-foreground/10 text-agent-foreground'
329
+ : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground'
330
+ }`}
331
+ title={`${rightPanel === 'terminal' ? '关闭' : '打开'}终端面板 (${isMac ? '⌘T' : 'Ctrl+T'})`}
332
+ aria-label="终端"
333
+ aria-pressed={rightPanel === 'terminal'}
334
+ data-action="terminal"
335
+ data-testid="header-terminal"
336
+ >
337
+ <LuTerminal className="h-3.5 w-3.5" />
338
+ </button>
339
+ )}
270
340
  {chat && taskPanelOpen && (
271
341
  <TaskPanelModal
272
342
  chatId={chat.id}
@@ -99,6 +99,7 @@ export function ApprovalPromptMenu({
99
99
  // 出口没有意义——只给一次性/会话两档,且默认焦点在拒绝(安全默认:
100
100
  // 用户能认出 api.github.com,认不出 cdn.jsdelivr.net.evil.com)。
101
101
  const isEgress = current.category === 'network_egress';
102
+ const isSandboxEscalation = current.category === 'sandbox_escalation';
102
103
  const egressHost =
103
104
  typeof current.arguments.host === 'string' ? current.arguments.host : '';
104
105
  const egressPort =
@@ -125,6 +126,8 @@ export function ApprovalPromptMenu({
125
126
  {egressPort !== null ? `:${egressPort}` : ''}
126
127
  </span>
127
128
  </>
129
+ ) : isSandboxEscalation ? (
130
+ <>Agent 请求在工作区外执行命令</>
128
131
  ) : (
129
132
  <>
130
133
  Agent 请求执行
@@ -153,6 +156,11 @@ export function ApprovalPromptMenu({
153
156
  用相似拼写,如 cdn.jsdelivr.net.evil.com)。放行仅对本次会话
154
157
  生效;要持久放行请把域名加入设置的出网白名单。
155
158
  </p>
159
+ ) : isSandboxEscalation ? (
160
+ <p className="mt-2 text-[11px] leading-relaxed text-agent-muted-foreground">
161
+ 命令已在工作区沙箱中尝试,但被系统拒绝。允许后会仅重试这条原始命令,
162
+ 不再限制它只能写入当前项目或本对话工作区。
163
+ </p>
156
164
  ) : (
157
165
  <>
158
166
  {current.category !== current.toolName && (
@@ -15,6 +15,7 @@ import {
15
15
  } from '@/lib/electron-bridge';
16
16
 
17
17
  interface ApprovalPromptContextValue {
18
+ queue: ApprovalPromptRequest[];
18
19
  current: ApprovalPromptRequest | null;
19
20
  pendingCount: number;
20
21
  decide: (kind: ApprovalDecisionKind) => void;
@@ -78,11 +79,12 @@ export function ApprovalPromptProvider({ children }: { children: ReactNode }) {
78
79
 
79
80
  const value = useMemo(
80
81
  () => ({
82
+ queue,
81
83
  current,
82
84
  pendingCount: Math.max(0, queue.length - 1),
83
85
  decide,
84
86
  }),
85
- [current, decide, queue.length],
87
+ [queue, current, decide],
86
88
  );
87
89
 
88
90
  return (
@@ -96,3 +98,16 @@ export function ApprovalPromptProvider({ children }: { children: ReactNode }) {
96
98
  export function useApprovalPrompt(): ApprovalPromptContextValue | null {
97
99
  return useContext(ApprovalPromptContext);
98
100
  }
101
+
102
+ /** 返回存在未处理工具权限审批的会话 ID 集合。 */
103
+ export function usePendingApprovalChatIds(): Set<string> {
104
+ const context = useContext(ApprovalPromptContext);
105
+ return useMemo(() => {
106
+ if (!context) return new Set();
107
+ const set = new Set<string>();
108
+ for (const request of context.queue) {
109
+ if (request.chatId) set.add(request.chatId);
110
+ }
111
+ return set;
112
+ }, [context?.queue]);
113
+ }
@@ -146,3 +146,17 @@ export function useAskUserPrompt(chatId?: string | null): AskUserPromptView | nu
146
146
  };
147
147
  }, [chatId, context]);
148
148
  }
149
+
150
+ /** 返回存在未处理提问(需要用户回答)的会话 ID 集合。 */
151
+ export function usePendingAskUserChatIds(): Set<string> {
152
+ const context = useContext(AskUserPromptContext);
153
+ return useMemo(() => {
154
+ if (!context) return new Set();
155
+ const set = new Set<string>();
156
+ for (const request of context.queue) {
157
+ if (request.chatId) set.add(request.chatId);
158
+ }
159
+ return set;
160
+ }, [context?.queue]);
161
+ }
162
+
@@ -441,7 +441,7 @@ export function AssistantMessage({
441
441
  回合收尾才有数据,流式期间天然为空。 */}
442
442
  {!isStreaming && turnFiles && turnFiles.length > 0 && (
443
443
  <div className="mt-2.5 mb-1">
444
- <TurnFilesCard files={turnFiles} />
444
+ <TurnFilesCard files={turnFiles} executedActions={executedActions} />
445
445
  </div>
446
446
  )}
447
447
  <div className="mt-2 flex items-center gap-2 text-[11px] text-agent-muted-foreground/80">
@@ -33,6 +33,7 @@ import {
33
33
  import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
34
34
  import type { ExecPolicy } from '@/lib/exec-policy';
35
35
  import { getWebChatModes, hostToolCapability, settingsChrome } from '@/lib/host-tools';
36
+ import { hostClipboardAvailable, requestHostPaste } from '@/lib/host-paste';
36
37
  import type { AttachmentFile } from '@/lib/attachments';
37
38
  import type { SteerOutcome } from '@steerable/agent-ui';
38
39
  import {
@@ -221,7 +222,6 @@ function mentionChipClassName(ref?: MentionReference): string {
221
222
  function getEditableText(element: HTMLDivElement): string {
222
223
  return (element.textContent ?? '').replace(/\u00a0/g, ' ');
223
224
  }
224
-
225
225
  function isEditorDomEmpty(editor: HTMLDivElement): boolean {
226
226
  return getEditableText(editor).length === 0;
227
227
  }
@@ -703,6 +703,18 @@ function clipboardFiles(data: DataTransfer | null): File[] {
703
703
  return Array.from(data.files ?? []);
704
704
  }
705
705
 
706
+ function clipboardPlainText(data: DataTransfer | null): string {
707
+ if (!data) return '';
708
+ // `text/plain` before any other type: older WebKit clears plain text
709
+ // if `text/html` is read first.
710
+ return data.getData('text/plain') || data.getData('text') || '';
711
+ }
712
+
713
+ function clipboardOf(event: ClipboardEvent<HTMLDivElement>): DataTransfer | null {
714
+ const native = event.nativeEvent as { clipboardData?: DataTransfer | null };
715
+ return native.clipboardData ?? event.clipboardData ?? null;
716
+ }
717
+
706
718
  function namePastedImage(file: File, serial: number, now: Date): File {
707
719
  if (!file.type.startsWith('image/')) return file;
708
720
  if (file.name.trim() && !GENERIC_CLIPBOARD_IMAGE_NAME.test(file.name.trim())) return file;
@@ -802,7 +814,9 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
802
814
  const pendingCaretOffsetRef = useRef<number | null>(null);
803
815
  // Overlay editor + IME: never rewrite textContent/selection while the
804
816
  // user is composing. macOS Pinyin otherwise commits the first letter as
805
- // Latin instead of starting a marked-text session.
817
+ // Latin instead of starting a marked-text session. Set only by real
818
+ // composition events: WKWebView also reports keyCode 229 for committed
819
+ // keys, with no compositionend to clear a flag guessed from keydown.
806
820
  const isComposingRef = useRef(false);
807
821
  // Last value we pushed from the editor (or accepted from the parent).
808
822
  // While focused, a matching value must not rewrite contenteditable DOM —
@@ -823,6 +837,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
823
837
 
824
838
  const fileInputRef = useRef<HTMLInputElement>(null);
825
839
  const pasteSerialRef = useRef(0);
840
+ const adoptPastedTextRef = useRef<(text: string) => void>(() => {});
826
841
  // 工具栏"空间不足时优先隐藏快捷键提示"的测量 refs,见下方 useLayoutEffect。
827
842
  const toolbarRowRef = useRef<HTMLDivElement>(null);
828
843
  const toolbarLeftRef = useRef<HTMLDivElement>(null);
@@ -1094,9 +1109,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1094
1109
  // or while the focused editor already owns this value.
1095
1110
  useLayoutEffect(() => {
1096
1111
  const editor = editorRef.current;
1097
- if (!editor || isComposingRef.current || editor.hasAttribute('data-composing')) {
1098
- return;
1099
- }
1112
+ if (!editor || isComposingRef.current) return;
1113
+ editor.removeAttribute('data-composing');
1100
1114
  const editorText = getEditableText(editor);
1101
1115
  const focused = document.activeElement === editor;
1102
1116
  const pendingCaretOffset = pendingCaretOffsetRef.current;
@@ -1337,7 +1351,20 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1337
1351
  const { end } = getSelectionOffsets(editor);
1338
1352
  const nextValue = getEditableText(editor);
1339
1353
  lastPushedValueRef.current = nextValue;
1340
- pendingCaretOffsetRef.current = end;
1354
+ // The browser already rendered this text. Rewrite the DOM only when a
1355
+ // typed token must become a mention chip; any other rewrite detaches the
1356
+ // input method from the text node.
1357
+ const mentionCount = parseMentionSegments(
1358
+ nextValue,
1359
+ mentionReferences,
1360
+ agents,
1361
+ chats,
1362
+ skills,
1363
+ mcpTools,
1364
+ ).filter((segment) => segment.type === 'mention').length;
1365
+ if (mentionCount !== editor.querySelectorAll('[data-mention-chip]').length) {
1366
+ pendingCaretOffsetRef.current = end;
1367
+ }
1341
1368
  onChange(nextValue);
1342
1369
  syncMentionQuery(nextValue, end);
1343
1370
  };
@@ -1365,6 +1392,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1365
1392
  }, 0);
1366
1393
  };
1367
1394
 
1395
+ adoptPastedTextRef.current = replaceSelection;
1396
+
1368
1397
  const handleBeforeInput = (event: FormEvent<HTMLDivElement>) => {
1369
1398
  const native = event.nativeEvent as unknown as InputEvent;
1370
1399
  if (
@@ -1413,16 +1442,34 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1413
1442
  }
1414
1443
  editor.setAttribute('data-composing', 'true');
1415
1444
  };
1445
+ const onHostPaste = (event: Event) => {
1446
+ const text = (event as CustomEvent<string>).detail;
1447
+ if (typeof text !== 'string' || !text) return;
1448
+ event.preventDefault();
1449
+ adoptPastedTextRef.current(text);
1450
+ };
1416
1451
  editor.addEventListener('compositionstart', onCompositionStart, true);
1452
+ editor.addEventListener('hostpaste', onHostPaste);
1417
1453
  return () => {
1418
1454
  editor.removeEventListener('compositionstart', onCompositionStart, true);
1455
+ editor.removeEventListener('hostpaste', onHostPaste);
1419
1456
  };
1420
1457
  }, []);
1421
1458
 
1422
1459
  const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
1423
- event.preventDefault();
1424
- const data = event.clipboardData;
1460
+ // Read before preventDefault. WebKit clears clipboardData once the paste is canceled.
1461
+ const data = clipboardOf(event);
1462
+ const text = clipboardPlainText(data);
1425
1463
  const pasted = !disabled && allowFileAttach ? clipboardFiles(data) : [];
1464
+ if (!text && pasted.length === 0) {
1465
+ // WKWebView hands the page an empty clipboard; read the OS pasteboard.
1466
+ if (hostClipboardAvailable()) {
1467
+ event.preventDefault();
1468
+ requestHostPaste(event.currentTarget);
1469
+ }
1470
+ return;
1471
+ }
1472
+ event.preventDefault();
1426
1473
  if (pasted.length > 0) {
1427
1474
  const now = new Date();
1428
1475
  addFiles(
@@ -1432,7 +1479,6 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1432
1479
  }),
1433
1480
  );
1434
1481
  }
1435
- const text = data?.getData('text/plain') ?? '';
1436
1482
  if (text) replaceSelection(text);
1437
1483
  };
1438
1484
 
@@ -1441,33 +1487,21 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1441
1487
  };
1442
1488
 
1443
1489
  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
1444
- // Honor IME composition (`isComposing` true while Chinese input is mid-
1445
- // selection) — only intercept on plain key events.
1446
- // WKWebView reports keyCode 229 for Enter itself. Treating that as IME
1447
- // composition sticks isComposing and swallows every later Enter, so the
1448
- // send shortcut never runs. Only non-Enter 229 starts composition.
1449
- if (event.key !== 'Enter' && (isComposingRef.current || event.nativeEvent.isComposing)) return;
1450
- if (event.key !== 'Enter' && event.keyCode === 229) {
1451
- markComposing();
1452
- return;
1453
- }
1454
- // Hide the placeholder on the first letter of an empty editor so it
1455
- // does not cover IME marked text. Do this even when `isComposing` is
1456
- // still false (macOS Pinyin first keydown).
1457
1490
  if (
1458
- !event.metaKey &&
1459
- !event.ctrlKey &&
1491
+ hostClipboardAvailable() &&
1492
+ (event.metaKey || event.ctrlKey) &&
1460
1493
  !event.altKey &&
1461
- event.key.length === 1 &&
1462
- !value
1494
+ event.key.toLowerCase() === 'v'
1463
1495
  ) {
1464
- editorRef.current?.setAttribute('data-composing', 'true');
1465
- }
1466
- // See `compositionEndAtRef` above: treat an Enter that lands right after
1467
- // `compositionend` as "confirm candidate", not "submit/newline".
1468
- if (event.key === 'Enter' && Date.now() - compositionEndAtRef.current < 50) {
1496
+ event.preventDefault();
1497
+ requestHostPaste(event.currentTarget);
1469
1498
  return;
1470
1499
  }
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;
1471
1505
 
1472
1506
  if (event.key === 'Backspace' || event.key === 'Delete') {
1473
1507
  const editor = editorRef.current;
@@ -1487,6 +1521,23 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1487
1521
  }
1488
1522
  }
1489
1523
  }
1524
+ // Hide the placeholder on the first letter of an empty editor so it
1525
+ // does not cover IME marked text. Do this even when `isComposing` is
1526
+ // still false (macOS Pinyin first keydown).
1527
+ if (
1528
+ !event.metaKey &&
1529
+ !event.ctrlKey &&
1530
+ !event.altKey &&
1531
+ event.key.length === 1 &&
1532
+ !value
1533
+ ) {
1534
+ editorRef.current?.setAttribute('data-composing', 'true');
1535
+ }
1536
+ // See `compositionEndAtRef` above: treat an Enter that lands right after
1537
+ // `compositionend` as "confirm candidate", not "submit/newline".
1538
+ if (event.key === 'Enter' && Date.now() - compositionEndAtRef.current < 50) {
1539
+ return;
1540
+ }
1490
1541
 
1491
1542
  if (mentionQuery && mentionSuggestions.length > 0) {
1492
1543
  if (event.key === 'ArrowDown') {
@@ -20,9 +20,12 @@ export function SuggestedReplies({
20
20
  className="mx-auto mt-2.5 flex w-full max-w-[var(--chat-input-box-width)] flex-wrap items-center gap-1.5 px-1 pb-0.5"
21
21
  data-testid="suggested-replies"
22
22
  >
23
- <div className="mr-0.5 inline-flex items-center gap-1 text-[11px] font-medium text-agent-muted-foreground/70 select-none">
23
+ <div
24
+ className="mr-0.5 inline-flex items-center select-none"
25
+ title="建议"
26
+ aria-label="建议"
27
+ >
24
28
  <LuSparkles className="h-3 w-3 text-agent-muted-foreground/80" />
25
- <span>建议回复</span>
26
29
  </div>
27
30
  {suggestions.map((text) => (
28
31
  <button