@spzxg/cli-ui 0.6.5 → 0.6.7

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +109 -30
  3. package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
  4. package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-BmwOLWFI.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
  7. package/dist/{cli-ui-xlsx-zs7oEtPj.js → cli-ui-xlsx-Sl7ypvSe.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +11 -8
  11. package/src/board/BrowserPane.tsx +198 -53
  12. package/src/board/CliBoard.tsx +128 -100
  13. package/src/board/FileViewer.tsx +276 -136
  14. package/src/board/FilesPane.tsx +129 -40
  15. package/src/board/HtmlReadingPreview.tsx +52 -0
  16. package/src/board/MarkdownPreview.tsx +134 -0
  17. package/src/board/PanelControls.tsx +19 -7
  18. package/src/board/fileKind.ts +138 -0
  19. package/src/board/fileOps.ts +9 -2
  20. package/src/board/fileScope.ts +29 -0
  21. package/src/board/fileSync.ts +343 -0
  22. package/src/board/fileSyncCache.ts +250 -0
  23. package/src/board/fileSyncProcessor.ts +71 -0
  24. package/src/board/fileSyncQueue.ts +56 -0
  25. package/src/board/fileSyncTransport.ts +85 -0
  26. package/src/board/fileWatch.ts +177 -70
  27. package/src/board/folderDownload.ts +39 -5
  28. package/src/board/frameQuote.ts +12 -10
  29. package/src/board/htmlReading.ts +148 -0
  30. package/src/board/textLoad.ts +18 -41
  31. package/src/board/useBoardUpload.tsx +51 -0
  32. package/src/board/useBoardVisibility.ts +35 -0
  33. package/src/board/workspace.ts +53 -11
  34. package/src/chat/CliChat.tsx +105 -55
  35. package/src/chat/Composer.tsx +55 -21
  36. package/src/chat/GroupChat.tsx +113 -0
  37. package/src/chat/RunControls.tsx +132 -42
  38. package/src/chat/draft.ts +5 -2
  39. package/src/core/blocks.ts +17 -5
  40. package/src/core/client.ts +88 -19
  41. package/src/core/conversation.ts +1 -0
  42. package/src/core/documentUpload.ts +196 -0
  43. package/src/core/provider.tsx +7 -0
  44. package/src/core/store.ts +134 -36
  45. package/src/core/types.ts +31 -10
  46. package/src/index.ts +3 -0
  47. package/src/layout/split.ts +67 -30
  48. package/src/msglist/CliMsgList.tsx +18 -5
  49. package/src/operator/node/Chat.tsx +7 -8
  50. package/src/operator/node/Composer.tsx +99 -72
  51. package/src/operator/node/Orchestrator.tsx +1 -1
  52. package/src/operator/node/SessionRail.tsx +71 -32
  53. package/src/operator/node/SessionView.tsx +25 -25
  54. package/src/operator/node/Transcript.tsx +28 -11
  55. package/src/operator/node/boardBackend.ts +6 -23
  56. package/src/operator/node/boardEvents.ts +21 -3
  57. package/src/operator/node/boardState.ts +18 -1
  58. package/src/operator/node/boardWorkspace.ts +4 -1
  59. package/src/operator/node/composerDraft.ts +6 -2
  60. package/src/operator/node/format.ts +4 -2
  61. package/src/operator/node/liveStream.ts +14 -2
  62. package/src/operator/node/protocol/types.ts +2 -2
  63. package/src/operator/node/useTaskStream.ts +8 -5
  64. package/src/operator.base.css +148 -37
  65. package/src/operator.css +148 -37
  66. package/src/styles.css +274 -32
  67. package/src/ui/Markdown.tsx +2 -2
  68. package/src/ui/Popover.tsx +32 -14
  69. package/src/ui/QueuedMessages.tsx +69 -0
  70. package/src/ui/ReferenceCard.tsx +28 -0
  71. package/src/ui/TextBlock.tsx +6 -4
  72. package/src/ui/Transcript.tsx +27 -12
  73. package/src/ui/UploadProgress.tsx +46 -0
  74. package/src/ui/WorkspaceDrawer.tsx +64 -0
  75. package/src/ui/WorkspaceTabs.tsx +60 -0
  76. package/src/ui/format.ts +3 -1
  77. package/src/ui/reference.ts +40 -0
  78. package/src/ui/useChatScrollbar.ts +29 -0
  79. package/src/ui/useComposerAutosize.ts +54 -0
  80. package/dist/cli-ui-embed-BmLN2Jjg.js +0 -43290
@@ -1,7 +1,7 @@
1
1
  // cli-msglist:消息对话列表。界面取自 Operator 的任务栏(TasksColumn 的列表视图,不做看板),
2
2
  // 一张卡片是 center 里的一个对话任务(mode=conversation)。对话任务里开了几个对话时,卡片下面按对话展开,
3
3
  // 点哪个对话选哪个(选中值是对话键 "job#task");卡片上的「+」在这个对话任务里新开一个对话(同一个工作区)。
4
- import { useEffect, useMemo, useState, type CSSProperties } from "react";
4
+ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
5
5
  import { CliErrorBoundary } from "../ui/ErrorBoundary";
6
6
  import type { CliEvent, JobEntry } from "../core/store";
7
7
  import type { CliStoreOptions, CliStore } from "../core/store";
@@ -12,6 +12,7 @@ import { Icon } from "../ui/icons";
12
12
  import { agentLabel, relTime, toMs } from "../ui/format";
13
13
  import { AgentBadge, ErrNote, Skel, StatusDot, type DotCls } from "../ui/shared";
14
14
  import { resolveMsgListConfig, type CliMsgListConfig, type ResolvedMsgListConfig } from "../core/config";
15
+ import { useBoardVisibility } from "../board/useBoardVisibility";
15
16
 
16
17
  /** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
17
18
  export interface CliMsgListProps extends CliStoreOptions, ResultHandlers {
@@ -59,6 +60,9 @@ export function CliMsgList(props: CliMsgListProps) {
59
60
 
60
61
  const SEARCH_MIN = 6;
61
62
  const BATCH_ACTIVE = new Set(["dispatching", "running", "verifying"]);
63
+ // An orphaned task can retain a fresh, authoritative human-wait observation
64
+ // while the center is reconciling its execution state. Do not call it running.
65
+ const BATCH_AWAITING = new Set([...BATCH_ACTIVE, "orphaned"]);
62
66
 
63
67
  interface CardView {
64
68
  id: string;
@@ -95,9 +99,11 @@ export function cardView(id: string, entry: JobEntry | undefined): CardView {
95
99
  const label = cancelled ? "已取消" : failed ? "已失败" : "已完成";
96
100
  return { ...base, cls: cancelled || failed ? "x" : "g", label, running: false, awaiting: false, group: cancelled ? "cancelled" : "done", activity: updatedAt ? `${label} · ${relTime(updatedAt)}` : label };
97
101
  }
98
- // 一次性任务:有子任务在节点上跑就是运行中,没有轮次、不会停在提问上。
102
+ // A browser handoff/input card keeps its turn running while requiring a human.
99
103
  if (batch) {
100
104
  const running = (job?.tasks ?? []).some((task) => BATCH_ACTIVE.has(task.status));
105
+ const awaiting = (job?.tasks ?? []).some((task) => BATCH_AWAITING.has(task.status) && task.awaiting_input === true);
106
+ if (awaiting) return { ...base, cls: "a", label: "需要你处理", running, awaiting, group: "needs", activity: "等你处理" };
101
107
  return { ...base, cls: "a", label: "进行中", running, awaiting: false, group: "active", activity: running ? "运行中 · 工作中" : status === "queued" ? "等待执行节点" : "进行中" };
102
108
  }
103
109
  // 开了几个对话:任何一个在跑就算在跑,任何一个停在提问上就要你处理(关掉的对话不算)。
@@ -105,7 +111,7 @@ export function cardView(id: string, entry: JobEntry | undefined): CardView {
105
111
  const many = (conv?.conversations?.length ?? 0) > 1;
106
112
  const running = many ? open.some((c) => c.running_turn) : !!conv?.running_turn;
107
113
  // 上一轮以提问结束、后面没有新消息:等你回答。
108
- const awaiting = many ? open.some((c) => !c.running_turn && !c.queued_turns && !!c.last_turn?.asked) : !running && !conv?.queued_turns && !!conv?.last_turn?.asked;
114
+ const awaiting = many ? open.some((c) => c.awaiting_input === true || (!c.running_turn && !c.queued_turns && !!c.last_turn?.asked)) : conv?.awaiting_input === true || (!running && !conv?.queued_turns && !!conv?.last_turn?.asked);
109
115
  if (awaiting) return { ...base, cls: "a", label: "需要你处理", running, awaiting, group: "needs", activity: updatedAt ? `等你处理 · ${relTime(updatedAt)}` : "等你处理" };
110
116
  if (running) return { ...base, cls: "a", label: "进行中", running, awaiting, group: "active", activity: "运行中 · 工作中" };
111
117
  const waiting = conv?.queued_turns ? `${conv.queued_turns} 条排队中` : status === "queued" ? "等待执行节点" : updatedAt ? relTime(updatedAt) : "未开始";
@@ -143,6 +149,7 @@ export function convRows(jobId: string, entry: JobEntry | undefined): ConvRowVie
143
149
  if (tasks.length <= 1) return [];
144
150
  return tasks.map((t) => {
145
151
  const look = BATCH_ROW[t.status] ?? { cls: "a" as DotCls, label: t.status };
152
+ if (BATCH_AWAITING.has(t.status) && t.awaiting_input === true) return { key: convKey(jobId, t.id), title: t.title, cls: "a" as DotCls, label: "需要你处理", running: !!look.running, awaiting: true, activity: "等你处理", turns: 0 };
146
153
  return { key: convKey(jobId, t.id), title: t.title, cls: look.cls, label: look.label, running: !!look.running, awaiting: false, activity: look.label, turns: 0 };
147
154
  });
148
155
  }
@@ -153,7 +160,7 @@ export function convRows(jobId: string, entry: JobEntry | undefined): ConvRowVie
153
160
  const base = { key: convKey(jobId, c.task_id), title: c.title, turns: c.turns };
154
161
  if (c.closed || jobEnded) return { ...base, cls: "g" as DotCls, label: "已结束", running: false, awaiting: false, activity: "已结束" };
155
162
  const running = c.running_turn;
156
- const awaiting = !running && !c.queued_turns && !!c.last_turn?.asked;
163
+ const awaiting = c.awaiting_input === true || (!running && !c.queued_turns && !!c.last_turn?.asked);
157
164
  if (awaiting) return { ...base, cls: "a" as DotCls, label: "需要你处理", running, awaiting, activity: "等你处理" };
158
165
  if (running) return { ...base, cls: "a" as DotCls, label: "进行中", running, awaiting, activity: "运行中" };
159
166
  return { ...base, cls: "a" as DotCls, label: "进行中", running, awaiting, activity: c.queued_turns ? `${c.queued_turns} 条排队中` : "空闲" };
@@ -278,6 +285,12 @@ function MsgListInner({ jobIds, selectedId, onSelect, onNew, title = "对话", c
278
285
  const cfg = useMemo(() => resolveMsgListConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
279
286
  const h = cfg.header;
280
287
  const store = useCliStore();
288
+ const rootRef = useRef<HTMLDivElement>(null);
289
+ const owner = useId();
290
+ const visible = useBoardVisibility(rootRef, true);
291
+ const expanded = visible && !className?.split(/\s+/).includes("collapsed");
292
+ useLayoutEffect(() => { store.reportListPresence(owner, expanded); }, [store, owner, expanded]);
293
+ useLayoutEffect(() => () => { store.reportListPresence(owner, false); }, [store, owner]);
281
294
  const ids = useCliState((s) => s.jobIds);
282
295
  const jobs = useCliState((s) => s.jobs);
283
296
  const shared = useCliState((s) => s.selected);
@@ -337,7 +350,7 @@ function MsgListInner({ jobIds, selectedId, onSelect, onNew, title = "对话", c
337
350
  const showBanner = showRow || showContext;
338
351
 
339
352
  return (
340
- <div className={`cli-ui cli-msglist ${className ?? ""}`} data-theme={theme} style={{ ...style, ...(width !== undefined ? { width } : null) }}>
353
+ <div ref={rootRef} className={`cli-ui cli-msglist ${className ?? ""}`} data-theme={theme} style={{ ...style, ...(width !== undefined ? { width } : null) }}>
341
354
  <div className="col col-tasks">
342
355
  {showBanner && <div className="proj-banner">
343
356
  {showRow && <div className="pb-row">
@@ -12,6 +12,8 @@ import { OperatorBoardContext } from "./boardInteraction";
12
12
  import { toolDataOf } from "./transcriptModel";
13
13
  import { FileLinkContext } from "./fileLinks";
14
14
  import { Composer } from "./Composer";
15
+ import { QueuedMessages } from "../../ui/QueuedMessages";
16
+ import { useChatScrollbar } from "../../ui/useChatScrollbar";
15
17
  import { BranchHistory, useBranchHistory } from "./BranchHistory";
16
18
  import { BranchNavigator } from "./BranchNavigator";
17
19
  import type { Quote } from "./composerDraft";
@@ -76,6 +78,7 @@ export function Chat(props: ChatProps) { return <ChatSession key={props.task.id}
76
78
  function ChatSession({ project, task, tasks, agents, messages, running, transcriptLoading, older=0, onLoadOlder,
77
79
  controls, configBusy, active=true, onSelectTask, onFork, onSend, onStop, onClear, onReconnect, onAnswer, onCancelQueued }: ChatProps) {
78
80
  const scrollRef = useRef<HTMLDivElement>(null);
81
+ useChatScrollbar(scrollRef, task.id);
79
82
  const [quoteRequest, setQuoteRequest] = useState<Quote|null>(null);
80
83
  const flow=useBranchHistory(task,tasks);
81
84
  const fileScope=useMemo(()=>({taskId:task.id}),[task.id]);
@@ -83,7 +86,7 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
83
86
  const view=active?'chat':'changes';
84
87
  const quote=(m: Msg, source: TaskRow=task, selected?:string)=>{
85
88
  const text=selected||splitAttachments(m.content).text;
86
- if(text.trim()) setQuoteRequest({id:m.id, author:`${m.role==='user'?'你':agentLabel(agents,source.agent)}${source.id===task.id?'':` · ${source.title}`}`,text});
89
+ if(text.trim()) setQuoteRequest({id:m.id, author:`${m.role==='user'?'你':agentLabel(agents,source.agent)}${source.id===task.id?'':` · ${source.title}`}`,text,sourceTaskId:source.id});
87
90
  };
88
91
  const openBoard=useContext(OperatorBoardContext);
89
92
  const handoffs=useRef<Set<string>|null>(null);
@@ -414,7 +417,7 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
414
417
  // window sheds rows above. Layout effect, so it lands before paint (no jump).
415
418
  useLayoutEffect(() => {
416
419
  if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
417
- }, [messages, running, startIdx, endIdx]);
420
+ }, [messages, running, startIdx, endIdx, win]);
418
421
  // …and when the pane itself resizes (the composer growing, a banner
419
422
  // appearing). Content resizes are left alone, so expanding a tool card at
420
423
  // the bottom doesn't yank its top out of view.
@@ -488,11 +491,6 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
488
491
  {running && !awaitingAnswer && !hasBelow && (
489
492
  <RunningIndicator agent={task.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />
490
493
  )}
491
- {/* Follow-ups queued mid-turn, pinned below the live turn — they
492
- send in order once it ends. */}
493
- {!hasBelow && messages.filter((m) => m.role === "queued").map((m) => (
494
- <MessageView key={m.id} m={m} initial={false} hideWho={false} onAnswer={stableAnswer} onCancelQueued={stableCancelQueued} />
495
- ))}
496
494
  {!hasBelow && <BranchNavigator sourceId={task.id} projectId={project.id} tasks={tasks} completed={task.status==='done'} onSelect={onSelectTask} />}
