@steerable/agent-shell-web 0.6.66 → 0.6.67

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-web",
3
- "version": "0.6.66",
3
+ "version": "0.6.67",
4
4
  "type": "module",
5
5
  "description": "Steerable framework — product-neutral renderer SPA source for @steerable/agent-shell (React + Vite). Consumed by product web entry points via the `@/` alias (anchored at this package's src/) and the createProductViteConfig factory. Published as a source package (src/), plus the compiled vite.base config factory (vite.base.js + .d.ts) for Node-side config loading.",
6
6
  "license": "Apache-2.0",
@@ -45,8 +45,8 @@
45
45
  "react-router-dom": "^6.30.0",
46
46
  "rehype-raw": "^7.0.0",
47
47
  "remark-gfm": "^4.0.1",
48
- "@steerable/agent-protocol": "0.6.66",
49
- "@steerable/agent-ui": "0.6.66"
48
+ "@steerable/agent-protocol": "0.6.67",
49
+ "@steerable/agent-ui": "0.6.67"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@tailwindcss/vite": "^4.3.0",
@@ -59,7 +59,7 @@
59
59
  "typescript": "^5.8.3",
60
60
  "vite": "^5.4.21",
61
61
  "vitest": "^3.2.4",
62
- "@steerable/pack-sdk": "0.6.66"
62
+ "@steerable/pack-sdk": "0.6.67"
63
63
  },
