@steerable/agent-shell-web 0.6.35 → 0.6.37

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,17 +1,17 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-web",
3
- "version": "0.6.35",
3
+ "version": "0.6.37",
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",
7
7
  "homepage": "https://steerableframework.com/",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/pathlyapp/steerable-framework.git",
10
+ "url": "git+https://github.com/pathlyapp/opensteerable.git",
11
11
  "directory": "packages/agent-shell/web"
12
12
  },
13
13
  "bugs": {
14
- "url": "https://github.com/pathlyapp/steerable-framework/issues"
14
+ "url": "https://github.com/pathlyapp/opensteerable/issues"
15
15
  },
16
16
  "exports": {
17
17
  "./auth/gate": "./src/auth/gate.ts",
@@ -43,8 +43,8 @@
43
43
  "react-router-dom": "^6.30.0",
44
44
  "rehype-raw": "^7.0.0",
45
45
  "remark-gfm": "^4.0.1",
46
- "@steerable/agent-ui": "0.6.35",
47
- "@steerable/agent-protocol": "0.6.35"
46
+ "@steerable/agent-protocol": "0.6.37",
47
+ "@steerable/agent-ui": "0.6.37"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@tailwindcss/vite": "^4.3.0",
@@ -57,7 +57,7 @@
57
57
  "typescript": "^5.8.3",
58
58
  "vite": "^5.4.21",
59
59
  "vitest": "^3.2.4",
60
- "@steerable/pack-sdk": "0.6.35"
60
+ "@steerable/pack-sdk": "0.6.37"
61
61
  },