497
495
  </div>
498
496
  </div>
@@ -510,7 +508,8 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
510
508
  )}
511
509
  </div>
512
510
  </div>
513
- <Composer tasks={tasks} onFork={onFork} task={task} agentLabel={agentLabel(agents, task.agent)} disabled={task.started!==1} configBusy={configBusy} running={running} onSend={onSend} onStop={onStop} onClear={onClear} quoteRequest={quoteRequest} onQuoteHandled={()=>setQuoteRequest(null)} controls={controls} />
511
+ <Composer tasks={tasks} onFork={onFork} task={task} agentLabel={agentLabel(agents, task.agent)} disabled={task.started!==1} configBusy={configBusy} running={running} onSend={async text => { const sent = await onSend(text); if (sent && !running) scrollToBottom("auto"); return sent; }} onStop={onStop} onClear={onClear} quoteRequest={quoteRequest} onQuoteHandled={()=>setQuoteRequest(null)} controls={controls}
512
+ queued={<QueuedMessages scopeKey={task.id} messages={messages.filter(message => message.role === "queued")} onCancel={message => stableCancelQueued(message.id)} attachmentUrl={attachment => `/api/tasks/${encodeURIComponent(attachment.taskId)}/uploads/${encodeURIComponent(attachment.name)}`} renderMessage={message => <MessageView m={message} initial={false} hideWho readOnly />} />} />
514
513
  </></FileLinkContext.Provider>
