@steerable/agent-shell-web 0.6.15
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/LICENSE +91 -0
- package/README.md +20 -0
- package/package.json +69 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/src/AppShell.tsx +24 -0
- package/src/assets/logo-generic.png +0 -0
- package/src/brand.test.ts +22 -0
- package/src/brand.ts +48 -0
- package/src/components/AgentSidebar.test.tsx +127 -0
- package/src/components/AgentSidebar.tsx +1010 -0
- package/src/components/ChatHeader.test.tsx +230 -0
- package/src/components/ChatHeader.tsx +436 -0
- package/src/components/DockPanelHeader.tsx +69 -0
- package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
- package/src/components/LocalLlmSettingsModal.tsx +72 -0
- package/src/components/TerminalPanel.tsx +68 -0
- package/src/components/TerminalView.tsx +228 -0
- package/src/components/chat/ApprovalModal.test.tsx +83 -0
- package/src/components/chat/ApprovalModal.tsx +229 -0
- package/src/components/chat/AskUserModalHost.test.tsx +176 -0
- package/src/components/chat/AskUserModalHost.tsx +61 -0
- package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
- package/src/components/chat/AssistantMessage.test.tsx +68 -0
- package/src/components/chat/AssistantMessage.tsx +469 -0
- package/src/components/chat/ChatInput.test.tsx +609 -0
- package/src/components/chat/ChatInput.tsx +2068 -0
- package/src/components/chat/ChatProjectBadge.tsx +362 -0
- package/src/components/chat/EmptyChat.tsx +18 -0
- package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
- package/src/components/chat/ExecPolicyPicker.tsx +120 -0
- package/src/components/chat/ExecutedActionsCard.tsx +216 -0
- package/src/components/chat/InterruptedTurnCard.tsx +53 -0
- package/src/components/chat/LocalChatPanel.tsx +408 -0
- package/src/components/chat/Markdown.test.tsx +170 -0
- package/src/components/chat/Markdown.tsx +432 -0
- package/src/components/chat/MessageList.tsx +332 -0
- package/src/components/chat/ModelPicker.test.tsx +149 -0
- package/src/components/chat/ModelPicker.tsx +289 -0
- package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
- package/src/components/chat/PlanTodoList.tsx +121 -0
- package/src/components/chat/ProjectTrustBanner.tsx +91 -0
- package/src/components/chat/SessionTreeModal.test.tsx +161 -0
- package/src/components/chat/SessionTreeModal.tsx +221 -0
- package/src/components/chat/StreamingStatus.tsx +95 -0
- package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
- package/src/components/chat/TaskOutcomeCards.tsx +128 -0
- package/src/components/chat/TaskPanelModal.test.tsx +198 -0
- package/src/components/chat/TaskPanelModal.tsx +310 -0
- package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
- package/src/components/chat/TaskProcessPanel.tsx +208 -0
- package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
- package/src/components/chat/TurnProcessGroup.tsx +193 -0
- package/src/components/chat/UnifiedDiff.tsx +31 -0
- package/src/components/chat/UserMessage.tsx +155 -0
- package/src/components/chat/elapsed.test.ts +42 -0
- package/src/components/chat/elapsed.ts +45 -0
- package/src/components/chat/executed-actions-model.test.ts +134 -0
- package/src/components/chat/executed-actions-model.ts +143 -0
- package/src/components/chat/orchestration-children-model.test.ts +115 -0
- package/src/components/chat/orchestration-children-model.ts +104 -0
- package/src/components/chat/timestamp.ts +24 -0
- package/src/components/chat/turn-timeline.test.ts +97 -0
- package/src/components/chat/turn-timeline.ts +132 -0
- package/src/components/chat/useChatTasks.test.ts +136 -0
- package/src/components/chat/useChatTasks.ts +134 -0
- package/src/components/chat/useCopy.ts +62 -0
- package/src/components/settings/AgentCapabilityFields.tsx +343 -0
- package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
- package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
- package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
- package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
- package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
- package/src/components/settings/LlmSettingsPanel.tsx +875 -0
- package/src/components/settings/McpSettingsPanel.tsx +466 -0
- package/src/components/settings/ModelCapabilityChips.tsx +46 -0
- package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
- package/src/components/settings/ModelIdCombobox.tsx +185 -0
- package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
- package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
- package/src/components/settings/SettingsSaveButton.tsx +35 -0
- package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
- package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
- package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
- package/src/components/settings/UsagePanel.tsx +196 -0
- package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
- package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
- package/src/components/settings/compat-flags-model.test.ts +71 -0
- package/src/components/settings/compat-flags-model.ts +57 -0
- package/src/components/settings/llm-vendors.test.ts +171 -0
- package/src/components/settings/llm-vendors.ts +275 -0
- package/src/components/settings/model-capabilities.test.ts +88 -0
- package/src/components/settings/model-capabilities.ts +123 -0
- package/src/components/settings/preset-choice-model.test.ts +96 -0
- package/src/components/settings/preset-choice-model.ts +75 -0
- package/src/fixtures/browser-dev-data.json +315 -0
- package/src/hooks/useChatsAndAgents.ts +182 -0
- package/src/layouts/AgentLayout.tsx +574 -0
- package/src/lib/attachments.ts +78 -0
- package/src/lib/browser-dev-electron-mock.ts +640 -0
- package/src/lib/chat-title.ts +33 -0
- package/src/lib/chat-transport.ts +346 -0
- package/src/lib/date-groups.ts +34 -0
- package/src/lib/demo-flag.ts +15 -0
- package/src/lib/electron-bridge.ts +270 -0
- package/src/lib/exec-policy.ts +25 -0
- package/src/lib/http-bridge.ts +293 -0
- package/src/lib/insights.ts +17 -0
- package/src/lib/local-api.ts +714 -0
- package/src/lib/pending-first-message.test.ts +17 -0
- package/src/lib/pending-first-message.ts +36 -0
- package/src/lib/slash-sources.ts +191 -0
- package/src/main.tsx +103 -0
- package/src/packs/registry.ts +64 -0
- package/src/pages/AgentPage.tsx +1226 -0
- package/src/pages/ChatPanelPreviewPage.tsx +264 -0
- package/src/pages/SettingsPage.test.tsx +102 -0
- package/src/pages/SettingsPage.tsx +197 -0
- package/src/styles/index.css +156 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.base.ts +94 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Outlet, useParams } from 'react-router-dom';
|
|
3
|
+
import { LuPanelLeftOpen } from 'react-icons/lu';
|
|
4
|
+
import { AgentSidebar } from '@/components/AgentSidebar';
|
|
5
|
+
import { TerminalPanel } from '@/components/TerminalPanel';
|
|
6
|
+
import { TaskProcessPanel, type InspectedTask } from '@/components/chat/TaskProcessPanel';
|
|
7
|
+
import { ApprovalModalHost } from '@/components/chat/ApprovalModal';
|
|
8
|
+
import { AskUserModalHost } from '@/components/chat/AskUserModalHost';
|
|
9
|
+
import { InsightsConsentBanner } from '@/components/settings/InsightsSettingsPanel';
|
|
10
|
+
import { trackBehavior } from '@/lib/insights';
|
|
11
|
+
import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
|
|
12
|
+
import { deleteChatIfEmpty, pruneEmptyChats } from '@/lib/local-api';
|
|
13
|
+
import { getPackChatSlots } from '@/packs/registry';
|
|
14
|
+
import {
|
|
15
|
+
useChatsAndAgents,
|
|
16
|
+
type UseChatsAndAgentsResult,
|
|
17
|
+
} from '@/hooks/useChatsAndAgents';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* AgentLayout wraps `/agent`, `/agent/:chatId`, and `/` (default) with a
|
|
21
|
+
* **resizable** sidebar + main content area. Pack-registered debug viewers
|
|
22
|
+
* lives OUTSIDE this layout in main.tsx, mounted directly under AppShell
|
|
23
|
+
* (it's loaded by its own dedicated native window).
|
|
24
|
+
*
|
|
25
|
+
* The chat/agents data lives at this layout level so the sidebar and any
|
|
26
|
+
* child route (chat header, future chat info panel) all share one cache.
|
|
27
|
+
* Children read it via `useOutletContext<AgentOutletContext>()`.
|
|
28
|
+
*
|
|
29
|
+
* Terminal panel:
|
|
30
|
+
* - Codex-style integrated terminal — a toggleable column BESIDE the
|
|
31
|
+
* chat content (not a route, not a separate window), so the xterm
|
|
32
|
+
* view stays mounted while the user switches chats. Toggled from the
|
|
33
|
+
* sidebar 终端 button or Cmd+T (`menu:open-terminal`, subscribed in
|
|
34
|
+
* AgentSidebar). Open state + width persist to localStorage.
|
|
35
|
+
* - Auto-reveal: when the agent runs a shell command through the
|
|
36
|
+
* visible PTY, main broadcasts `terminal:reveal` and this layout
|
|
37
|
+
* opens the panel (idempotent) so the user watches the agent type.
|
|
38
|
+
* - Closing the panel only unmounts the xterm VIEW; the PTY session is
|
|
39
|
+
* a main-process singleton and keeps running. Reopening replays the
|
|
40
|
+
* output buffer via `terminal:ensure`.
|
|
41
|
+
*
|
|
42
|
+
* Sidebar width:
|
|
43
|
+
* - Drag the handle (between sidebar + content) to resize.
|
|
44
|
+
* - Double-click the handle to reset to DEFAULT_SIDEBAR_WIDTH.
|
|
45
|
+
* - Width is persisted to localStorage so each user has a stable layout
|
|
46
|
+
* across sessions. Bounded by [MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH].
|
|
47
|
+
* - Body cursor + user-select are locked during drag to prevent text
|
|
48
|
+
* selection flicker while the user is dragging across chat rows.
|
|
49
|
+
* - The sidebar can be COLLAPSED entirely (header button in
|
|
50
|
+
* AgentSidebar) — a slim rail with an expand button takes its place.
|
|
51
|
+
* Collapse state is persisted too (`SIDEBAR_COLLAPSED_KEY`).
|
|
52
|
+
*
|
|
53
|
+
* Matches `deeppath/apps/web/src/app/agent/page.tsx`'s old resize ergonomics
|
|
54
|
+
* verbatim. Width key intentionally namespaced under `deeppath.agent.*`
|
|
55
|
+
* so it stays the same value the cloud sibling persists — users hopping
|
|
56
|
+
* between the two won't lose their preferred width.
|
|
57
|
+
*/
|
|
58
|
+
const SIDEBAR_WIDTH_KEY = 'deeppath.agent.sidebarWidth';
|
|
59
|
+
const SIDEBAR_COLLAPSED_KEY = 'deeppath.agent.sidebarCollapsed';
|
|
60
|
+
const DEFAULT_SIDEBAR_WIDTH = 200;
|
|
61
|
+
const MIN_SIDEBAR_WIDTH = 180;
|
|
62
|
+
const MAX_SIDEBAR_WIDTH = 420;
|
|
63
|
+
|
|
64
|
+
const TERMINAL_OPEN_KEY = 'deeppath.agent.terminalOpen';
|
|
65
|
+
const RIGHT_PANEL_KEY = 'deeppath.agent.rightPanel';
|
|
66
|
+
const TERMINAL_WIDTH_KEY = 'deeppath.agent.terminalWidth';
|
|
67
|
+
const DEFAULT_TERMINAL_WIDTH = 520;
|
|
68
|
+
const MIN_TERMINAL_WIDTH = 360;
|
|
69
|
+
const MAX_TERMINAL_WIDTH = 960;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 右侧栏位状态:null = 都不打开,'terminal' = 终端,其余字符串 = 包注册的
|
|
73
|
+
* 聊天页槽位(chatSlots,如文档包的预览栏位)。栏位同一时刻只开一个
|
|
74
|
+
* (互斥规则在本布局,不在包)。
|
|
75
|
+
*/
|
|
76
|
+
export type RightPanelState = string | null;
|
|
77
|
+
|
|
78
|
+
/** 从聊天页外把一段内容作为普通用户消息发进当前会话(包槽位的 fallback 通道)。 */
|
|
79
|
+
export type ChatMessageSender = (input: {
|
|
80
|
+
content: string;
|
|
81
|
+
metadata?: Record<string, unknown>;
|
|
82
|
+
}) => boolean | void | Promise<boolean | void>;
|
|
83
|
+
|
|
84
|
+
export type AgentOutletContext = UseChatsAndAgentsResult & {
|
|
85
|
+
/** AgentPage 挂载时注册它的 handleSubmit,供包槽位面板 fallback 发送。 */
|
|
86
|
+
registerChatMessageSender: (fn: ChatMessageSender | null) => void;
|
|
87
|
+
/** 发送普通用户消息到当前聊天;未注册时返回 false。 */
|
|
88
|
+
sendChatMessage: ChatMessageSender;
|
|
89
|
+
inspectTask: (task: InspectedTask) => void;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export function AgentLayout() {
|
|
93
|
+
const data = useChatsAndAgents();
|
|
94
|
+
const { chatId } = useParams<{ chatId?: string }>();
|
|
95
|
+
|
|
96
|
+
// ── 包槽位 fallback 发送通道:AgentPage 注册 handleSubmit,包面板(如
|
|
97
|
+
// PPT 单页修改在 sidecar 未就绪时)经 sendChatMessage 退回主聊天发送。
|
|
98
|
+
const chatMessageSenderRef = useRef<ChatMessageSender | null>(null);
|
|
99
|
+
const registerChatMessageSender = useCallback((fn: ChatMessageSender | null) => {
|
|
100
|
+
chatMessageSenderRef.current = fn;
|
|
101
|
+
}, []);
|
|
102
|
+
const sendChatMessage = useCallback<ChatMessageSender>((input) => {
|
|
103
|
+
const fn = chatMessageSenderRef.current;
|
|
104
|
+
if (!fn) return false;
|
|
105
|
+
return fn(input);
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
// 订阅后台 AI 标题异步更新。Backend 在 chat 第一条回复完成后 fire-and-forget
|
|
109
|
+
// 跑 LLM 生成标题,完成时通过 IPC 广播 `chat-title-updated`。这里就近 patch
|
|
110
|
+
// sidebar 的标题——不打后端、不 refresh 列表(避免重置分页)。
|
|
111
|
+
//
|
|
112
|
+
// 放在 layout 而不是 AgentPage:因为 AgentPage 只在打开某个 chat 时挂载,
|
|
113
|
+
// 标题更新可能在用户已经切到别的 chat 时才来;layout 一直在,订阅一次就够。
|
|
114
|
+
// 用 ref 把 patchChatTitle 包起来——这样 effect 只跑一次(只依赖空数组)
|
|
115
|
+
// 而 callback 永远拿到最新版本。
|
|
116
|
+
const patchChatTitleRef = useRef(data.patchChatTitle);
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
patchChatTitleRef.current = data.patchChatTitle;
|
|
119
|
+
}, [data.patchChatTitle]);
|
|
120
|
+
const refreshChatsRef = useRef(data.refreshChats);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
refreshChatsRef.current = data.refreshChats;
|
|
123
|
+
}, [data.refreshChats]);
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
const bridge = getElectronBridge();
|
|
126
|
+
if (!bridge?.onChatTitleUpdated) return;
|
|
127
|
+
const unsubscribe = bridge.onChatTitleUpdated((payload) => {
|
|
128
|
+
patchChatTitleRef.current(payload.chatId, payload.title);
|
|
129
|
+
});
|
|
130
|
+
return unsubscribe;
|
|
131
|
+
}, []);
|
|
132
|
+
// 会话补建通知:Backend 对本地不存在的 chatId 首次发送时会按 URL 里的 id
|
|
133
|
+
// 现场补建(见 router.handleStream),随后广播 `chat-created`。这里刷一次
|
|
134
|
+
// 侧栏把这条会话拉出来——否则会出现"URL 能聊、列表里却查无此会话"。
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const bridge = getElectronBridge();
|
|
137
|
+
if (!bridge?.onChatCreated) return;
|
|
138
|
+
const unsubscribe = bridge.onChatCreated(() => {
|
|
139
|
+
void refreshChatsRef.current();
|
|
140
|
+
});
|
|
141
|
+
return unsubscribe;
|
|
142
|
+
}, []);
|
|
143
|
+
|
|
144
|
+
// 空会话不进侧栏:启动时清掉从未发过消息的残骸;离开一段空对话时丢掉它。
|
|
145
|
+
// except / 离开检测都避开当前打开的 chatId,避免和首页首条发送抢跑。
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!isElectron()) return;
|
|
148
|
+
let cancelled = false;
|
|
149
|
+
void pruneEmptyChats(chatId)
|
|
150
|
+
.then((res) => {
|
|
151
|
+
if (cancelled || (res.deletedChatIds?.length ?? 0) === 0) return;
|
|
152
|
+
return refreshChatsRef.current();
|
|
153
|
+
})
|
|
154
|
+
.catch(() => {});
|
|
155
|
+
return () => {
|
|
156
|
+
cancelled = true;
|
|
157
|
+
};
|
|
158
|
+
// 只在 layout 挂载时扫一次历史空会话。
|
|
159
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
160
|
+
}, []);
|
|
161
|
+
const prevChatIdRef = useRef<string | undefined>(undefined);
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
const prev = prevChatIdRef.current;
|
|
164
|
+
prevChatIdRef.current = chatId;
|
|
165
|
+
if (!isElectron() || !prev || prev === chatId) return;
|
|
166
|
+
void deleteChatIfEmpty(prev)
|
|
167
|
+
.then((res) => {
|
|
168
|
+
if (res.deleted) return refreshChatsRef.current();
|
|
169
|
+
})
|
|
170
|
+
.catch(() => {});
|
|
171
|
+
}, [chatId]);
|
|
172
|
+
|
|
173
|
+
const [sidebarWidth, setSidebarWidth] = useState<number>(
|
|
174
|
+
DEFAULT_SIDEBAR_WIDTH,
|
|
175
|
+
);
|
|
176
|
+
// 收起状态持久化——刷新/重启后保持用户偏好的布局。收起时渲染一条窄
|
|
177
|
+
// rail(只有展开按钮),而不是把侧边栏整个从树里摘掉。
|
|
178
|
+
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
|
|
179
|
+
try {
|
|
180
|
+
return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1';
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
const toggleSidebarCollapsed = useCallback(() => {
|
|
186
|
+
setSidebarCollapsed((v) => {
|
|
187
|
+
try {
|
|
188
|
+
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, v ? '0' : '1');
|
|
189
|
+
} catch {
|
|
190
|
+
/* ignore */
|
|
191
|
+
}
|
|
192
|
+
return !v;
|
|
193
|
+
});
|
|
194
|
+
}, []);
|
|
195
|
+
const [isResizing, setIsResizing] = useState(false);
|
|
196
|
+
// We keep the live width in a ref so the mousemove handler always reads
|
|
197
|
+
// the freshest value (the `mouseup` cleanup needs to persist the last
|
|
198
|
+
// width, but it captures `sidebarWidth` from the closure — without a ref
|
|
199
|
+
// it would lag one frame behind on fast drags).
|
|
200
|
+
const sidebarWidthRef = useRef<number>(DEFAULT_SIDEBAR_WIDTH);
|
|
201
|
+
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
try {
|
|
204
|
+
const saved = window.localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
|
205
|
+
if (saved) {
|
|
206
|
+
const n = parseInt(saved, 10);
|
|
207
|
+
if (
|
|
208
|
+
!Number.isNaN(n) &&
|
|
209
|
+
n >= MIN_SIDEBAR_WIDTH &&
|
|
210
|
+
n <= MAX_SIDEBAR_WIDTH
|
|
211
|
+
) {
|
|
212
|
+
setSidebarWidth(n);
|
|
213
|
+
sidebarWidthRef.current = n;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
/* ignore */
|
|
218
|
+
}
|
|
219
|
+
}, []);
|
|
220
|
+
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
if (!isResizing) return;
|
|
223
|
+
const onMove = (e: MouseEvent) => {
|
|
224
|
+
const next = Math.min(
|
|
225
|
+
MAX_SIDEBAR_WIDTH,
|
|
226
|
+
Math.max(MIN_SIDEBAR_WIDTH, e.clientX),
|
|
227
|
+
);
|
|
228
|
+
sidebarWidthRef.current = next;
|
|
229
|
+
setSidebarWidth(next);
|
|
230
|
+
};
|
|
231
|
+
const onUp = () => {
|
|
232
|
+
setIsResizing(false);
|
|
233
|
+
try {
|
|
234
|
+
window.localStorage.setItem(
|
|
235
|
+
SIDEBAR_WIDTH_KEY,
|
|
236
|
+
String(sidebarWidthRef.current),
|
|
237
|
+
);
|
|
238
|
+
} catch {
|
|
239
|
+
/* ignore */
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
window.addEventListener('mousemove', onMove);
|
|
243
|
+
window.addEventListener('mouseup', onUp);
|
|
244
|
+
document.body.style.cursor = 'col-resize';
|
|
245
|
+
document.body.style.userSelect = 'none';
|
|
246
|
+
return () => {
|
|
247
|
+
window.removeEventListener('mousemove', onMove);
|
|
248
|
+
window.removeEventListener('mouseup', onUp);
|
|
249
|
+
document.body.style.cursor = '';
|
|
250
|
+
document.body.style.userSelect = '';
|
|
251
|
+
};
|
|
252
|
+
}, [isResizing]);
|
|
253
|
+
|
|
254
|
+
const resetWidth = useCallback(() => {
|
|
255
|
+
setSidebarWidth(DEFAULT_SIDEBAR_WIDTH);
|
|
256
|
+
sidebarWidthRef.current = DEFAULT_SIDEBAR_WIDTH;
|
|
257
|
+
try {
|
|
258
|
+
window.localStorage.setItem(
|
|
259
|
+
SIDEBAR_WIDTH_KEY,
|
|
260
|
+
String(DEFAULT_SIDEBAR_WIDTH),
|
|
261
|
+
);
|
|
262
|
+
} catch {
|
|
263
|
+
/* ignore */
|
|
264
|
+
}
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
// ───── Right-side panel (embedded column beside the chat content) ─────
|
|
268
|
+
// 终端与包槽位面板(如 PPT 预览)合并为同一栏位的互斥切换:
|
|
269
|
+
// null | 'terminal' | <slotId>。持久化到新 key;老版本只持久化终端打开
|
|
270
|
+
// 状态,首次读取时迁移。
|
|
271
|
+
const packChatSlots = getPackChatSlots();
|
|
272
|
+
const [rightPanel, setRightPanelState] = useState<RightPanelState>(() => {
|
|
273
|
+
try {
|
|
274
|
+
const saved = window.localStorage.getItem(RIGHT_PANEL_KEY);
|
|
275
|
+
if (saved !== null) {
|
|
276
|
+
return saved === 'terminal' || packChatSlots.some((s) => s.slotId === saved)
|
|
277
|
+
? saved
|
|
278
|
+
: null;
|
|
279
|
+
}
|
|
280
|
+
// 新 key 不存在时才迁移老版本仅持久化终端的旧 key。
|
|
281
|
+
return window.localStorage.getItem(TERMINAL_OPEN_KEY) === '1'
|
|
282
|
+
? 'terminal'
|
|
283
|
+
: null;
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// 自动展开 / 手动切换回调是挂载时注册的,直接读 state 会拿到过期闭包,
|
|
290
|
+
// 所以用 ref 跟踪当前栏位状态(见下方同步 effect)。
|
|
291
|
+
const rightPanelRef = useRef(rightPanel);
|
|
292
|
+
|
|
293
|
+
const persistRightPanel = useCallback((next: RightPanelState) => {
|
|
294
|
+
try {
|
|
295
|
+
window.localStorage.setItem(RIGHT_PANEL_KEY, next ?? '');
|
|
296
|
+
} catch {
|
|
297
|
+
/* ignore */
|
|
298
|
+
}
|
|
299
|
+
}, []);
|
|
300
|
+
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
rightPanelRef.current = rightPanel;
|
|
303
|
+
}, [rightPanel]);
|
|
304
|
+
|
|
305
|
+
const closeRightPanel = useCallback(() => {
|
|
306
|
+
rightPanelRef.current = null;
|
|
307
|
+
setRightPanelState(null);
|
|
308
|
+
persistRightPanel(null);
|
|
309
|
+
}, [persistRightPanel]);
|
|
310
|
+
|
|
311
|
+
/** 分段控件 / 快捷键入口:点已打开的按钮关闭;点另一个按钮直接切换。
|
|
312
|
+
打开任一面板即离开任务 dock(栏位同一时刻只呈现一个内容)。 */
|
|
313
|
+
const toggleRightPanel = useCallback(
|
|
314
|
+
(kind: string) => {
|
|
315
|
+
const next: RightPanelState = rightPanelRef.current === kind ? null : kind;
|
|
316
|
+
rightPanelRef.current = next;
|
|
317
|
+
if (next !== null) setInspectedTask(null);
|
|
318
|
+
setRightPanelState(next);
|
|
319
|
+
persistRightPanel(next);
|
|
320
|
+
},
|
|
321
|
+
[persistRightPanel],
|
|
322
|
+
);
|
|
323
|
+
// dock 里当前显示的任务(null = 显示终端)。recentTask 在切回终端后仍
|
|
324
|
+
// 保留,终端头部的「后台任务」按钮据此切回来——任务的挑选入口只有一个,
|
|
325
|
+
// 就是 chat 头部的后台任务弹层。
|
|
326
|
+
const [inspectedTask, setInspectedTask] = useState<InspectedTask | null>(null);
|
|
327
|
+
const [recentTask, setRecentTask] = useState<InspectedTask | null>(null);
|
|
328
|
+
const [terminalWidth, setTerminalWidth] = useState<number>(
|
|
329
|
+
DEFAULT_TERMINAL_WIDTH,
|
|
330
|
+
);
|
|
331
|
+
const [isTerminalResizing, setIsTerminalResizing] = useState(false);
|
|
332
|
+
const terminalWidthRef = useRef<number>(DEFAULT_TERMINAL_WIDTH);
|
|
333
|
+
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
try {
|
|
336
|
+
const saved = window.localStorage.getItem(TERMINAL_WIDTH_KEY);
|
|
337
|
+
if (saved) {
|
|
338
|
+
const n = parseInt(saved, 10);
|
|
339
|
+
if (
|
|
340
|
+
!Number.isNaN(n) &&
|
|
341
|
+
n >= MIN_TERMINAL_WIDTH &&
|
|
342
|
+
n <= MAX_TERMINAL_WIDTH
|
|
343
|
+
) {
|
|
344
|
+
setTerminalWidth(n);
|
|
345
|
+
terminalWidthRef.current = n;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
/* ignore */
|
|
350
|
+
}
|
|
351
|
+
}, []);
|
|
352
|
+
|
|
353
|
+
const inspectTask = useCallback((task: InspectedTask) => {
|
|
354
|
+
setInspectedTask(task);
|
|
355
|
+
setRecentTask(task);
|
|
356
|
+
}, []);
|
|
357
|
+
|
|
358
|
+
const showRecentTask = useCallback(() => {
|
|
359
|
+
setInspectedTask(recentTask);
|
|
360
|
+
}, [recentTask]);
|
|
361
|
+
|
|
362
|
+
const closeTaskProcess = useCallback(() => {
|
|
363
|
+
setInspectedTask(null);
|
|
364
|
+
}, []);
|
|
365
|
+
|
|
366
|
+
const showTerminalFromTask = useCallback(() => {
|
|
367
|
+
setInspectedTask(null);
|
|
368
|
+
rightPanelRef.current = 'terminal';
|
|
369
|
+
setRightPanelState('terminal');
|
|
370
|
+
persistRightPanel('terminal');
|
|
371
|
+
}, [persistRightPanel]);
|
|
372
|
+
|
|
373
|
+
// Codex 式 auto-reveal:agent 的命令进入可见 PTY 时,main 广播
|
|
374
|
+
// `terminal:reveal`,这里自动展开终端面板。幂等——已打开时是 no-op;
|
|
375
|
+
// 用户手动关掉后,下一条命令会再次拉开(符合"看 agent 打字"的意图)。
|
|
376
|
+
useEffect(() => {
|
|
377
|
+
const bridge = getElectronBridge();
|
|
378
|
+
if (!bridge?.terminal?.onReveal) return;
|
|
379
|
+
return bridge.terminal.onReveal(() => {
|
|
380
|
+
setInspectedTask(null);
|
|
381
|
+
rightPanelRef.current = 'terminal';
|
|
382
|
+
setRightPanelState('terminal');
|
|
383
|
+
persistRightPanel('terminal');
|
|
384
|
+
});
|
|
385
|
+
}, [persistRightPanel]);
|
|
386
|
+
|
|
387
|
+
// 包槽位的自动展开(如文档包:后端在本轮产出新设计稿时广播包事件)。
|
|
388
|
+
// 互斥规则:仅当栏位空闲(null)时 reveal 才生效;
|
|
389
|
+
// 已打开任一面板时,自动展开请求被忽略。订阅逻辑由包自己实现。
|
|
390
|
+
useEffect(() => {
|
|
391
|
+
const cleanups: Array<() => void> = [];
|
|
392
|
+
for (const slot of packChatSlots) {
|
|
393
|
+
const cleanup = slot.setupAutoReveal?.({
|
|
394
|
+
reveal: () => {
|
|
395
|
+
if (rightPanelRef.current !== null) return;
|
|
396
|
+
rightPanelRef.current = slot.slotId;
|
|
397
|
+
setRightPanelState(slot.slotId);
|
|
398
|
+
persistRightPanel(slot.slotId);
|
|
399
|
+
},
|
|
400
|
+
getCurrentChatId: () => chatId ?? null,
|
|
401
|
+
});
|
|
402
|
+
if (cleanup) cleanups.push(cleanup);
|
|
403
|
+
}
|
|
404
|
+
return () => {
|
|
405
|
+
for (const cleanup of cleanups) cleanup();
|
|
406
|
+
};
|
|
407
|
+
// packChatSlots 在 bootstrap 后稳定;chatId 经 getCurrentChatId 闭包读取。
|
|
408
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
409
|
+
}, [chatId, persistRightPanel]);
|
|
410
|
+
|
|
411
|
+
// Same drag ergonomics as the sidebar handle, mirrored: the terminal's
|
|
412
|
+
// right edge is pinned to the window's right padding (p-2 = 8px), so the
|
|
413
|
+
// width is the distance from the cursor to that edge.
|
|
414
|
+
useEffect(() => {
|
|
415
|
+
if (!isTerminalResizing) return;
|
|
416
|
+
const onMove = (e: MouseEvent) => {
|
|
417
|
+
const next = Math.min(
|
|
418
|
+
MAX_TERMINAL_WIDTH,
|
|
419
|
+
Math.max(MIN_TERMINAL_WIDTH, window.innerWidth - 8 - e.clientX),
|
|
420
|
+
);
|
|
421
|
+
terminalWidthRef.current = next;
|
|
422
|
+
setTerminalWidth(next);
|
|
423
|
+
};
|
|
424
|
+
const onUp = () => {
|
|
425
|
+
setIsTerminalResizing(false);
|
|
426
|
+
try {
|
|
427
|
+
window.localStorage.setItem(
|
|
428
|
+
TERMINAL_WIDTH_KEY,
|
|
429
|
+
String(terminalWidthRef.current),
|
|
430
|
+
);
|
|
431
|
+
} catch {
|
|
432
|
+
/* ignore */
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
window.addEventListener('mousemove', onMove);
|
|
436
|
+
window.addEventListener('mouseup', onUp);
|
|
437
|
+
document.body.style.cursor = 'col-resize';
|
|
438
|
+
document.body.style.userSelect = 'none';
|
|
439
|
+
return () => {
|
|
440
|
+
window.removeEventListener('mousemove', onMove);
|
|
441
|
+
window.removeEventListener('mouseup', onUp);
|
|
442
|
+
document.body.style.cursor = '';
|
|
443
|
+
document.body.style.userSelect = '';
|
|
444
|
+
};
|
|
445
|
+
}, [isTerminalResizing]);
|
|
446
|
+
|
|
447
|
+
const resetTerminalWidth = useCallback(() => {
|
|
448
|
+
setTerminalWidth(DEFAULT_TERMINAL_WIDTH);
|
|
449
|
+
terminalWidthRef.current = DEFAULT_TERMINAL_WIDTH;
|
|
450
|
+
try {
|
|
451
|
+
window.localStorage.setItem(
|
|
452
|
+
TERMINAL_WIDTH_KEY,
|
|
453
|
+
String(DEFAULT_TERMINAL_WIDTH),
|
|
454
|
+
);
|
|
455
|
+
} catch {
|
|
456
|
+
/* ignore */
|
|
457
|
+
}
|
|
458
|
+
}, []);
|
|
459
|
+
|
|
460
|
+
useEffect(() => {
|
|
461
|
+
trackBehavior('app_open', { path: window.location.hash || '#/' });
|
|
462
|
+
}, []);
|
|
463
|
+
|
|
464
|
+
return (
|
|
465
|
+
<div className="relative flex h-full w-full bg-agent-muted/30">
|
|
466
|
+
{sidebarCollapsed ? (
|
|
467
|
+
// 收起态:窄 rail 只放展开按钮。新建对话仍可用 Cmd+N / 菜单触发。
|
|
468
|
+
<div className="flex h-full w-10 flex-shrink-0 flex-col items-center border-r border-agent-border/60 bg-agent-muted/70 py-2 backdrop-blur-md">
|
|
469
|
+
<button
|
|
470
|
+
type="button"
|
|
471
|
+
onClick={toggleSidebarCollapsed}
|
|
472
|
+
className="flex h-7 w-7 items-center justify-center rounded-full text-agent-muted-foreground transition-colors duration-200 hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
473
|
+
title="展开侧边栏"
|
|
474
|
+
aria-label="展开侧边栏"
|
|
475
|
+
>
|
|
476
|
+
<LuPanelLeftOpen className="h-3.5 w-3.5" />
|
|
477
|
+
</button>
|
|
478
|
+
</div>
|
|
479
|
+
) : (
|
|
480
|
+
<>
|
|
481
|
+
<div className="h-full flex-shrink-0" style={{ width: sidebarWidth }}>
|
|
482
|
+
<AgentSidebar
|
|
483
|
+
data={data}
|
|
484
|
+
rightPanel={rightPanel}
|
|
485
|
+
onToggleRightPanel={toggleRightPanel}
|
|
486
|
+
chatSlots={packChatSlots}
|
|
487
|
+
onCollapse={toggleSidebarCollapsed}
|
|
488
|
+
/>
|
|
489
|
+
</div>
|
|
490
|
+
{/* Drag handle — 1px wide, 4px hit area via padding. */}
|
|
491
|
+
<div
|
|
492
|
+
role="separator"
|
|
493
|
+
aria-orientation="vertical"
|
|
494
|
+
aria-label="调整侧边栏宽度"
|
|
495
|
+
onMouseDown={() => setIsResizing(true)}
|
|
496
|
+
onDoubleClick={resetWidth}
|
|
497
|
+
className="w-1 flex-shrink-0 cursor-col-resize bg-transparent transition-colors hover:bg-agent-border/60"
|
|
498
|
+
title="拖拽调整侧边栏宽度(双击恢复默认)"
|
|
499
|
+
/>
|
|
500
|
+
</>
|
|
501
|
+
)}
|
|
502
|
+
<div className="h-full min-w-0 flex-1 p-2">
|
|
503
|
+
<div className="flex h-full w-full">
|
|
504
|
+
<div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden rounded-agent-lg bg-agent-canvas shadow-sm">
|
|
505
|
+
<div className="min-h-0 flex-1 overflow-hidden">
|
|
506
|
+
<Outlet
|
|
507
|
+
context={{
|
|
508
|
+
...data,
|
|
509
|
+
registerChatMessageSender,
|
|
510
|
+
sendChatMessage,
|
|
511
|
+
inspectTask,
|
|
512
|
+
} satisfies AgentOutletContext}
|
|
513
|
+
/>
|
|
514
|
+
</div>
|
|
515
|
+
<InsightsConsentBanner />
|
|
516
|
+
</div>
|
|
517
|
+
{(rightPanel !== null || inspectedTask !== null) && (
|
|
518
|
+
<>
|
|
519
|
+
<div
|
|
520
|
+
role="separator"
|
|
521
|
+
aria-orientation="vertical"
|
|
522
|
+
aria-label="调整右侧面板宽度"
|
|
523
|
+
onMouseDown={() => setIsTerminalResizing(true)}
|
|
524
|
+
onDoubleClick={resetTerminalWidth}
|
|
525
|
+
className="w-1 flex-shrink-0 cursor-col-resize bg-transparent transition-colors hover:bg-agent-border/60"
|
|
526
|
+
title="拖拽调整右侧面板宽度(双击恢复默认)"
|
|
527
|
+
/>
|
|
528
|
+
<div
|
|
529
|
+
className={`h-full flex-shrink-0 overflow-hidden rounded-agent-lg shadow-sm ${
|
|
530
|
+
rightPanel === 'terminal' && !inspectedTask ? 'bg-[#0b0b0c]' : 'bg-agent-canvas'
|
|
531
|
+
}`}
|
|
532
|
+
style={{ width: terminalWidth }}
|
|
533
|
+
data-testid={inspectedTask ? 'task-process-dock' : 'terminal-panel'}
|
|
534
|
+
>
|
|
535
|
+
{inspectedTask ? (
|
|
536
|
+
<TaskProcessPanel
|
|
537
|
+
inspected={inspectedTask}
|
|
538
|
+
onClose={closeTaskProcess}
|
|
539
|
+
onShowTerminal={showTerminalFromTask}
|
|
540
|
+
/>
|
|
541
|
+
) : rightPanel === 'terminal' ? (
|
|
542
|
+
<TerminalPanel
|
|
543
|
+
onClose={closeRightPanel}
|
|
544
|
+
onShowTaskProcess={recentTask ? showRecentTask : undefined}
|
|
545
|
+
taskProcessTitle={recentTask?.title}
|
|
546
|
+
/>
|
|
547
|
+
) : (
|
|
548
|
+
(() => {
|
|
549
|
+
const slot = packChatSlots.find((s) => s.slotId === rightPanel);
|
|
550
|
+
if (!slot) return null;
|
|
551
|
+
const SlotPanel = slot.Component;
|
|
552
|
+
return (
|
|
553
|
+
<SlotPanel
|
|
554
|
+
chatId={chatId ?? ''}
|
|
555
|
+
onClose={closeRightPanel}
|
|
556
|
+
onSubmitToChat={sendChatMessage}
|
|
557
|
+
/>
|
|
558
|
+
);
|
|
559
|
+
})()
|
|
560
|
+
)}
|
|
561
|
+
</div>
|
|
562
|
+
</>
|
|
563
|
+
)}
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
566
|
+
{/* W4-1 审批弹窗挂在 layout 层而非 chat 视图:审批请求属于正在跑的
|
|
567
|
+
回合,回合不因用户切走页面而暂停——挂在 AgentChatView 下时,用户
|
|
568
|
+
导航到首页/别的对话会让模态永不渲染,请求只能等超时 fail-closed。 */}
|
|
569
|
+
{isElectron() && <ApprovalModalHost />}
|
|
570
|
+
{/* W8 提问卡片同理:挂在 layout 层,页面切换不丢待答问题。 */}
|
|
571
|
+
{isElectron() && <AskUserModalHost />}
|
|
572
|
+
</div>
|
|
573
|
+
);
|
|
574
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话附件(文件上传)的 renderer 侧助手。
|
|
3
|
+
*
|
|
4
|
+
* ChatInput 只负责「收集」文件——通过隐藏的 `<input type="file" multiple>`
|
|
5
|
+
* 或拖拽拿到 File,产出 `AttachmentFile`({name, path, file?})交给父组件。
|
|
6
|
+
* 真正把文件落进会话空间(`<userDataDir>/attachments/<chatId>/`)由
|
|
7
|
+
* `saveChatAttachments` 在提交时完成:
|
|
8
|
+
* - Electron:File 对象带真实 `path`,走 `attachments:save` IPC 拷贝;
|
|
9
|
+
* - BS/browser:File 没有路径,这里把字节读成 base64 走 HTTP 端点落盘。
|
|
10
|
+
* 返回的落盘路径会重写消息正文里的文件引用与图像附件元数据。
|
|
11
|
+
*/
|
|
12
|
+
import { getElectronBridge, isElectron } from './electron-bridge';
|
|
13
|
+
|
|
14
|
+
export interface AttachmentFile {
|
|
15
|
+
name: string;
|
|
16
|
+
/** Electron 下的源绝对路径;浏览器模式为空串(用 file 读字节)。 */
|
|
17
|
+
path: string;
|
|
18
|
+
/** 原始 File 对象,用于浏览器模式在提交时读取字节。 */
|
|
19
|
+
file?: File;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
|
|
23
|
+
|
|
24
|
+
/** 扩展名是否为图像(renderer 侧预过滤,后端 image-attachment 会二次校验)。 */
|
|
25
|
+
export function isImageFile(path: string): boolean {
|
|
26
|
+
const dot = path.lastIndexOf('.');
|
|
27
|
+
return dot >= 0 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** File 字节 → base64(浏览器模式上传用)。分块避免大文件触发调用栈限制。 */
|
|
31
|
+
export async function fileToBase64(file: File): Promise<string> {
|
|
32
|
+
const buffer = await file.arrayBuffer();
|
|
33
|
+
const bytes = new Uint8Array(buffer);
|
|
34
|
+
const chunkSize = 0x8000;
|
|
35
|
+
let binary = '';
|
|
36
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
37
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
|
38
|
+
}
|
|
39
|
+
return btoa(binary);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 把一批附件持久化到会话附件目录,返回「提交时应使用」的文件列表:
|
|
44
|
+
* 成功项用落盘路径,失败项退回原文件(不让用户的选择因单个文件失败而丢失)。
|
|
45
|
+
* 非 Electron / 无 attachments 桥 / 空 chatId 时原样返回。
|
|
46
|
+
*/
|
|
47
|
+
export async function saveChatAttachments(
|
|
48
|
+
chatId: string | null | undefined,
|
|
49
|
+
files: AttachmentFile[],
|
|
50
|
+
): Promise<AttachmentFile[]> {
|
|
51
|
+
if (!chatId || files.length === 0) return files;
|
|
52
|
+
if (!isElectron()) return files;
|
|
53
|
+
const bridge = getElectronBridge();
|
|
54
|
+
if (!bridge?.attachments?.save) return files;
|
|
55
|
+
try {
|
|
56
|
+
// 有真实路径走 path 拷贝(Electron);没有则读字节走 data 上传(BS/browser)。
|
|
57
|
+
const payloadFiles = await Promise.all(
|
|
58
|
+
files.map(async (file) => {
|
|
59
|
+
if (file.path) return { name: file.name, path: file.path };
|
|
60
|
+
if (file.file) return { name: file.name, data: await fileToBase64(file.file) };
|
|
61
|
+
return { name: file.name, path: file.path || '' };
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
const result = await bridge.attachments.save({ chatId, files: payloadFiles });
|
|
65
|
+
const stored = Array.isArray(result?.files) ? result.files : [];
|
|
66
|
+
// 服务端按输入顺序返回逐文件结果,这里按下标对齐回填。
|
|
67
|
+
return files.map((file, index) => {
|
|
68
|
+
const entry = stored[index];
|
|
69
|
+
if (entry && entry.path && !entry.error) {
|
|
70
|
+
return { name: entry.name || file.name, path: entry.path };
|
|
71
|
+
}
|
|
72
|
+
return file;
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.warn('[attachments] save failed, falling back to source paths', err);
|
|
76
|
+
return files;
|
|
77
|
+
}
|
|
78
|
+
}
|