62
62
  "scripts": {
63
63
  "gen": "echo agent-shell-web",
@@ -115,7 +115,6 @@ import {
115
115
  type LocalProject,
116
116
  } from "@/lib/local-api";
117
117
  import type { UseChatsAndAgentsResult } from "@/hooks/useChatsAndAgents";
118
- import type { PackChatSlotContribution } from "@/packs/registry";
119
118
  import type { RightPanelState } from "@/layouts/AgentLayout";
120
119
  import { BrandLockup } from "@/components/BrandLockup";
121
120
  import { CreateProjectModal } from "@/components/CreateProjectModal";
@@ -272,8 +271,6 @@ interface AgentSidebarProps {
272
271
  rightPanel: RightPanelState;
273
272
  /** 切换右侧栏面板显隐——面板不是路由也不是独立窗口,只是布局里的一栏。 */
274
273
  onToggleRightPanel: (kind: string) => void;
275
- /** 包注册的聊天页槽位(分段控件里每个槽位一个按钮);空 = 只有终端。 */
276
- chatSlots: readonly PackChatSlotContribution[];
277
274
  /** 收起侧边栏(AgentLayout 换成窄 rail,展开按钮在 rail 上)。 */
278
275
  onCollapse: () => void;
279
276
  }
@@ -282,7 +279,6 @@ export function AgentSidebar({
282
279
  data,
283
280
  rightPanel,
284
281
  onToggleRightPanel,
285
- chatSlots,
286
282
  onCollapse,
287
283
  }: AgentSidebarProps) {
288
284
  const navigate = useNavigate();
@@ -1054,70 +1050,26 @@ export function AgentSidebar({
1054
1050
 
1055
1051
  {/* ───── Footer: 右侧面板切换(终端 | 包槽位)+ 设置 ───── */}
1056
1052
  <div className="flex-shrink-0 border-t border-agent-border/40 px-2.5 py-1.5">
1057
- {chatSlots.length === 0 ? (
1058
- showTerminalChrome ? (
1059
- <button
1060
- type="button"
1061
- onClick={() => onToggleRightPanel("terminal")}
1062
- className={[
1063
- "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
1064
- rightPanel === "terminal"
1065
- ? "bg-agent-foreground/10 font-medium text-agent-foreground"
1066
- : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-foreground",
1067
- ].join(" ")}
1068
- title={`${rightPanel === "terminal" ? "关闭" : "打开"}终端面板 (${isMac ? "⌘T" : "Ctrl+T"})`}
1069
- data-testid="sidebar-terminal"
1070
- >
1071
- <LuTerminal className="h-3.5 w-3.5" />
1072
- <span>终端</span>
1073
- <span className="ml-auto text-[10px] text-agent-muted-foreground/70">
1074
- {isMac ? "⌘T" : "Ctrl+T"}
1075
- </span>
1076
- </button>
1077
- ) : null
1078
- ) : (
1079
- <div
1080
- role="group"
1081
- aria-label="右侧面板切换"
1082
- className="flex h-7 w-full items-center gap-0.5 rounded-full bg-agent-foreground/5 p-0.5"
1083
- >
1084
- {showTerminalChrome ? (
1085
- <button
1086
- type="button"
1087
- onClick={() => onToggleRightPanel("terminal")}
1088
- className={[
1089
- "flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors",
1090
- rightPanel === "terminal"
1091
- ? "bg-agent-foreground/10 text-agent-foreground"
1092
- : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
1093
- ].join(" ")}
1094
- title={`${rightPanel === "terminal" ? "关闭" : "打开"}终端面板`}
1095
- data-testid="sidebar-terminal"
1096
- >
1097
- <LuTerminal className="h-3.5 w-3.5" />
1098
- <span>终端</span>
1099
- </button>
1100
- ) : null}
1101
- {chatSlots.map((slot) => (
1102
- <button
1103
- key={slot.slotId}
1104
- type="button"
1105
- onClick={() => onToggleRightPanel(slot.slotId)}
1106
- className={[
1107
- "flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors",
1108
- rightPanel === slot.slotId
1109
- ? "bg-agent-foreground/10 text-agent-foreground"
1110
- : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
1111
- ].join(" ")}
1112
- title={`${rightPanel === slot.slotId ? "关闭" : "打开"}${slot.title}`}
1113
- data-testid={`sidebar-slot-${slot.slotId}`}
1114
- >
1115
- <slot.Icon className="h-3.5 w-3.5" />
1116
- <span>{slot.title}</span>
1117
- </button>
1118
- ))}
1119
- </div>
1120
- )}
1053
+ {showTerminalChrome ? (
1054
+ <button
1055
+ type="button"
1056
+ onClick={() => onToggleRightPanel("terminal")}
1057
+ className={[
1058
+ "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
1059
+ rightPanel === "terminal"
1060
+ ? "bg-agent-foreground/10 font-medium text-agent-foreground"
1061
+ : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-foreground",
1062
+ ].join(" ")}
1063
+ title={`${rightPanel === "terminal" ? "关闭" : "打开"}终端面板 (${isMac ? "⌘T" : "Ctrl+T"})`}
1064
+ data-testid="sidebar-terminal"
1065
+ >
1066
+ <LuTerminal className="h-3.5 w-3.5" />
1067
+ <span>终端</span>
1068
+ <span className="ml-auto text-[10px] text-agent-muted-foreground/70">
1069
+ {isMac ? "⌘T" : "Ctrl+T"}
1070
+ </span>
1071
+ </button>
1072
+ ) : null}
1121
1073
  {hasGeneralSettingsChrome() && (
1122
1074
  <button
1123
1075
  type="button"
@@ -10,6 +10,7 @@ import {
10
10
  import { SessionTreeModal } from './chat/SessionTreeModal';
11
11
  import { TaskPanelModal } from './chat/TaskPanelModal';
12
12
  import { actionableTasks, summarizeTasks, type ChatTaskSummary } from './chat/useChatTasks';
13
+ import type { PackChatSlotContribution } from '@/packs/registry';
13
14
 
14
15
  interface ChatHeaderProps {
15
16
  chat: LocalChat | null;
@@ -23,6 +24,12 @@ interface ChatHeaderProps {
23
24
  onInspectTask?: (task: { id: string; chatId: string; title: string }) => void;
24
25
  /** 本对话的后台任务(AgentPage 的 `useChatTasks` 单一订阅源)。 */
25
26
  tasks?: LocalTask[];
27
+ /** 包注册的聊天页槽位(如「文档预览」):入口放在本标题栏,按会话独立开关。 */
28
+ chatSlots?: readonly PackChatSlotContribution[];
29
+ /** 当前会话打开的右侧栏位(null = 都关着)。 */
30
+ rightPanel?: string | null;
31
+ /** 点击槽位入口:点已打开的关闭,点另一个直接切换。 */
32
+ onToggleChatSlot?: (slotId: string) => void;
26
33
  }
27
34
 
28
35
  /**
@@ -40,6 +47,9 @@ export function ChatHeader({
40
47
  onBranchSwitched,
41
48
  onInspectTask,
42
49
  tasks = [],
50
+ chatSlots = [],
51
+ rightPanel = null,
52
+ onToggleChatSlot,
43
53
  }: ChatHeaderProps) {
44
54
  const [branchMenuOpen, setBranchMenuOpen] = useState(false);
45
55
  const [treeModalOpen, setTreeModalOpen] = useState(false);
@@ -233,6 +243,30 @@ export function ChatHeader({
233
243
  {taskBadge && <span className="text-xs">{taskBadge.count}</span>}
234
244
  </button>
235
245
  )}
246
+ {chat &&
247
+ chatSlots.map((slot) => {
248
+ const open = rightPanel === slot.slotId;
249
+ return (
250
+ <button
251
+ key={slot.slotId}
252
+ type="button"
253
+ onClick={() => onToggleChatSlot?.(slot.slotId)}
254
+ className={`flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs font-medium transition-colors ${
255
+ open
256
+ ? 'bg-agent-foreground/10 text-agent-foreground'
257
+ : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground'
258
+ }`}
259
+ title={`${open ? '关闭' : '打开'}${slot.title}`}
260
+ aria-label={slot.title}
261
+ aria-pressed={open}
262
+ data-action={`chat-slot-${slot.slotId}`}
263
+ data-testid={`header-slot-${slot.slotId}`}
264
+ >
265
+ <slot.Icon className="h-3.5 w-3.5" />
266
+ {slot.title}
267
+ </button>
268
+ );
269
+ })}
236
270
  {chat && taskPanelOpen && (
237
271
  <TaskPanelModal
238
272
  chatId={chat.id}
@@ -21,7 +21,9 @@ import type { LlmSpeedSnapshot } from './process-status';
21
21
  import { SessionTodoList } from './SessionTodoList';
22
22
  import { resolveLatestSessionTodos } from './todo-list-model';
23
23
  import {
24
- composeAttachmentUserContent,
24
+ appendAttachmentRefs,
25
+ collectImageAttachments,
26
+ formatAttachmentFailures,
25
27
  saveChatAttachments,
26
28
  type AttachmentFile,
27
29
  } from '@/lib/attachments';
@@ -214,6 +216,7 @@ export function LocalChatPanel({
214
216
  }: LocalChatPanelProps) {
215
217
  const [inputValue, setInputValue] = useState('');
216
218
  const [files, setFiles] = useState<AttachmentFile[]>([]);
219
+ const [attachmentError, setAttachmentError] = useState<string | null>(null);
217
220
  const [mentionReferences, setMentionReferences] = useState<MentionReference[]>([]);
218
221
  const inputRef = useRef<ChatInputHandle>(null);
219
222
 
@@ -231,12 +234,32 @@ export function LocalChatPanel({
231
234
  if (!trimmed && files.length === 0) return;
232
235
 
233
236
  // 把上传的文件持久化到会话空间:成功项用落盘路径(稳定、可被 agent
234
- // 读回),失败项退回原源路径。落地页(无 chatId)由调用方创建会话后
235
- // 自行持久化,这里 chatId 为空时原样用源路径。
236
- const resolvedFiles =
237
- chatId && files.length > 0 ? await saveChatAttachments(chatId, files) : files;
238
- const assembled = composeAttachmentUserContent(trimmed, resolvedFiles);
239
- trimmed = assembled.content;
237
+ // 读回),失败项若在 Electron 下有源路径则退回源路径;浏览器模式下没有
238
+ // 任何可用路径的失败项会被剔除并上报,绝不把空路径写进正文。
239
+ // 没有文件就没有可持久化的东西,直接跳过 saveChatAttachments:它的 await
240
+ // 会让「无附件提交」多出一个微任务,打乱调用方基于 streaming 上升/下降沿的
241
+ // 判定(plan 模式操作条依赖该沿)。语义与 saveChatAttachments(chatId, [])
242
+ // 完全一致(后者原样返回空列表、无失败)。
243
+ const { files: resolvedFiles, failures } =
244
+ chatId && files.length > 0
245
+ ? await saveChatAttachments(chatId, files)
246
+ : { files, failures: [] };
247
+
248
+ if (failures.length > 0) {
249
+ // 本次消息至少保留可用的文件继续发送,但失败项必须让用户看到。
250
+ setAttachmentError(formatAttachmentFailures(failures));
251
+ } else {
252
+ setAttachmentError(null);
253
+ }
254
+
255
+ // Append file references to the user message content
256
+ const content = appendAttachmentRefs(trimmed, resolvedFiles);
257
+ if (resolvedFiles.length === 0 && !trimmed) {
258
+ // 唯一的文件全是失败项、又没有正文 —— 没有可发送的内容,保留输入框
259
+ // (含失败文件)让用户重试或移除。
260
+ return;
261
+ }
262
+ trimmed = content;
240
263
 
241
264
  const mentionedAgentIds = mentionReferences
242
265
  .filter((ref) => ref.type === 'agent')
@@ -247,13 +270,14 @@ export function LocalChatPanel({
247
270
  // W6-3: image attachments ride as metadata so the main process reads the
248
271
  // bytes into base64 ImageParts; the text path refs above stay so the
249
272
  // persisted record reflects that an image was attached.
273
+ const imageAttachments = collectImageAttachments(resolvedFiles);
250
274
  const metadata = {
251
275
  ...(mode === 'plan' ? { mode: 'plan' as const } : {}),
252
276
  ...(execPolicy === 'full' ? { execPolicy: 'full' as const } : {}),
253
277
  ...(selectedAgentId ? { agentId: selectedAgentId } : {}),
254
278
  ...(mentionedAgentIds.length > 0 ? { mentionedAgentIds } : {}),
255
279
  ...(referencedChatIds.length > 0 ? { referencedChatIds } : {}),
256
- ...(assembled.images.length > 0 ? { images: assembled.images } : {}),
280
+ ...(imageAttachments.length > 0 ? { images: imageAttachments } : {}),
257
281
  };
258
282
 
259
283
  // Snapshot what we're about to clear so a failed submit can restore it —
@@ -261,8 +285,10 @@ export function LocalChatPanel({
261
285
  // (and any attachments/mentions) whenever the submit rejected.
262
286
  const submittedFiles = files;
263
287
  const submittedMentions = mentionReferences;
288
+ // 失败项留在输入框里,用户可以直接重试 / 移除;成功项随消息发出后清掉。
289
+ const failedFiles = failures.map((f) => f.file);
264
290
  setInputValue('');
265
- setFiles([]);
291
+ setFiles(failedFiles);
266
292
  setMentionReferences([]);
267
293
  try {
268
294
  await onSubmit({
@@ -329,40 +355,55 @@ export function LocalChatPanel({
329
355
  }, [showEmptyHero]);
330
356
 
331
357
  const chatInputNode = (
332
- <ChatInput
333
- ref={inputRef}
334
- value={inputValue}
335
- onChange={setInputValue}
336
- onSubmit={handleSubmit}
337
- onCancel={onCancel}
338
- onSteer={onSteer}
339
- onFollowUp={onFollowUp}
340
- pendingFollowUps={pendingFollowUps}
341
- onRemoveFollowUp={onRemoveFollowUp}
342
- isStreaming={isStreaming}
343
- disabled={disabled}
344
- placeholder={
345
- mode === 'plan'
346
- ? '描述你的目标,Agent 将先制定计划…'
347
- : inputPlaceholder
348
- }
349
- currentAgent={currentAgent}
350
- agents={agents}
351
- chats={chats}
352
- selectedAgentId={selectedAgentId}
353
- onSelectAgent={onSelectAgent}
354
- onOpenSettings={onOpenSettings}
355
- toolbarExtras={inputToolbarExtras}
356
- leadingChrome={inputLeadingChrome}
357
- trailingChrome={sessionTodos ? <SessionTodoList todos={sessionTodos} /> : undefined}
358
- mode={mode}
359
- onModeChange={onModeChange}
360
- execPolicy={execPolicy}
361
- onExecPolicyChange={onExecPolicyChange}
362
- files={files}
363
- onFilesChange={setFiles}
364
- onMentionReferencesChange={setMentionReferences}
365
- />
358
+ <>
359
+ {attachmentError && (
360
+ <p
361
+ className="mx-3 mb-1 text-xs text-agent-destructive"
362
+ role="alert"
363
+ data-testid="attachment-error"
364
+ >
365
+ {attachmentError}
366
+ </p>
367
+ )}
368
+ <ChatInput
369
+ ref={inputRef}
370
+ value={inputValue}
371
+ onChange={setInputValue}
372
+ onSubmit={handleSubmit}
373
+ onCancel={onCancel}
374
+ onSteer={onSteer}
375
+ onFollowUp={onFollowUp}
376
+ pendingFollowUps={pendingFollowUps}
377
+ onRemoveFollowUp={onRemoveFollowUp}
378
+ isStreaming={isStreaming}
379
+ disabled={disabled}
380
+ placeholder={
381
+ mode === 'plan'
382
+ ? '描述你的目标,Agent 将先制定计划…'
383
+ : inputPlaceholder
384
+ }
385
+ currentAgent={currentAgent}
386
+ agents={agents}
387
+ chats={chats}
388
+ selectedAgentId={selectedAgentId}
389
+ onSelectAgent={onSelectAgent}
390
+ onOpenSettings={onOpenSettings}
391
+ toolbarExtras={inputToolbarExtras}
392
+ leadingChrome={inputLeadingChrome}
393
+ trailingChrome={sessionTodos ? <SessionTodoList todos={sessionTodos} /> : undefined}
394
+ mode={mode}
395
+ onModeChange={onModeChange}
396
+ execPolicy={execPolicy}
397
+ onExecPolicyChange={onExecPolicyChange}
398
+ files={files}
399
+ onFilesChange={(next) => {
400
+ setFiles(next);
401
+ // 用户手动增删文件后,上一次提交的失败提示已过期。
402
+ if (attachmentError) setAttachmentError(null);
403
+ }}
404
+ onMentionReferencesChange={setMentionReferences}
405
+ />
406
+ </>
366
407
  );
367
408
 
368
409
  return (
@@ -10,7 +10,7 @@ import { InsightsConsentBanner } from '@/components/settings/InsightsSettingsPan
10
10
  import { trackBehavior } from '@/lib/insights';
11
11
  import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
12
12
  import { deleteChatIfEmpty, pruneEmptyChats } from '@/lib/local-api';
13
- import { getPackChatSlots } from '@/packs/registry';
13
+ import { getPackChatSlots, type PackChatSlotContribution } from '@/packs/registry';
14
14
  import {
15
15
  useChatsAndAgents,
16
16
  type UseChatsAndAgentsResult,
@@ -75,6 +75,46 @@ const MAX_TERMINAL_WIDTH = 960;
75
75
  */
76
76
  export type RightPanelState = string | null;
77
77
 
78
+ /** 每个会话各自的右侧栏位状态:chatId → RightPanelState(null 不落盘)。 */
79
+ export type RightPanelMap = Record<string, string>;
80
+
81
+ /**
82
+ * 解析持久化的「会话 → 右侧栏位」映射。
83
+ *
84
+ * 兼容三种历史格式:
85
+ * - 新格式:{"<chatId>": "terminal" | "<slotId>"};
86
+ * - 旧格式:整个值就是单个字符串('' / 'terminal' / slotId)→ 迁到当前会话;
87
+ * - 更老:只记录终端是否打开过的布尔 key。
88
+ * 无效的会话 / 栏位值会被丢弃,避免脏数据把某个会话卡在打不开的面板上。
89
+ */
90
+ export function parseRightPanelMap(options: {
91
+ raw: string | null;
92
+ legacyTerminalOpen: string | null;
93
+ chatId: string | null;
94
+ isValidValue: (value: string) => boolean;
95
+ }): RightPanelMap {
96
+ const { raw, legacyTerminalOpen, chatId, isValidValue } = options;
97
+ if (raw !== null) {
98
+ if (raw === '') return {};
99
+ try {
100
+ const parsed = JSON.parse(raw) as unknown;
101
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
102
+ const map: RightPanelMap = {};
103
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
104
+ if (typeof value === 'string' && isValidValue(value)) map[key] = value;
105
+ }
106
+ return map;
107
+ }
108
+ } catch {
109
+ // 旧格式:整个 key 就是单个值。
110
+ if (isValidValue(raw) && chatId) return { [chatId]: raw };
111
+ }
112
+ return {};
113
+ }
114
+ if (legacyTerminalOpen === '1' && chatId) return { [chatId]: 'terminal' };
115
+ return {};
116
+ }
117
+
78
118
  /** 从聊天页外把一段内容作为普通用户消息发进当前会话(包槽位的 fallback 通道)。 */
79
119
  export type ChatMessageSender = (input: {
80
120
  content: string;
@@ -87,6 +127,12 @@ export type AgentOutletContext = UseChatsAndAgentsResult & {
87
127
  /** 发送普通用户消息到当前聊天;未注册时返回 false。 */
88
128
  sendChatMessage: ChatMessageSender;
89
129
  inspectTask: (task: InspectedTask) => void;
130
+ /** 包槽位(如文档预览):入口渲染在 chat 标题栏,状态按会话隔离。 */
131
+ chatSlots: readonly PackChatSlotContribution[];
132
+ /** 当前会话打开的右侧栏位(null = 都关着)。 */
133
+ rightPanel: RightPanelState;
134
+ /** 顶栏入口:点已打开的关闭,点另一个直接切换。 */
135
+ onToggleChatSlot: (slotId: string) => void;
90
136
  };
91
137
 
92
138
  function AgentLayoutContent() {
@@ -269,48 +315,81 @@ function AgentLayoutContent() {
269
315
  // null | 'terminal' | <slotId>。持久化到新 key;老版本只持久化终端打开
270
316
  // 状态,首次读取时迁移。
271
317
  const packChatSlots = getPackChatSlots();
272
- const [rightPanel, setRightPanelState] = useState<RightPanelState>(() => {
318
+
319
+ /**
320
+ * 右侧栏位状态按会话隔离:chatId → null | 'terminal' | <slotId>。
321
+ *
322
+ * 以前是单个全局值(localStorage 里存一个字符串),会话 1 打开预览会连带
323
+ * 影响会话 2。现在存成映射,每个会话独立;老的单值 key 读取时自动迁移到
324
+ * 当前会话。
325
+ */
326
+ const readRightPanelMap = useCallback((): RightPanelMap => {
327
+ const isValidValue = (value: string): boolean =>
328
+ value === 'terminal' || packChatSlots.some((s) => s.slotId === value);
273
329
  try {
274
- const saved = window.localStorage.getItem(RIGHT_PANEL_KEY);
275
- if (saved !== null) {
276
- const raw =
277
- saved === 'terminal' || packChatSlots.some((s) => s.slotId === saved)
278
- ? saved
279
- : null;
280
- return sanitizeRightPanelKind(raw);
330
+ const map = parseRightPanelMap({
331
+ raw: window.localStorage.getItem(RIGHT_PANEL_KEY),
332
+ legacyTerminalOpen: window.localStorage.getItem(TERMINAL_OPEN_KEY),
333
+ chatId: chatId ?? null,
334
+ isValidValue,
335
+ });
336
+ const sanitized: RightPanelMap = {};
337
+ for (const [id, value] of Object.entries(map)) {
338
+ const next = sanitizeRightPanelKind(value);
339
+ if (next) sanitized[id] = next;
281
340
  }
282
- // 新 key 不存在时才迁移老版本仅持久化终端的旧 key。
283
- return sanitizeRightPanelKind(
284
- window.localStorage.getItem(TERMINAL_OPEN_KEY) === '1' ? 'terminal' : null,
285
- );
341
+ return sanitized;
286
342
  } catch {
287
- return null;
343
+ return {};
288
344
  }
289
- });
345
+ }, [chatId, packChatSlots]);
290
346
 
291
- // 自动展开 / 手动切换回调是挂载时注册的,直接读 state 会拿到过期闭包,
292
- // 所以用 ref 跟踪当前栏位状态(见下方同步 effect)。
293
- const rightPanelRef = useRef(rightPanel);
347
+ const [rightPanelMap, setRightPanelMap] = useState<RightPanelMap>(() =>
348
+ readRightPanelMap(),
349
+ );
294
350
 
295
- const persistRightPanel = useCallback((next: RightPanelState) => {
351
+ // 切换会话时重新读取映射(迁移老 key / 多窗口写入)。
352
+ useEffect(() => {
353
+ setRightPanelMap(readRightPanelMap());
354
+ }, [readRightPanelMap]);
355
+
356
+ const rightPanel: RightPanelState = chatId ? rightPanelMap[chatId] ?? null : null;
357
+
358
+ const persistRightPanelMap = useCallback((map: RightPanelMap) => {
296
359
  try {
297
- window.localStorage.setItem(RIGHT_PANEL_KEY, next ?? '');
360
+ window.localStorage.setItem(RIGHT_PANEL_KEY, JSON.stringify(map));
298
361
  } catch {
299
362
  /* ignore */
300
363
  }
301
364
  }, []);
302
365
 
366
+ const setRightPanel = useCallback(
367
+ (next: RightPanelState) => {
368
+ if (!chatId) return;
369
+ setRightPanelMap((prev) => {
370
+ const map = { ...prev };
371
+ if (next === null) delete map[chatId];
372
+ else map[chatId] = next;
373
+ persistRightPanelMap(map);
374
+ return map;
375
+ });
376
+ },
377
+ [chatId, persistRightPanelMap],
378
+ );
379
+
380
+ // 自动展开 / 手动切换回调是挂载时注册的,直接读 state 会拿到过期闭包,
381
+ // 所以用 ref 跟踪当前栏位状态(见下方同步 effect)。
382
+ const rightPanelRef = useRef<RightPanelState>(rightPanel);
383
+
303
384
  useEffect(() => {
304
385
  rightPanelRef.current = rightPanel;
305
386
  }, [rightPanel]);
306
387
 
307
388
  const closeRightPanel = useCallback(() => {
308
- rightPanelRef.current = null;
309
- setRightPanelState(null);
310
- persistRightPanel(null);
311
- }, [persistRightPanel]);
389
+ setRightPanel(null);
390
+ }, [setRightPanel]);
312
391
 
313
- /** 分段控件 / 快捷键入口:点已打开的按钮关闭;点另一个按钮直接切换。
392
+ /** 顶栏 / 快捷键入口:点已打开的按钮关闭;点另一个按钮直接切换。
314
393
  打开任一面板即离开任务 dock(栏位同一时刻只呈现一个内容)。 */
315
394
  const toggleRightPanel = useCallback(
316
395
  (kind: string) => {
@@ -318,11 +397,11 @@ function AgentLayoutContent() {
318
397
  const next = sanitizeRightPanelKind(raw);
319
398
  rightPanelRef.current = next;
320
399
  if (next !== null) setInspectedTask(null);
321
- setRightPanelState(next);
322
- persistRightPanel(next);
400
+ setRightPanel(next);
323
401
  },
324
- [persistRightPanel],
402
+ [setRightPanel],
325
403
  );
404
+
326
405
  // dock 里当前显示的任务(null = 显示终端)。recentTask 在切回终端后仍
327
406
  // 保留,终端头部的「后台任务」按钮据此切回来——任务的挑选入口只有一个,
328
407
  // 就是 chat 头部的后台任务弹层。
@@ -369,10 +448,8 @@ function AgentLayoutContent() {
369
448
  const showTerminalFromTask = useCallback(() => {
370
449
  if (!hostToolChrome('terminal')) return;
371
450
  setInspectedTask(null);
372
- rightPanelRef.current = 'terminal';
373
- setRightPanelState('terminal');
374
- persistRightPanel('terminal');
375
- }, [persistRightPanel]);
451
+ setRightPanel('terminal');
452
+ }, [setRightPanel]);
376
453
 
377
454
  // 包槽位的自动展开(如文档包:后端在本轮产出新设计稿时广播包事件)。
378
455
  // 互斥规则:仅当栏位空闲(null)时 reveal 才生效;
@@ -384,8 +461,7 @@ function AgentLayoutContent() {
384
461
  reveal: () => {
385
462
  if (rightPanelRef.current !== null) return;
386
463
  rightPanelRef.current = slot.slotId;
387
- setRightPanelState(slot.slotId);
388
- persistRightPanel(slot.slotId);
464
+ setRightPanel(slot.slotId);
389
465
  },
390
466
  getCurrentChatId: () => chatId ?? null,
391
467
  });
@@ -396,7 +472,7 @@ function AgentLayoutContent() {
396
472
  };
397
473
  // packChatSlots 在 bootstrap 后稳定;chatId 经 getCurrentChatId 闭包读取。
398
474
  // eslint-disable-next-line react-hooks/exhaustive-deps
399
- }, [chatId, persistRightPanel]);
475
+ }, [chatId, setRightPanel]);
400
476
 
401
477
  // Same drag ergonomics as the sidebar handle, mirrored: the terminal's
402
478
  // right edge is pinned to the window's right padding (p-1.5 = 6px), so the
@@ -475,7 +551,6 @@ function AgentLayoutContent() {
475
551
  data={data}
476
552
  rightPanel={rightPanel}
477
553
  onToggleRightPanel={toggleRightPanel}
478
- chatSlots={packChatSlots}
479
554
  onCollapse={toggleSidebarCollapsed}
480
555
  />
481
556
  </div>
@@ -501,6 +576,9 @@ function AgentLayoutContent() {
501
576
  registerChatMessageSender,
502
577
  sendChatMessage,
503
578
  inspectTask,
579
+ chatSlots: packChatSlots,
580
+ rightPanel,
581
+ onToggleChatSlot: toggleRightPanel,
504
582
  } satisfies AgentOutletContext}
505
583
  />
506
584
  </div>
@@ -39,43 +39,43 @@ export async function fileToBase64(file: File): Promise<string> {
39
39
  return btoa(binary);
40
40
  }
41
41
 
42
- /** 正文引用:优先落盘路径,浏览器未落盘时退回文件名(避免写成空的 ``)。 */
43
- export function attachmentRefLabel(file: AttachmentFile): string {
44
- return file.path || file.name;
42
+ /** 单个附件落盘失败的原因(附带原文件,调用方可保留在选择器里让用户重试)。 */
43
+ export interface AttachmentSaveFailure {
44
+ name: string;
45
+ error: string;
46
+ /** 失败前的原始选择项;浏览器模式下 `file` 仍在,可重试。 */
47
+ file: AttachmentFile;
45
48
  }
46
49
 
47
- /**
48
- * 把附件写进用户消息:正文「关联文件」列表 + 图像 metadata。
49
- * 只收录有路径或文件名的项。
50
- */
51
- export function composeAttachmentUserContent(
52
- text: string,
53
- files: AttachmentFile[],
54
- ): { content: string; images: Array<{ path: string; name: string }> } {
55
- const usable = files.filter((file) => attachmentRefLabel(file));
56
- let content = text.trim();
57
- if (usable.length > 0) {
58
- const fileRefs = usable.map((file) => `- \`${attachmentRefLabel(file)}\``).join('\n');
59
- content = content ? `${content}\n\n---\n关联文件:\n${fileRefs}` : `关联文件:\n${fileRefs}`;
60
- }
61
- const images = usable
62
- .filter((file) => isImageFile(file.path || file.name))
63
- .map((file) => ({ path: attachmentRefLabel(file), name: file.name }));
64
- return { content, images };
50
+ export interface SaveChatAttachmentsResult {
51
+ /**
52
+ * 本条消息可用的附件:已落盘(返回落盘绝对路径)的项;Electron 下若落盘
53
+ * 失败但源路径仍在,也保留源路径。**不会**再出现浏览器模式那种
54
+ * `path: ''` 的空引用。
55
+ */
56
+ files: AttachmentFile[];
57
+ /** 无法持久化、也没有任何可用路径的文件——调用方必须让用户看到。 */
58
+ failures: AttachmentSaveFailure[];
65
59
  }
66
60
 
67
61
  /**
68
- * 把一批附件持久化到会话附件目录,返回「提交时应使用」的文件列表:
69
- * 成功项用落盘路径,失败项退回原文件(不让用户的选择因单个文件失败而丢失)。
70
- * 无 attachments 桥 / 空 chatId 时原样返回。
62
+ * 把一批附件持久化到会话附件目录,返回「提交时应使用」的文件列表 + 失败项:
63
+ * - 成功项用落盘路径(稳定、可被 agent 的 local_read_file 读回);
64
+ * - 失败项:Electron 下源路径还可用就退回源路径;浏览器模式下文件没有
65
+ * 路径,落盘失败即不可用,从 `files` 剔除并进 `failures`(避免把
66
+ * 空路径写进消息、让模型以为「收到了文件」却什么都读不到)。
67
+ *
68
+ * 无 attachments 桥 / 空 chatId 时不做持久化,原样返回
69
+ * (`failures` 为空——此时是调用方自己决定不落盘,不算失败)。
70
+ * 浏览器模式只要桥在,仍把字节读成 base64 走 HTTP 落盘。
71
71
  */
72
72
  export async function saveChatAttachments(
73
73
  chatId: string | null | undefined,
74
74
  files: AttachmentFile[],
75
- ): Promise<AttachmentFile[]> {
76
- if (!chatId || files.length === 0) return files;
75
+ ): Promise<SaveChatAttachmentsResult> {
76
+ if (!chatId || files.length === 0) return { files, failures: [] };
77
77
  const bridge = getElectronBridge();
78
- if (!bridge?.attachments?.save) return files;
78
+ if (!bridge?.attachments?.save) return { files, failures: [] };
79
79
  try {
80
80
  // 有真实路径走 path 拷贝(Electron);没有则读字节走 data 上传(BS/browser)。
81
81
  const payloadFiles = await Promise.all(
@@ -88,15 +88,65 @@ export async function saveChatAttachments(
88
88
  const result = await bridge.attachments.save({ chatId, files: payloadFiles });
89
89
  const stored = Array.isArray(result?.files) ? result.files : [];
90
90
  // 服务端按输入顺序返回逐文件结果,这里按下标对齐回填。
91
- return files.map((file, index) => {
91
+ const kept: AttachmentFile[] = [];
92
+ const failures: AttachmentSaveFailure[] = [];
93
+ files.forEach((file, index) => {
92
94
  const entry = stored[index];
93
95
  if (entry && entry.path && !entry.error) {
94
- return { name: entry.name || file.name, path: entry.path };
96
+ kept.push({ name: entry.name || file.name, path: entry.path });
97
+ return;
98
+ }
99
+ if (file.path) {
100
+ // Electron 源路径仍可用,退回源路径(与旧行为一致)。
101
+ kept.push(file);
102
+ return;
95
103
  }
96
- return file;
104
+ failures.push({ name: file.name, error: entry?.error || '上传失败', file });
97
105
  });
106
+ return { files: kept, failures };
98
107
  } catch (err) {
108
+ const message = err instanceof Error ? err.message : String(err);
99
109
  console.warn('[attachments] save failed, falling back to source paths', err);
100
- return files;
110
+ const kept = files.filter((f) => Boolean(f.path));
111
+ const failures = files
112
+ .filter((f) => !f.path)
113
+ .map((f) => ({ name: f.name, error: message, file: f }));
114
+ return { files: kept, failures };
101
115
  }
102
116
  }
117
+
118
+ /** 把失败项拼成一条给用户看的中文提示(供各提交入口复用)。 */
119
+ export function formatAttachmentFailures(failures: AttachmentSaveFailure[]): string {
120
+ if (failures.length === 0) return '';
121
+ const detail = failures.map((f) => `${f.name}(${f.error})`).join(';');
122
+ return failures.length === 1
123
+ ? `文件「${failures[0].name}」未能上传,本条消息未包含它:${failures[0].error}`
124
+ : `${failures.length} 个文件未能上传,本条消息未包含它们:${detail}`;
125
+ }
126
+
127
+ /**
128
+ * 把附件引用段落拼进用户消息正文。**所有文件**(docx / pdf / xlsx / 任意
129
+ * 二进制,不只是图片)都写落盘绝对路径,agent 用 `local_read_file` 读回;
130
+ * 图片的多模态通道是**额外**的,见 {@link collectImageAttachments}。
131
+ * 两个提交入口(会话内 / 落地页)共用本函数,避免装配规则再次分叉。
132
+ */
133
+ export function appendAttachmentRefs(rawText: string, files: AttachmentFile[]): string {
134
+ if (files.length === 0) return rawText;
135
+ const refs = files.map((f) => `- \`${f.path}\``).join('\n');
136
+ return rawText
137
+ ? `${rawText}\n\n---\n关联文件:\n${refs}`
138
+ : `关联文件:\n${refs}`;
139
+ }
140
+
141
+ /**
142
+ * 图片附件的多模态元数据(`metadata.images`)。非图片文件不进这里——它们
143
+ * 已经通过 {@link appendAttachmentRefs} 的路径引用交给 agent 读取,不会被
144
+ * 图片逻辑过滤掉。
145
+ */
146
+ export function collectImageAttachments(
147
+ files: AttachmentFile[],
148
+ ): Array<{ path: string; name: string }> {
149
+ return files
150
+ .filter((f) => isImageFile(f.path))
151
+ .map((f) => ({ path: f.path, name: f.name }));
152
+ }
@@ -61,11 +61,14 @@ import {
61
61
  setPendingFirstMessage,
62
62
  takePendingFirstMessage,
63
63
  } from "@/lib/pending-first-message";
64
+ import { BRAND_NAME, pickDefaultAgentId } from "@/brand";
64
65
  import {
65
- composeAttachmentUserContent,
66
+ appendAttachmentRefs,
67
+ collectImageAttachments,
68
+ formatAttachmentFailures,
66
69
  saveChatAttachments,
70
+ type AttachmentFile,
67
71
  } from "@/lib/attachments";
68
- import { BRAND_NAME, pickDefaultAgentId } from "@/brand";
69
72
 
70
73
  /**
71
74
  * `GET /api/v2/chats/:id/messages` returns rows in `createdAt DESC` (latest
@@ -672,6 +675,15 @@ function AgentChatView({
672
675
  // 是 fire-and-forget 的,所以这边不需要也不应该处理 chat_title_updated。
673
676
  }, []);
674
677
 
678
+ // 前端流式连接断开(浏览器/网络层)时,后端回合可能仍在运行。不要停在
679
+ // 「请求失败:network error」;立即 remount 做一次对账:
680
+ // - 后端仍 active → 新 mount 从 /live-stream 快照继续展示;
681
+ // - 后端已结束 → 新 mount 从 /messages 拉最终落库消息。
682
+ // 这样就不需要用户手动刷新页面。
683
+ const handleStreamError = useCallback(() => {
684
+ onBranchTick();
685
+ }, [onBranchTick]);
686
+
675
687
  const {
676
688
  messages,
677
689
  isStreaming,
@@ -687,6 +699,7 @@ function AgentChatView({
687
699
  transport,
688
700
  initialMessages,
689
701
  onUnknownEvent: handleUnknownEvent,
702
+ onStreamError: handleStreamError,
690
703
  });
691
704
 
692
705
  // ── 切回恢复:远端回合仍在跑,但本 mount 不是发起者 ──────────────────
@@ -1065,6 +1078,7 @@ function AgentChatView({
1065
1078
  </div>
1066
1079
  )}
1067
1080
  <LocalChatPanel
1081
+ chatId={chatId}
1068
1082
  messages={effectiveMessages}
1069
1083
  isStreaming={effectiveIsStreaming}
1070
1084
  onSubmit={handleSubmit}
@@ -1074,7 +1088,6 @@ function AgentChatView({
1074
1088
  pendingFollowUps={pendingFollowUps.map((m) => m.content)}
1075
1089
  onRemoveFollowUp={removeFollowUp}
1076
1090
  className="flex-1"
1077
- chatId={chatId}
1078
1091
  emptyHero={{
1079
1092
  title: BRAND_NAME,
1080
1093
  subtitle: '输入消息,直接开始一段新对话。',
@@ -1085,6 +1098,9 @@ function AgentChatView({
1085
1098
  onBranchSwitched={isElectron() ? onBranchTick : undefined}
1086
1099
  onInspectTask={ctx.inspectTask}
1087
1100
  tasks={tasks}
1101
+ chatSlots={ctx.chatSlots}
1102
+ rightPanel={ctx.rightPanel}
1103
+ onToggleChatSlot={ctx.onToggleChatSlot}
1088
1104
  />
1089
1105
  }
1090
1106
  onRegenerate={
@@ -1225,7 +1241,7 @@ function EmptyChatGate() {
1225
1241
  const [searchParams] = useSearchParams();
1226
1242
  const projectIdFromUrl = searchParams.get("projectId");
1227
1243
  const [inputValue, setInputValue] = useState("");
1228
- const [files, setFiles] = useState<{ name: string; path: string }[]>([]);
1244
+ const [files, setFiles] = useState<AttachmentFile[]>([]);
1229
1245
  const [mentionReferences, setMentionReferences] = useState<
1230
1246
  MentionReference[]
1231
1247
  >([]);
@@ -1293,14 +1309,14 @@ function EmptyChatGate() {
1293
1309
 
1294
1310
  const handleSubmit = useCallback(async () => {
1295
1311
  if (isCreating) return;
1296
- let trimmed = inputValue.trim();
1297
- if (!trimmed && files.length === 0) return;
1312
+ const rawText = inputValue.trim();
1313
+ if (!rawText && files.length === 0) return;
1298
1314
  trackBehavior('composer_send', {
1299
1315
  empty: false,
1300
1316
  home: true,
1301
1317
  mode: mode === 'plan' ? 'plan' : 'agent',
1302
1318
  execPolicy,
1303
- length: trimmed.length,
1319
+ length: rawText.length,
1304
1320
  });
1305
1321
 
1306
1322
  const mentionedAgentIds = mentionReferences
@@ -1309,6 +1325,18 @@ function EmptyChatGate() {
1309
1325
  const referencedChatIds = mentionReferences
1310
1326
  .filter((ref) => ref.type === "chat")
1311
1327
  .map((ref) => ref.id);
1328
+ // 文件路径引用与图像元数据在会话建好、附件落盘之后再装配(见下)——
1329
+ // 落地页提交时还没有 chatId,不能像旧代码那样直接用 `f.path`(浏览器
1330
+ // 模式下它是空串,会把一条空引用写进正文,模型以为收到了文件却读不到)。
1331
+ const baseMetadata = {
1332
+ ...(mode === "plan" ? { mode: "plan" as const } : {}),
1333
+ ...(execPolicy === "full" ? { execPolicy: "full" as const } : {}),
1334
+ ...(ctx.selectedAgentId ? { agentId: ctx.selectedAgentId } : {}),
1335
+ ...(mentionedAgentIds.length > 0 ? { mentionedAgentIds } : {}),
1336
+ ...(referencedChatIds.length > 0 ? { referencedChatIds } : {}),
1337
+ ...(modelOverride ? { model: modelOverride } : {}),
1338
+ ...(effortOverride ? { reasoningEffort: effortOverride } : {}),
1339
+ };
1312
1340
 
1313
1341
  setIsCreating(true);
1314
1342
  setCreateError(null);
@@ -1318,23 +1346,29 @@ function EmptyChatGate() {
1318
1346
  ...(ctx.selectedAgentId ? { agentId: ctx.selectedAgentId } : {}),
1319
1347
  });
1320
1348
  if (!id) throw new Error("创建对话失败,请重试");
1321
- // 落地页提交时还没有 chatId;先建会话再落盘,正文用落盘路径。
1322
- const resolvedFiles =
1323
- files.length > 0 ? await saveChatAttachments(id, files) : files;
1324
- const assembled = composeAttachmentUserContent(trimmed, resolvedFiles);
1349
+ // 会话已建,把附件落进会话空间(与 LocalChatPanel 同一套语义):
1350
+ // 所有文件都写落盘路径引用(agent 用 local_read_file 读回);仅图片
1351
+ // 额外进 metadata.images 走多模态,非图片文件不会被图片逻辑吞掉。
1352
+ const { files: resolvedFiles, failures } = await saveChatAttachments(id, files);
1353
+ if (failures.length > 0) {
1354
+ // 落地页一旦跳转就没法让用户重试了,所以这里宁可中止本次发送:
1355
+ // 删掉刚建的空会话,保留输入框(含失败文件),把原因显示出来。
1356
+ await ctx.deleteChat(id);
1357
+ setCreateError(formatAttachmentFailures(failures));
1358
+ setIsCreating(false);
1359
+ return;
1360
+ }
1361
+
1362
+ const content = appendAttachmentRefs(rawText, resolvedFiles);
1363
+ const imageAttachments = collectImageAttachments(resolvedFiles);
1325
1364
  const metadata = {
1326
- ...(mode === "plan" ? { mode: "plan" as const } : {}),
1327
- ...(execPolicy === "full" ? { execPolicy: "full" as const } : {}),
1328
- ...(ctx.selectedAgentId ? { agentId: ctx.selectedAgentId } : {}),
1329
- ...(mentionedAgentIds.length > 0 ? { mentionedAgentIds } : {}),
1330
- ...(referencedChatIds.length > 0 ? { referencedChatIds } : {}),
1331
- ...(modelOverride ? { model: modelOverride } : {}),
1332
- ...(effortOverride ? { reasoningEffort: effortOverride } : {}),
1333
- ...(assembled.images.length > 0 ? { images: assembled.images } : {}),
1365
+ ...baseMetadata,
1366
+ ...(imageAttachments.length > 0 ? { images: imageAttachments } : {}),
1334
1367
  };
1368
+
1335
1369
  setPendingFirstMessage({
1336
1370
  chatId: id,
1337
- content: assembled.content,
1371
+ content,
1338
1372
  metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
1339
1373
  });
1340
1374
  navigate(`/agent/${id}`);