@steerable/agent-shell-web 0.6.48 → 0.6.50
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/components/AgentSidebar.tsx +411 -298
- package/src/components/ChatHeader.tsx +74 -4
- package/src/components/chat/ApprovalModal.tsx +8 -0
- package/src/components/chat/ApprovalPromptProvider.tsx +16 -1
- package/src/components/chat/AskUserPromptProvider.tsx +14 -0
- package/src/components/chat/AssistantMessage.tsx +1 -1
- package/src/components/chat/ChatInput.tsx +82 -31
- package/src/components/chat/SuggestedReplies.tsx +5 -2
- package/src/components/chat/TurnFilesCard.tsx +417 -69
- package/src/components/chat/turn-files.ts +273 -0
- package/src/components/settings/PortableSettingsPanel.tsx +493 -0
- package/src/components/settings/PythonRunnerSettingsPanel.tsx +231 -0
- package/src/layouts/AgentLayout.tsx +23 -0
- package/src/lib/electron-bridge.ts +44 -0
- package/src/lib/host-paste.ts +85 -0
- package/src/lib/local-api.ts +18 -0
- package/src/lib/portable.ts +229 -0
- package/src/lib/tauri-bridge.ts +40 -1
- package/src/main.tsx +2 -0
- package/src/pages/AgentPage.tsx +17 -0
- package/src/pages/SettingsPage.tsx +15 -1
- package/src/styles/index.css +29 -0
- package/src/vite-env.d.ts +2 -0
- package/vite.base.js +1 -0
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
1098
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1424
|
-
const data = event
|
|
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
|
-
|
|
1459
|
-
|
|
1491
|
+
hostClipboardAvailable() &&
|
|
1492
|
+
(event.metaKey || event.ctrlKey) &&
|
|
1460
1493
|
!event.altKey &&
|
|
1461
|
-
event.key.
|
|
1462
|
-
!value
|
|
1494
|
+
event.key.toLowerCase() === 'v'
|
|
1463
1495
|
) {
|
|
1464
|
-
|
|
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
|
|
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
|