@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.
- package/package.json +4 -4
- package/src/brand.ts +7 -0
- package/src/components/AgentSidebar.tsx +284 -188
- package/src/components/ConfirmDialog.tsx +90 -0
- package/src/components/CreateProjectModal.tsx +32 -16
- package/src/components/chat/ApprovalModal.tsx +2 -5
- package/src/components/chat/ApprovalPromptProvider.tsx +38 -22
- package/src/components/chat/AskUserPromptProvider.tsx +1 -32
- package/src/components/chat/ChatInput.tsx +129 -59
- package/src/components/chat/ChatProjectBadge.tsx +1 -1
- package/src/components/chat/EmptyChatHero.tsx +22 -0
- package/src/components/chat/ExecPolicyPicker.tsx +2 -2
- package/src/components/chat/ExecutedActionsCard.tsx +22 -12
- package/src/components/chat/LocalChatPanel.tsx +25 -19
- package/src/components/chat/MessageList.tsx +77 -5
- package/src/components/chat/ModelPicker.tsx +21 -1
- package/src/components/chat/OrchestrationFlowCard.tsx +13 -3
- package/src/components/chat/SessionOrchestrationFlow.tsx +66 -19
- package/src/components/chat/SessionTodoList.tsx +12 -3
- package/src/components/chat/UserMessage.tsx +116 -25
- package/src/components/chat/composer-status-stack.tsx +79 -0
- package/src/components/chat/orchestration-flow-model.ts +108 -43
- package/src/components/chat/prompt-chat-scope.ts +33 -0
- package/src/components/settings/AgentsSettingsPanel.tsx +33 -22
- package/src/components/settings/McpSettingsPanel.tsx +144 -48
- package/src/components/settings/SkillsSettingsPanel.tsx +1 -1
- package/src/hooks/useProjects.ts +82 -0
- package/src/lib/attachments.ts +8 -0
- package/src/lib/electron-bridge.ts +16 -1
- package/src/lib/exec-policy.ts +1 -1
- package/src/lib/host-file-drop.ts +68 -0
- package/src/lib/host-paste.ts +38 -9
- package/src/lib/local-api.ts +1 -1
- package/src/lib/tauri-bridge.ts +5 -0
- package/src/pages/AgentPage.tsx +114 -64
- package/src/pages/SettingsPage.tsx +131 -46
- package/src/vite-env.d.ts +2 -0
- 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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 期间按
|
|
118
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
1501
|
-
//
|
|
1502
|
-
//
|
|
1503
|
-
// the
|
|
1504
|
-
|
|
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
|
|
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
|
-
//
|
|
1599
|
-
//
|
|
1680
|
+
// streaming 期间:Enter = follow-up 排队(本轮结束后自动作为下一轮
|
|
1681
|
+
// 发出);⌘/Ctrl+Enter = 轮中插队(注入运行中的回合;注入不了时
|
|
1682
|
+
// hook 兜底为 W6-2 排队或新回合直发,消息不会丢)。
|
|
1600
1683
|
if (isStreaming) {
|
|
1601
|
-
if ((event.metaKey || event.ctrlKey) &&
|
|
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-
|
|
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
|
-
|
|
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"
|
|
2096
|
+
<span className="ml-1">排队</span>
|
|
2033
2097
|
</span>
|
|
2034
2098
|
)}
|
|
2035
|
-
{
|
|
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"
|
|
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
|
-
? `停止生成 (
|
|
2083
|
-
:
|
|
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
|
>
|
|
@@ -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
|
-
'
|
|
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
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
(action.tool === 'delegate_subagent'
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
/**
|
|
64
|
+
/** 轮中插队(streaming 期间 ⌘/Ctrl+Enter):注入失败由 hook 兜底为排队/直发。 */
|
|
63
65
|
onSteer?: (text: string) => Promise<SteerOutcome>;
|
|
64
|
-
/** W6-2 follow-up 排队:streaming 期间
|
|
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
|
|
82
|
-
*
|
|
83
|
-
*
|
|
83
|
+
* 空会话 hero 布局:为 true 时,当 messages 为空且未在流式时,整个面板渲染为
|
|
84
|
+
* 居中首屏(品牌 logo + brand.homeHint + 居中输入框,无 header)——与 /agent
|
|
85
|
+
* 落地页(EmptyChatGate)同款视觉。首条消息落地后自动切回常规布局。
|
|
84
86
|
*/
|
|
85
|
-
emptyHero?:
|
|
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={
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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}
|