64
64
  "scripts": {
65
65
  "gen": "echo agent-shell-web",
@@ -32,7 +32,13 @@ import {
32
32
  import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
33
33
  import type { ExecPolicy } from '@/lib/exec-policy';
34
34
  import { getWebChatModes, hostToolCapability, settingsChrome } from '@/lib/host-tools';
35
- import { hostClipboardAvailable, requestHostPaste, type HostClipboardFile } from '@/lib/host-paste';
35
+ import {
36
+ hostClipboardAvailable,
37
+ releaseComposerPasteTarget,
38
+ requestHostPaste,
39
+ setComposerPasteTarget,
40
+ type HostClipboardFile,
41
+ } from '@/lib/host-paste';
36
42
  import { attachmentFromPath, type AttachmentFile } from '@/lib/attachments';
37
43
  import { dropPointHitsRect, hostFileDropAvailable, listenHostFileDrop } from '@/lib/host-file-drop';
38
44
  import type { SteerOutcome } from '@steerable/agent-ui';
@@ -510,10 +516,17 @@ function getSelectionOffsets(root: HTMLDivElement): { start: number; end: number
510
516
  return measureRange.toString().length;
511
517
  };
512
518
 
513
- return {
514
- start: getOffset(range.startContainer, range.startOffset),
515
- end: getOffset(range.endContainer, range.endOffset),
516
- };
519
+ try {
520
+ return {
521
+ start: getOffset(range.startContainer, range.startOffset),
522
+ end: getOffset(range.endContainer, range.endOffset),
523
+ };
524
+ } catch {
525
+ // The caret can sit on the empty scaffold's <br> with an offset the
526
+ // range rejects. Paste still belongs at the end of the draft.
527
+ const end = getEditableText(root).length;
528
+ return { start: end, end };
529
+ }
517
530
  }
518
531
 
519
532
  function setCaretOffset(root: HTMLDivElement, offset: number) {
@@ -1149,6 +1162,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1149
1162
  return;
1150
1163
  }
1151
1164
 
1165
+ const wrote =
1166
+ editorText !== value ||
1167
+ pendingCaretOffset !== null ||
1168
+ (!value && !hasEmptyImeScaffold(editor));
1152
1169
  if (editorText !== value || pendingCaretOffset !== null) {
1153
1170
  writeEditorValue(
1154
1171
  editor,
@@ -1167,8 +1184,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1167
1184
  lastPushedValueRef.current = value;
1168
1185
  updateEditorHeight(editor);
1169
1186
 
1170
- if (pendingCaretOffset !== null && focused) {
1171
- setCaretOffset(editor, Math.min(pendingCaretOffset, value.length));
1187
+ // replaceChildren drops the DOM selection. Without a caret inside the
1188
+ // field, WKWebView ignores Ctrl/Cmd+V after a send.
1189
+ if (focused && wrote) {
1190
+ setCaretOffset(editor, Math.min(pendingCaretOffset ?? value.length, value.length));
1172
1191
  pendingCaretOffsetRef.current = null;
1173
1192
  }
1174
1193
  }, [value, mentionReferences, agents, chats, disabled, skills, mcpTools]);
@@ -1494,6 +1513,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1494
1513
  useLayoutEffect(() => {
1495
1514
  const editor = editorRef.current;
1496
1515
  if (!editor) return;
1516
+ setComposerPasteTarget(editor);
1497
1517
  const onCompositionStart = () => {
1498
1518
  isComposingRef.current = true;
1499
1519
  inputSyncGenRef.current += 1;
@@ -1519,6 +1539,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1519
1539
  editor.addEventListener('hostpaste', onHostPaste);
1520
1540
  editor.addEventListener('hostpastefiles', onHostPasteFiles);
1521
1541
  return () => {
1542
+ releaseComposerPasteTarget(editor);
1522
1543
  editor.removeEventListener('compositionstart', onCompositionStart, true);
1523
1544
  editor.removeEventListener('hostpaste', onHostPaste);
1524
1545
  editor.removeEventListener('hostpastefiles', onHostPasteFiles);
@@ -224,10 +224,13 @@ export function LocalChatPanel({
224
224
  const [attachmentError, setAttachmentError] = useState<string | null>(null);
225
225
  const [mentionReferences, setMentionReferences] = useState<MentionReference[]>([]);
226
226
  const inputRef = useRef<ChatInputHandle>(null);
227
+ // 空会话首屏和对话底部各有一棵输入框。发出第一条消息会卸掉正在聚焦的那棵;
228
+ // 不重新聚焦的话,Ctrl/Cmd+V 没有落点。
229
+ const showEmptyHero = Boolean(emptyHero) && messages.length === 0 && !isStreaming;
227
230
 
228
231
  useEffect(() => {
229
232
  inputRef.current?.focusAtEnd();
230
- }, [chatId]);
233
+ }, [chatId, showEmptyHero]);
231
234
 
232
235
  const handleSelectPrompt = useCallback((prompt: string) => {
233
236
  setInputValue(prompt);
@@ -315,9 +318,6 @@ export function LocalChatPanel({
315
318
  }
316
319
  }, [inputValue, files, mentionReferences, onSubmit, mode, execPolicy, chatId, selectedAgentId]);
317
320
 
318
- // 空会话 hero:messages 为空且未在流式时整面板切到居中首屏布局。
319
- const showEmptyHero = Boolean(emptyHero) && messages.length === 0 && !isStreaming;
320
-
321
321
  const sessionTodos = useMemo(
322
322
  () =>
323
323
  resolveLatestSessionTodos({
@@ -19,8 +19,22 @@ import { getHostBridge } from './host-bridge';
19
19
 
20
20
  let pending = false;
21
21
  let lastEditable: HTMLElement | null = null;
22
+ let composerPasteTarget: HTMLElement | null = null;
22
23
  let installed = false;
23
24
 
25
+ /**
26
+ * Remember the live chat composer. A menu paste uses it when no other field
27
+ * is focused, including right after the empty-hero composer unmounts.
28
+ */
29
+ export function setComposerPasteTarget(element: HTMLElement): void {
30
+ composerPasteTarget = element;
31
+ }
32
+
33
+ /** Drop the composer target when that element unmounts. */
34
+ export function releaseComposerPasteTarget(element: HTMLElement): void {
35
+ if (composerPasteTarget === element) composerPasteTarget = null;
36
+ }
37
+
24
38
  /** Remember the field that should receive a menu paste after focus moves. */
25
39
  export function installHostPaste(): void {
26
40
  if (installed || typeof document === 'undefined') return;
@@ -87,12 +101,9 @@ export function hostClipboardAvailable(): boolean {
87
101
  }
88
102
 
89
103
  function isEditable(target: EventTarget | null): target is HTMLElement {
90
- return (
91
- !isTerminalInput(target) &&
92
- (target instanceof HTMLInputElement ||
93
- target instanceof HTMLTextAreaElement ||
94
- (target instanceof HTMLElement && target.isContentEditable))
95
- );
104
+ if (isTerminalInput(target) || !(target instanceof HTMLElement)) return false;
105
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return true;
106
+ return target.isContentEditable || target.getAttribute('contenteditable') === 'true';
96
107
  }
97
108
 
98
109
  /**
@@ -105,8 +116,12 @@ function isTerminalInput(target: EventTarget | null): boolean {
105
116
 
106
117
  function focusedEditable(): HTMLElement | null {
107
118
  if (isTerminalInput(document.activeElement)) return null;
108
- if (isEditable(document.activeElement)) return document.activeElement;
109
- if (lastEditable?.isConnected) return lastEditable;
119
+ const active = document.activeElement;
120
+ if (active instanceof HTMLElement && active.isConnected && isEditable(active)) return active;
121
+ if (lastEditable?.isConnected && isEditable(lastEditable)) return lastEditable;
122
+ if (composerPasteTarget?.isConnected && isEditable(composerPasteTarget)) {
123
+ return composerPasteTarget;
124
+ }
110
125
  return null;
111
126
  }
112
127