515
514
  );
516
515
 
@@ -1,26 +1,34 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
+ import { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
4
+ import { DOCUMENT_MAX_BYTES, FILE_UPLOAD_ACCEPT, isOriginalDocument, uploadFileType, UPLOAD_OTHER_MAX_BYTES } from "../../core/documentUpload";
4
5
  import { Popover } from "./shared";
5
6
  import type { TaskRow } from "./types";
6
7
  import { Icon } from "./icons";
7
8
  import { attachmentMarker, fileAttachmentMarker } from "./format";
8
9
  import { MAX_MESSAGE_CHARS, PASTE_ATTACH_THRESHOLD } from "./protocol/promptLimits";
9
10
  import { RunControls, type RunControlProps } from "./RunControls";
10
- import { appendBoardContext, formatBoardContext } from "../../board/context";
11
+ import { appendBoardContext } from "../../board/context";
11
12
  import { readBoardState, useBoardState } from "./boardState";
12
13
  import { clientId } from "../../core/id";
14
+ import { useComposerAutosize } from "../../ui/useComposerAutosize";
15
+ import { ReferenceCard } from "../../ui/ReferenceCard";
16
+ import { UploadProgress } from "../../ui/UploadProgress";
17
+ import { Markdown } from "./Markdown";
18
+ import { FileLinkContext } from "./fileLinks";
13
19
  import { isConfigurationBusy, useConfigurationBusy } from "../../chat/configuration";
14
20
  import { acknowledgeDraft, DRAFT_EVENT, isTaskSending, setTaskSending, readDraft, writeDraft, quoteText, type ComposerDraft, type Quote } from "./composerDraft";
21
+ import { getOperatorBoardBackend } from "./boardBackend";
22
+ import { OperatorBoardContext, openOperatorBoard } from "./boardInteraction";
15
23
 
16
- export function Composer({ task, agentLabel, disabled, running, onSend, onStop, onClear, quoteRequest, onQuoteHandled, controls, configBusy, tasks, onFork }: {
24
+ export function Composer({ task, agentLabel, disabled, running, onSend, onStop, onClear, quoteRequest, onQuoteHandled, controls, configBusy, tasks, onFork, queued }: {
17
25
  task: TaskRow; agentLabel: string; disabled: boolean; running: boolean;
18
26
  tasks: TaskRow[]; onFork: (text: string, sourceIds: string[], requestId: string) => Promise<boolean>;
19
27
  onSend: (text: string) => Promise<boolean>; onStop: () => Promise<boolean>; onClear: () => void;
20
28
  configBusy: boolean; quoteRequest: Quote | null; onQuoteHandled: () => void; controls: RunControlProps;
29
+ queued?: ReactNode;
21
30
  }) {
22
31
  const boardView=useBoardState(task.id);
23
- const [includeBoard,setIncludeBoard]=useState(true);
24
32
  const [draft, setDraft] = useState(() => readDraft(task.id));
25
33
  const draftRef = useRef(draft);
26
34
  const [addOpen, setAddOpen] = useState(false);
@@ -40,6 +48,10 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
40
48
  const caretRequest = useRef<number | null>(null);
41
49
  const composing = useRef(false);
42
50
  const uploads = useRef(new Map<string, AbortController>());
51
+ const uploadFiles = useRef(new Map<string, File>());
52
+ const backend = useMemo(() => getOperatorBoardBackend(), []);
53
+ const parentBoard = useContext(OperatorBoardContext);
54
+ const uploadScope = useRef({ taskId: task.id, openBoard: parentBoard ?? openOperatorBoard });
43
55
  const previews = useRef(new Set<string>());
44
56
  const mounted = useRef(true);
45
57
  const update = (fn: (prev: ComposerDraft) => ComposerDraft) => {
@@ -48,9 +60,12 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
48
60
  writeDraft(task.id, next);
49
61
  if (mounted.current) setDraft(next);
50
62
  };
51
- useEffect(() => {
63
+ useLayoutEffect(() => { uploadScope.current = { taskId: task.id, openBoard: parentBoard ?? openOperatorBoard }; }, [task.id, parentBoard]);
64
+ useLayoutEffect(() => {
52
65
  mounted.current = true;
53
- const pending = uploads.current, urls = previews.current;
66
+ const current = readDraft(task.id);
67
+ draftRef.current = current; setDraft(current);
68
+ const pending = uploads.current, urls = previews.current, files = uploadFiles.current;
54
69
  const sync = (event: Event) => {
55
70
  const change = (event as CustomEvent<{ id: string; draft?: ComposerDraft; sending?: boolean }>).detail;
56
71
  if (change.id !== task.id) return;
@@ -69,28 +84,25 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
69
84
  const current = readDraft(task.id);
70
85
  writeDraft(task.id, { ...current, attachments: current.attachments.map((attachment) =>
71
86
  interrupted.has(attachment.key) && attachment.status === "uploading"
72
- ? { ...attachment, preview: "", status: "error", error: "上传已中断,请移除后重新添加。" }
87
+ ? { ...attachment, preview: "", status: "error", error: "上传已中断,请重新选择文件。" }
73
88
  : attachment) });
74
89
  }
75
90
  pending.clear();
91
+ files.clear();
76
92
  urls.forEach((url) => URL.revokeObjectURL(url));
77
93
  urls.clear();
78
94
  };
79
- }, []);
95
+ }, [task.id]);
80
96
  useEffect(() => { if (!running) { setStopping(false); stoppingRef.current = false; } }, [running]);
81
97
  useEffect(() => {
82
98
  if (!quoteRequest) return;
83
99
  update((d) => ({ ...d, quote: quoteRequest }));
84
- ref.current?.focus();
100
+ ref.current?.focus({ preventScroll: true });
85
101
  onQuoteHandled();
86
102
  // Each request is a new object even when quoting the same message again.
87
103
  // eslint-disable-next-line react-hooks/exhaustive-deps
88
104
  }, [quoteRequest]);
89
- useEffect(() => {
90
- if (!ref.current) return;
91
- ref.current.style.height = "auto";
92
- ref.current.style.height = Math.min(ref.current.scrollHeight, 200) + "px";
93
- }, [draft.text]);
105
+ useComposerAutosize(ref, draft.text);
94
106
  // A menu action hands keyboard control straight to the edited text. Deferring
95
107
  // focus to rAF leaves a visible mention list whose first Escape/arrow is lost.
96
108
  useLayoutEffect(() => {
@@ -103,38 +115,56 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
103
115
 
104
116
  const sharedConfigBusy = useConfigurationBusy(task.id);
105
117
  const locked = disabled || sending || configBusy || sharedConfigBusy;
118
+ const startUpload = (key: string, file: File) => {
119
+ if (!backend.uploadFile || locked || uploads.current.has(key)) return;
120
+ const taskId = task.id;
121
+ const preview = draftRef.current.attachments.find(a => a.key === key)?.preview;
122
+ const controller = new AbortController();
123
+ uploads.current.set(key, controller);
124
+ uploadFiles.current.set(key, file);
125
+ const current = () => mounted.current && uploadScope.current.taskId === taskId && uploads.current.get(key) === controller && !controller.signal.aborted && draftRef.current.attachments.some(a => a.key === key);
126
+ update(d => ({ ...d, attachments: d.attachments.map(a => a.key === key ? { ...a, status: "uploading", phase: "queued", bytes: 0, size: file.size, error: undefined } : a) }));
127
+ void Promise.resolve().then(() => backend.uploadFile!(taskId, file, {
128
+ draftId: key, signal: controller.signal,
129
+ onPhase: phase => { if (current()) update(d => ({ ...d, attachments: d.attachments.map(a => a.key === key ? { ...a, phase } : a) })); },
130
+ onBytes: bytes => { if (current()) update(d => ({ ...d, attachments: d.attachments.map(a => a.key === key ? { ...a, bytes } : a) })); },
131
+ })).then(receipt => {
132
+ if (!current()) return;
133
+ if (!receipt.path) throw new Error("文件尚未进入当前工作区,请稍后重试。");
134
+ const path = receipt.path;
135
+ update(d => ({ ...d, attachments: d.attachments.map(a => a.key === key ? { ...a, path, preview: a.kind === "image" ? receipt.url ?? backend.fileUrl(taskId, path) : "", status: "ready", bytes: file.size } : a) }));
136
+ uploadFiles.current.delete(key);
137
+ uploadScope.current.openBoard({ kind: "file", taskId, path });
138
+ }).catch((e: unknown) => {
139
+ if (current()) update(d => ({ ...d, attachments: d.attachments.map(a => a.key === key ? { ...a, status: "error", error: e instanceof Error ? e.message : "上传失败,请重试。" } : a) }));
140
+ }).finally(() => {
141
+ if (uploads.current.get(key) === controller) uploads.current.delete(key);
142
+ if (preview && previews.current.delete(preview)) URL.revokeObjectURL(preview);
143
+ });
144
+ };
106
145
  const addFiles = (files: File[]) => {
107
146
  if (locked) return;
108
147
  setError("");
109
- for (const original of files) {
110
- const f = !original.type.startsWith("image/") && /\.(txt|md|log|csv|json|ya?ml|[cm]?[jt]sx?|py|sh|css|html)$/i.test(original.name)
111
- ? new File([original], original.name, { type: "text/plain" }) : original;
112
- const type = f.type.split(";")[0];
113
- const isImage = ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(type);
114
- if (!isImage && type !== "text/plain") { setError(`“${f.name}”暂不支持,请选择 PNG、JPEG、GIF、WebP 或纯文本文件。`); continue; }
115
- if (f.size > 10 * 1024 * 1024) { setError(`“${f.name}”超过 10 MB,请缩小后重试。`); continue; }
148
+ if (!backend.uploadFile) { setError("当前服务暂不支持附件上传。"); return; }
149
+ for (const f of files) {
150
+ const type = uploadFileType(f.name);
151
+ const document = isOriginalDocument(f.name);
152
+ const isImage = !!type?.startsWith("image/");
153
+ if (!type) { setError(`“${f.name}”暂不支持,请选择图片、文档、表格或文本文件。`); continue; }
154
+ if (f.size > (document ? DOCUMENT_MAX_BYTES : UPLOAD_OTHER_MAX_BYTES)) { setError(`“${f.name}”超过 ${document ? "100 MiB" : "10 MiB"},请缩小后重试。`); continue; }
116
155
  const key = clientId();
117
156
  const preview = isImage ? URL.createObjectURL(f) : "";
118
157
  if (preview) previews.current.add(preview);
119
- update((d) => ({ ...d, attachments: [...d.attachments, { key, kind: isImage ? "image" : "file", name: f.name || "附件", preview, path: "", status: "uploading" }] }));
120
- const controller = new AbortController();
121
- uploads.current.set(key, controller);
122
- const body = new FormData(); body.append("file", f, f.name);
123
- void fetch(`/api/tasks/${task.id}/uploads`, { method: "POST", body, signal: controller.signal })
124
- .then(async (res) => {
125
- const j = await res.json();
126
- if (!res.ok || !j.path || !j.url) throw new Error(j.error || "上传失败,请重新添加。");
127
- if (!mounted.current) return;
128
- update((d) => ({ ...d, attachments: d.attachments.map((a) => a.key === key ? { ...a, path: j.path, preview: isImage ? j.url : "", status: "ready" } : a) }));
129
- })
130
- .catch((e: unknown) => {
131
- if (!mounted.current || controller.signal.aborted) return;
132
- update((d) => ({ ...d, attachments: d.attachments.map((a) => a.key === key ? { ...a, status: "error", error: e instanceof Error ? e.message : "上传失败" } : a) }));
133
- }).finally(() => { uploads.current.delete(key); if (preview) { URL.revokeObjectURL(preview); previews.current.delete(preview); } });
158
+ update((d) => ({ ...d, attachments: [...d.attachments, { key, kind: isImage ? "image" : "file", name: f.name || "附件", preview, path: "", status: "uploading", phase: "queued", bytes: 0, size: f.size }] }));
159
+ startUpload(key, f);
134
160
  }
135
161
  };
136
162
  const removeAtt = (key: string) => {
137
163
  uploads.current.get(key)?.abort();
164
+ uploads.current.delete(key);
165
+ uploadFiles.current.delete(key);
166
+ const preview = draftRef.current.attachments.find(a => a.key === key)?.preview;
167
+ if (preview && previews.current.delete(preview)) URL.revokeObjectURL(preview);
138
168
  update((d) => ({ ...d, attachments: d.attachments.filter((a) => a.key !== key) }));
139
169
  };
140
170
  const sources = draft.sources ?? [];
@@ -158,12 +188,12 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
158
188
  };
159
189
  const uploading = draft.attachments.some((a) => a.status === "uploading");
160
190
  const failed = draft.attachments.some((a) => a.status === "error");
161
- const ownPayload = [draft.quote ? quoteText(draft.quote) : "", draft.text.trim(), ...draft.attachments.filter((a) => a.status === "ready").map((a) => a.kind === "image" ? attachmentMarker(a.path) : fileAttachmentMarker(a.path))].filter(Boolean).join("\n\n");
162
- const hasBoardContext=!!boardView&&!!formatBoardContext(boardView,task.id);
191
+ const readyAttachments = draft.attachments.filter(a => a.status === "ready");
192
+ const ownPayload = [draft.quote ? quoteText(draft.quote) : "", draft.text.trim(), readyAttachments.length ? "请先读取以下附件原件,再处理本条消息。" : "", ...readyAttachments.map((a) => a.kind === "image" ? attachmentMarker(a.path) : fileAttachmentMarker(a.path))].filter(Boolean).join("\n\n");
163
193
  const isClear=draft.text.trim()==='/clear'&&!draft.attachments.length&&!draft.quote;
164
- const currentSignature=JSON.stringify({payload:ownPayload,ids:sources.map(source=>source.id),...(!includeBoard||boardView?.autoContext===false?{withoutBoardContext:true}:{})});
194
+ const currentSignature=JSON.stringify({payload:ownPayload,ids:sources.map(source=>source.id),...(boardView?.autoContext===false?{withoutBoardContext:true}:{})});
165
195
  const pendingFork=sources.length&&draft.forkRequest?.signature===currentSignature?draft.forkRequest:null;
166
- const payload=pendingFork?pendingFork.payload??ownPayload:ownPayload&&!isClear&&includeBoard?appendBoardContext(ownPayload,boardView,task.id):ownPayload;
196
+ const payload=pendingFork?pendingFork.payload??ownPayload:ownPayload&&!isClear?appendBoardContext(ownPayload,boardView,task.id):ownPayload;
167
197
  const tooLong = payload.length > MAX_MESSAGE_CHARS;
168
198
  const canSend = !!payload && !uploading && !failed && !tooLong && !locked;
169
199
  const clear = () => {
@@ -177,23 +207,23 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
177
207
  let submittedDraft = draftRef.current;
178
208
  let requestId = "";
179
209
  const currentView=readBoardState(task.id);
180
- let submittedPayload=includeBoard?appendBoardContext(ownPayload,currentView,task.id):ownPayload;
210
+ let submittedPayload=appendBoardContext(ownPayload,currentView,task.id);
181
211
  if (sources.length) {
182
- const signature = JSON.stringify({ payload:ownPayload, ids: sources.map((s) => s.id), ...(!includeBoard||currentView?.autoContext===false?{withoutBoardContext:true}:{}) });
212
+ const signature = JSON.stringify({ payload:ownPayload, ids: sources.map((s) => s.id), ...(currentView?.autoContext===false?{withoutBoardContext:true}:{}) });
183
213
  const previous=submittedDraft.forkRequest?.signature===signature?submittedDraft.forkRequest:null;
184
214
  requestId=previous?.id??clientId();
185
215
  // Once dispatched, retries must keep the exact payload as well as the id.
186
216
  // A passive page/file change is not a new user request or a new branch.
187
217
  if(previous)submittedPayload=previous.payload??ownPayload;
188
- if(submittedPayload.length>MAX_MESSAGE_CHARS){setError("消息和看板背景合计超过长度限制,请精简消息或取消本次背景。");return;}
218
+ if(submittedPayload.length>MAX_MESSAGE_CHARS){setError("消息和看板背景合计超过长度限制,请精简消息内容后重试。");return;}
189
219
  update((d) => ({ ...d, forkRequest: { id: requestId, signature, payload:submittedPayload } }));
190
220
  submittedDraft = draftRef.current;
191
221
  }
192
- if(submittedPayload.length>MAX_MESSAGE_CHARS){setError("消息和看板背景合计超过长度限制,请精简消息或取消本次背景。");return;}
222
+ if(submittedPayload.length>MAX_MESSAGE_CHARS){setError("消息和看板背景合计超过长度限制,请精简消息内容后重试。");return;}
193
223
  sendingRef.current = true; setTaskSending(task.id, true); setSending(true); setError("");
194
224
  try {
195
225
  if (await (sources.length ? onFork(submittedPayload, sources.map((s) => s.id), requestId) : onSend(submittedPayload))) {
196
- if (mounted.current) {update(current=>acknowledgeDraft(current,submittedDraft));setIncludeBoard(true);}
226
+ if (mounted.current) {update(current=>acknowledgeDraft(current,submittedDraft));}
197
227
  else writeDraft(task.id,acknowledgeDraft(readDraft(task.id),submittedDraft));
198
228
  }
199
229
  else if (mounted.current) setError("发送失败,草稿已保留,请重试。");
@@ -217,34 +247,24 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
217
247
  </div>}
218
248
 
219
249
  {slash && !running && !locked && "/clear".startsWith(draft.text.trim()) && <div className="slash"><button className="slash-item" onClick={clear}><span className="cmd">/clear</span><span className="cd">保存摘要 · 开启新上下文</span></button></div>}
250
+ {queued}
220
251
  <div className={`comp-box${dragging ? " dropping" : ""}`}
221
252
  onDragEnter={(e) => { if (hasFileDrag(e)) { e.preventDefault(); if (!locked) { dragDepth.current++; setDragging(true); } } }}
222
253
  onDragOver={(e) => { if (hasFileDrag(e)) e.preventDefault(); }}
223
254
  onDragLeave={() => { if (dragDepth.current > 0 && --dragDepth.current === 0) setDragging(false); }}
224
255
  onDrop={(e) => { if (!hasFileDrag(e)) return; e.preventDefault(); dragDepth.current = 0; setDragging(false); addFiles(Array.from(e.dataTransfer.files)); }}>
225
256
  {sources.length > 0 && <div className="fork-sources">{sources.map((source) => <span key={source.id}>{Icon.git()} 从 {source.title} 派生<button disabled={locked} aria-label={`移除来源 ${source.title}`} onClick={() => update((d) => ({ ...d, sources: d.sources?.filter((s) => s.id !== source.id), forkRequest: undefined }))}>{Icon.x()}</button></span>)}</div>}
226
- {hasBoardContext&&<div className="comp-board-context" style={{display:'flex',alignItems:'center',gap:8,padding:'8px 14px',fontSize:12,color:'var(--muted)'}}>
227
- <label style={{display:'flex',alignItems:'center',gap:6,cursor:'pointer'}} title="发送时自动附带当前文件、打开页签和最近访问记录;不会读取文件正文。">
228
- <input type="checkbox" checked={includeBoard} disabled={locked} onChange={event=>setIncludeBoard(event.target.checked)}/>看板背景
229
- </label>
230
- <details style={{minWidth:0,flex:1}}>
231
- <summary style={{cursor:'pointer',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{boardView?.current?.path??boardView?.file??(boardView?.browser?'浏览器页面':'无当前文件')} · {boardView?.opened?.length??boardView?.files.length??0} 个打开 · {boardView?.history?.length??0} 个最近访问{!includeBoard?' · 本次不带':''}</summary>
232
- <div style={{maxHeight:140,overflow:'auto',overflowWrap:'anywhere',paddingTop:6}}>
233
- <div>{boardView?.visible===false?'看板已收起,以下为上次浏览状态。':'以下浏览背景会随消息提供给 AI。'}</div>
234
- <div>打开页签:{(boardView?.opened??[]).map(file=>`${file.path}${file.sourceId&&file.sourceId!==task.id?`(来源:${tasks.find(source=>source.id===file.sourceId)?.title??file.sourceId})`:''}`).join('、')||'无'}</div>
235
- <div>最近访问:{(boardView?.history??[]).map(file=>`${file.path}${file.sourceId&&file.sourceId!==task.id?`(来源:${tasks.find(source=>source.id===file.sourceId)?.title??file.sourceId})`:''}`).join('、')||'无'}</div>
236
- </div>
237
- </details>
238
- </div>}
239
- {draft.quote && <div className="comp-quote"><div><strong>引用 {draft.quote.author}</strong><p>{draft.quote.text}</p></div><button className="icon-btn" disabled={locked} aria-label="移除引用" onClick={() => update((d) => ({ ...d, quote: null }))}>{Icon.x()}</button></div>}
240
- {draft.attachments.length > 0 && <div className="attach-row">{draft.attachments.map((a) => <div key={a.key} className={`attach-chip ${a.kind} ${a.status}`} title={a.error || a.name}>
257
+ {draft.quote && <ReferenceCard key={draft.quote.id} className="comp-quote" title={draft.quote.author} text={draft.quote.text} disabled={locked} removeLabel="移除引用" onRemove={() => { update((d) => ({ ...d, quote: null })); ref.current?.focus({ preventScroll: true }); }} renderContent={draft.quote.sourceTaskId ? text => <FileLinkContext.Provider value={{ taskId: draft.quote!.sourceTaskId! }}><Markdown>{text}</Markdown></FileLinkContext.Provider> : undefined} />}
258
+ {draft.attachments.some(a => a.status !== "ready") && <div className="composer-upload-list">{draft.attachments.filter(a => a.status !== "ready").map(a => <UploadProgress key={a.key} name={a.name}
259
+ status={a.status === "error" ? "error" : a.phase ?? "queued"} bytes={a.bytes} total={a.size} disabled={locked}
260
+ error={`${a.error || "上传失败,请重试。"}${uploadFiles.current.has(a.key) || a.error?.includes("重新选择文件") ? "" : " 请重新选择文件。"}`}
261
+ onCancel={() => removeAtt(a.key)} onRemove={() => removeAtt(a.key)} onRetry={uploadFiles.current.has(a.key) ? () => startUpload(a.key, uploadFiles.current.get(a.key)!) : undefined} />)}</div>}
262
+ {readyAttachments.length > 0 && <div className="attach-row">{readyAttachments.map((a) => <div key={a.key} className={`attach-chip ${a.kind} ${a.status}`} title={a.name}>
241
263
  {a.kind === "image" && a.preview ? <img src={a.preview} alt={a.name} /> : <span className="attach-file">{Icon.clip()} {a.name}</span>}
242
- {a.status === "uploading" && <span className="attach-badge">上传中…</span>}
243
- {a.status === "error" && <span className="attach-badge err">{a.error || "上传失败"}</span>}
244
264
  <button className="attach-x" disabled={locked} aria-label={`移除 ${a.name}`} onClick={() => removeAtt(a.key)}>×</button>
245
265
  </div>)}</div>}
246
266
  <div className="comp-area"><textarea ref={ref} rows={2} value={draft.text} disabled={locked} aria-label="消息输入框" aria-expanded={mentionOpen} aria-controls={mentionOpen ? `mentions-${task.id}` : undefined} aria-activedescendant={mentionOpen && candidates.length ? `mention-${candidates[Math.min(mentionIndex, candidates.length - 1)].id}` : undefined}
247
- placeholder={configBusy ? "正在保存设置…" : disabled ? "启动会话后即可回复…" : sources.length ? "描述这些新分支要继续完成的工作…" : running ? "补充下一步,当前轮次结束后发送…" : `和 ${agentLabel} 一起工作,@ 派生任务分支`}
267
+ placeholder={configBusy ? "正在保存设置…" : disabled ? "启动会话后即可回复…" : sources.length ? "描述这些新分支要继续完成的工作…" : running ? "补充下一步,当前轮次结束后发送…" : `和 ${agentLabel} 一起工作`}
248
268
  onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}
249
269
  onChange={(e) => { const text = e.target.value; update((d) => ({ ...d, text })); setSlash(text.trim().startsWith("/")); setCaret(e.target.selectionStart); setMentionClosed(false); setMentionIndex(0); }}
250
270
  onSelect={(e) => setCaret(e.currentTarget.selectionStart)}
@@ -265,22 +285,29 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
265
285
  if (text.length > PASTE_ATTACH_THRESHOLD) { e.preventDefault(); addFiles([new File([text], "pasted-text.txt", { type: "text/plain" })]); }
266
286
  }} /></div>
267
287
  <div className="comp-toolbar">
268
- <input ref={fileRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp,text/plain,.txt,.md,.log,.json,.csv,.yaml,.yml,.ts,.tsx,.js,.jsx,.py" multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />
269
- <div className="comp-control"><button className="comp-add" disabled={locked} title="添加附件或派生分支" aria-label="添加内容" aria-expanded={addOpen} onClick={(e) => { e.stopPropagation(); setAddOpen(!addOpen); }}>{Icon.plus()}</button>
270
- {addOpen && <Popover onClose={() => setAddOpen(false)}>
271
- <button className="pop-item" onClick={() => { setAddOpen(false); fileRef.current?.click(); }}>{Icon.clip()} 添加图片或文本文件</button>
272
- <button className="pop-item" onClick={openMention}>{Icon.git()} 从已完成任务派生 <span>@</span></button>
288
+ <input ref={fileRef} type="file" accept={FILE_UPLOAD_ACCEPT} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />
289
+ <div className="comp-control"><button className={`comp-add${addOpen ? " on" : ""}`} disabled={locked} title="附件、派生任务与新上下文" aria-label="添加内容" aria-haspopup="dialog" aria-expanded={addOpen} onClick={(e) => { e.stopPropagation(); setAddOpen(!addOpen); }}>{Icon.plus()}</button>
290
+ {addOpen && <Popover className="composer-popover composer-add-popover" ariaLabel="添加内容" placement="top" align="start" onClose={() => setAddOpen(false)}>
291
+ <div className="composer-menu-heading">添加内容</div>
292
+ <button className="pop-item composer-option" disabled={locked} onClick={() => { setAddOpen(false); fileRef.current?.click(); }}>
293
+ <span className="composer-option-icon">{Icon.clip()}</span><span className="composer-option-copy"><span className="composer-option-title">添加附件</span><span className="composer-option-sub">HTML、Markdown 最多 100 MiB,其他文件最多 10 MiB</span></span>
294
+ </button>
295
+ <button className="pop-item composer-option" disabled={locked} onClick={openMention}>
296
+ <span className="composer-option-icon">{Icon.git()}</span><span className="composer-option-copy"><span className="composer-option-title">从已完成任务派生</span><span className="composer-option-sub">带上已完成的任务,在新分支继续工作</span></span><span className="composer-option-trailing composer-menu-key">@</span>
297
+ </button>
298
+ <button className="pop-item composer-option" disabled={running || locked || task.started !== 1} onClick={() => { setAddOpen(false); clear(); }}>
299
+ <span className="composer-option-icon">{Icon.clear()}</span><span className="composer-option-copy"><span className="composer-option-title">新上下文</span><span className="composer-option-sub">{running ? "本轮结束后可用" : "保存摘要,开启新的上下文窗口"}</span></span>
300
+ </button>
273
301
  </Popover>}
274
302
  </div>
275
303
  <RunControls {...controls} disabled={controls.disabled || sending} />
276
304
  <div className="send-group">
277
- {(!running || !!payload) && <button className={`send${running ? " queue" : ""}`} disabled={!canSend} aria-label={sources.length ? `派生 ${sources.length} 个分支` : running ? "排队发送" : "发送消息"} title={sending ? "发送中…" : running ? "本轮结束后发送" : "发送消息"} onClick={() => void submit()}>{sending ? <span className="spinner" /> : Icon.send()}</button>}
278
- {running && <button className="send stop" disabled={stopping} aria-label="停止当前轮次" title={stopping ? "正在停止…" : "停止当前轮次"} onClick={() => void stop()}>{stopping ? <span className="spinner" /> : Icon.stop()}</button>}
305
+ {(!running || !!payload) && <button className={`send${running ? " queue" : ""}`} disabled={!canSend} aria-busy={sending} aria-label={sources.length ? `派生 ${sources.length} 个分支` : running ? "排队发送" : "发送消息"} title={sending ? "发送中…" : running ? "本轮结束后发送" : "发送消息(Enter)"} onClick={() => void submit()}>{sending ? <span className="spinner" /> : Icon.send()}</button>}
306
+ {running && <button className="send stop" disabled={stopping} aria-busy={stopping} aria-label="停止当前轮次" title={stopping ? "正在停止…" : "停止当前轮次"} onClick={() => void stop()}>{stopping ? <span className="spinner" /> : Icon.stop()}</button>}
279
307
  </div>
280
308
  </div>
281
309
  </div>
282
- <div className="comp-caption"><span>{sources.length ? `发送后创建 ${sources.length} 条独立分支并开始执行` : running ? "运行中 · 补充消息将排队" : "Enter 发送 · Shift + Enter 换行"}</span><button disabled={running || locked || task.started !== 1} onClick={clear} title="保存摘要并开启新的上下文窗口">{Icon.clear()} 新上下文</button></div>
283
- {(error || failed || tooLong) && <div className="comp-error" role="alert">{error || (tooLong ? "消息过长,请将部分内容作为文本附件添加。" : "请移除上传失败的附件后重新添加,再发送。")}</div>}
310
+ {(error || failed || tooLong) && <div className="comp-error" role="alert">{error || (tooLong ? "消息过长,请将部分内容作为文本附件添加。" : "请重试或移除上传失败的附件,再发送。")}</div>}
284
311
  {uploading && <div className="comp-upload-status" role="status">正在上传附件,完成后可发送…</div>}
285
312
  </div>
286
313
  </div>;
@@ -487,7 +487,7 @@ export default function Orchestrator({ board }: { board?: import("./boardInterac
487
487
  than inside the task that happened to hit it first. */}
488
488
  <AgentAuthBanner broken={o.brokenAgents} onReconnect={() => openSettings("agents")} />
489
489
 
490
- <div className={`body${isMobile ? " mobile" : ""}`}>
490
+ <div className={`body${isMobile ? " mobile" : ""}`} data-chat-sidebars={!isMobile&&(!layout.projCollapsed||project&&!layout.taskCollapsed)?"open":"closed"}>
491
491
  {o.bootError ? (
492
492
  // The very first fetch failed — nothing to render behind this, so a
493
493
  // centered retry beats an empty workspace that looks "hung".