@spzxg/cli-ui 0.6.6 → 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 (75) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.md +79 -20
  3. package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
  4. package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-BmwOLWFI.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
  7. package/dist/{cli-ui-xlsx-DEDdHrJJ.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 +3 -1
  11. package/src/board/CliBoard.tsx +128 -100
  12. package/src/board/FileViewer.tsx +259 -132
  13. package/src/board/FilesPane.tsx +129 -40
  14. package/src/board/HtmlReadingPreview.tsx +52 -0
  15. package/src/board/MarkdownPreview.tsx +134 -0
  16. package/src/board/PanelControls.tsx +19 -7
  17. package/src/board/fileKind.ts +138 -0
  18. package/src/board/fileOps.ts +9 -2
  19. package/src/board/fileScope.ts +29 -0
  20. package/src/board/fileSync.ts +343 -0
  21. package/src/board/fileSyncCache.ts +250 -0
  22. package/src/board/fileSyncProcessor.ts +71 -0
  23. package/src/board/fileSyncQueue.ts +56 -0
  24. package/src/board/fileSyncTransport.ts +85 -0
  25. package/src/board/fileWatch.ts +177 -76
  26. package/src/board/folderDownload.ts +39 -5
  27. package/src/board/frameQuote.ts +10 -8
  28. package/src/board/htmlReading.ts +148 -0
  29. package/src/board/textLoad.ts +14 -39
  30. package/src/board/useBoardUpload.tsx +51 -0
  31. package/src/board/useBoardVisibility.ts +35 -0
  32. package/src/board/workspace.ts +38 -6
  33. package/src/chat/CliChat.tsx +86 -46
  34. package/src/chat/Composer.tsx +52 -20
  35. package/src/chat/GroupChat.tsx +113 -0
  36. package/src/chat/RunControls.tsx +132 -42
  37. package/src/chat/draft.ts +5 -2
  38. package/src/core/blocks.ts +2 -1
  39. package/src/core/client.ts +44 -57
  40. package/src/core/documentUpload.ts +196 -0
  41. package/src/core/store.ts +71 -14
  42. package/src/core/types.ts +1 -1
  43. package/src/index.ts +3 -0
  44. package/src/msglist/CliMsgList.tsx +9 -2
  45. package/src/operator/node/Chat.tsx +7 -8
  46. package/src/operator/node/Composer.tsx +99 -72
  47. package/src/operator/node/Orchestrator.tsx +1 -1
  48. package/src/operator/node/SessionRail.tsx +71 -32
  49. package/src/operator/node/SessionView.tsx +25 -25
  50. package/src/operator/node/Transcript.tsx +21 -9
  51. package/src/operator/node/boardBackend.ts +0 -19
  52. package/src/operator/node/boardEvents.ts +21 -3
  53. package/src/operator/node/boardState.ts +18 -1
  54. package/src/operator/node/boardWorkspace.ts +4 -1
  55. package/src/operator/node/composerDraft.ts +6 -2
  56. package/src/operator/node/format.ts +4 -2
  57. package/src/operator/node/liveStream.ts +14 -2
  58. package/src/operator/node/useTaskStream.ts +3 -1
  59. package/src/operator.base.css +141 -38
  60. package/src/operator.css +141 -38
  61. package/src/styles.css +248 -34
  62. package/src/ui/Markdown.tsx +2 -2
  63. package/src/ui/Popover.tsx +25 -12
  64. package/src/ui/QueuedMessages.tsx +69 -0
  65. package/src/ui/ReferenceCard.tsx +28 -0
  66. package/src/ui/TextBlock.tsx +6 -4
  67. package/src/ui/Transcript.tsx +20 -10
  68. package/src/ui/UploadProgress.tsx +46 -0
  69. package/src/ui/WorkspaceDrawer.tsx +64 -0
  70. package/src/ui/WorkspaceTabs.tsx +60 -0
  71. package/src/ui/format.ts +3 -1
  72. package/src/ui/reference.ts +40 -0
  73. package/src/ui/useChatScrollbar.ts +29 -0
  74. package/src/ui/useComposerAutosize.ts +54 -0
  75. package/dist/cli-ui-embed-BAqonuoD.js +0 -45952
@@ -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".
@@ -1,6 +1,9 @@
1
1
  "use client";
2
+ import { subscribeWorkspaceFiles } from "../../board/fileWatch";
3
+ import { retainFileScope } from "../../board/fileScope";
2
4
 
3
5
  import { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
6
+ import { createPortal } from "react-dom";
4
7
  import { Icon } from "./icons";
5
8
  import TaskChanges, { type ResolveResult } from "./TaskChanges";
6
9
  import { BrowserPane } from "./BrowserPane";
@@ -13,17 +16,18 @@ import { clientId } from "../../core/id";
13
16
  import { useBoardState, readBoardState, visitBoardFile, reportBoardState, releaseBoardState, activateBoardState } from "./boardState";
14
17
  import type { BoardView } from "../../board/workspace";
15
18
  import { useBoardPanels } from "../../board/panels";
16
- import { PanelControls, PanelTab } from "../../board/PanelControls";
19
+ import { PanelControls, PanelTab, type InlineBoardLayout } from "../../board/PanelControls";
17
20
  import { FilesPane } from "../../board/FilesPane";
18
21
  import { FileViewer } from "../../board/FileViewer";
22
+ import { useBoardUpload } from "../../board/useBoardUpload";
19
23
  import { getOperatorBoardBackend } from "./boardBackend";
20
24
  import { useOperatorContextEvents, useOperatorFileWatch } from "./boardEvents";
21
25
  import { OperatorBoardContext, registerOperatorBoard, OperatorBoardQuoteContext } from "./boardInteraction";
22
26
  import type { Quote } from "./composerDraft";
23
27
  import type { OperatorBoardOptions, OperatorBoardRequest } from "./boardInteraction";
24
- type OpenFile = { reloadKey?:number; taskId: string; path: string; version: number; mtime?: number; size?: number };
28
+ type OpenFile = { name?: string; reloadKey?:number; taskId: string; path: string; version: number; mtime?: number; size?: number };
25
29
  const fileKey = (file: Pick<OpenFile,"taskId"|"path">) => `file:${file.taskId}:${file.path}`;
26
- const LABEL:Record<string,string>={files:"文件",changes:"改动",context:"上下文",preview:"预览",browser:"浏览器"};
30
+ const LABEL:Record<string,string>={files:"工作区",changes:"改动",context:"上下文",preview:"预览",browser:"浏览器"};
27
31
  type Session = { n: number; summaryBefore: string | null };
28
32
 
29
33
  // The live URL a project's dev server is reachable at when no registered
@@ -119,15 +123,21 @@ function ContextPane({ task, sessions, running, onClear }: { task: TaskRow; sess
119
123
  }
120
124
 
121
125
  export function SessionRail({ project, task, sessions, running, onResolveWithAI, onMerged, onPrCreated, onClear, onCollapse, onSwitchToChat,
122
- tabs, availableTabs, defaultTab, autoContext, watchFiles = true, fileWatchIntervalMs, onFilesChange, onFilesWatch, onBoardContextChange, onEvent, openRequest, onRequestHandled, activity, onQuote }: OperatorBoardOptions & {
126
+ tabs, availableTabs, defaultTab, autoContext, watchFiles = true, fileWatchIntervalMs, onFilesChange, onFilesWatch, onBoardContextChange, onEvent, openRequest, onRequestHandled, onQuote, inlineLayout, onExpand, visible = true }: OperatorBoardOptions & {
123
127
  project: ProjectRow; task: TaskRow; sessions: Session[]; running: boolean;
124
128
  onResolveWithAI: (taskId: string) => Promise<ResolveResult>; onMerged?: () => void;
125
129
  onPrCreated?: (url: string) => void; onClear: () => void; onCollapse: () => void; onSwitchToChat: () => void;
126
130
  openRequest?: OperatorBoardRequest | null; onRequestHandled?: (sequence:number)=>void; activity?: string; onQuote?: (quote:Quote)=>void;
131
+ inlineLayout?: InlineBoardLayout;
132
+ onExpand?: () => void;
133
+ visible?: boolean;
127
134
  }) {
128
135
  const contextQuote=useContext(OperatorBoardQuoteContext);
129
136
  const parentBoard=useContext(OperatorBoardContext);
130
- const quote=onQuote??contextQuote;
137
+ const inlineRef=useRef(inlineLayout);inlineRef.current=inlineLayout;
138
+ const sendQuote=onQuote??contextQuote;
139
+ const quote=sendQuote ? (value:Quote)=>{sendQuote(value);inlineLayout?.onChat();} : undefined;
140
+ const contentVisible=visible&&(!inlineLayout||inlineLayout.active);
131
141
  const features=clientFeatures();
132
142
  const available=(availableTabs ?? ['files','changes','context','browser','preview']).filter(id=>id in LABEL).filter(id=>id!=='browser'||features.browser).filter(id=>id!=='preview'||features.livePreview);
133
143
  const defaults=tabs ?? ['files',...(features.browser?['browser']:[])];
@@ -167,13 +177,37 @@ export function SessionRail({ project, task, sessions, running, onResolveWithAI,
167
177
  const fileTabs=available.includes('files')?files.map(fileKey):[];
168
178
  const shown=[...panels.open,...fileTabs];
169
179
  const active=shown.includes(tab)?tab:shown[0]??'';
180
+ const [toolbarHost, setToolbarHost] = useState<HTMLSpanElement | null>(null);
170
181
  const tabsRef=useRef<HTMLDivElement>(null);
171
182
  useLayoutEffect(()=>{
172
183
  const reveal=()=>{const box=tabsRef.current,item=box?.querySelector<HTMLElement>('.rail-tab.on');if(!box||!item)return;
173
184
  const outer=box.getBoundingClientRect(),inner=item.getBoundingClientRect();if(inner.left<outer.left)box.scrollLeft+=inner.left-outer.left;else if(inner.right>outer.right)box.scrollLeft+=inner.right-outer.right;};
174
185
  reveal();if(typeof ResizeObserver==='undefined')return;const observer=new ResizeObserver(reveal);if(tabsRef.current)observer.observe(tabsRef.current);return()=>observer.disconnect();
175
- },[active,shown.length]);
186
+ },[active,shown.length,inlineLayout?.active,inlineLayout?.tabsHost]);
176
187
  const file=files.find(f=>fileKey(f)===active);
188
+ // Every opened tab participates, including a file from a source task. Only
189
+ // already-read preview ranges are synchronized; closed files are not prefetched.
190
+ const openedScope = JSON.stringify(available.includes('files') ? files.map(({ taskId, path }) => ({ taskId, path })) : []);
191
+ const directorySource = active === 'files' ? folderTask : task.id;
192
+ useEffect(() => {
193
+ if (!watchFiles || !contentVisible) return;
194
+ const grouped = new Map<string, string[]>();
195
+ for (const file of JSON.parse(openedScope) as Array<{ taskId: string; path: string }>) {
196
+ grouped.set(file.taskId, [...(grouped.get(file.taskId) ?? []), file.path]);
197
+ }
198
+ if (!grouped.has(directorySource)) grouped.set(directorySource, []);
199
+ const stops: Array<() => void> = [];
200
+ for (const [source, paths] of grouped) {
201
+ stops.push(retainFileScope(backend, source, { paths, dirs: [] }));
202
+ if (source !== task.id) stops.push(subscribeWorkspaceFiles(backend, source, event => {
203
+ setTick(value => value + 1);
204
+ const changed = new Set(event.changes.map(change => change.path));
205
+ setFiles(previous => previous.map(file => file.taskId === source && changed.has(file.path)
206
+ ? { ...file, version: file.version + 1, reloadKey: (file.reloadKey ?? 0) + 1 } : file));
207
+ }));
208
+ }
209
+ return () => stops.forEach(stop => stop());
210
+ }, [backend, task.id, watchFiles, contentVisible, openedScope, directorySource]);
177
211
  const current=useRef(active);current.current=active;
178
212
  const openFile=(taskId:string,path:string)=>{
179
213
  if(!available.includes('files'))return;
@@ -187,14 +221,17 @@ export function SessionRail({ project, task, sessions, running, onResolveWithAI,
187
221
  const next=[...previous,{taskId,path:clean,version:0}];
188
222
  if(next.length>8){const index=next.findIndex(f=>fileKey(f)!==current.current&&fileKey(f)!==id);next.splice(index<0?0:index,1);}
189
223
  return next;
190
- });setTab(id);
224
+ });setTab(id);inlineRef.current?.onActivate();
191
225
  };
226
+ const canUpload=available.includes('files')&&!!(task.started||task.worktree_path);
227
+ const uploader=useMemo(()=>canUpload&&backend.uploadFile ? (file:File,options:Parameters<NonNullable<typeof backend.uploadFile>>[2])=>backend.uploadFile!(task.id,file,options) : undefined,[backend,task.id,canUpload]);
228
+ const upload=useBoardUpload(task.id,uploader,receipt=>{if(receipt.path)openFile(task.id,receipt.path);});
192
229
  useEffect(()=>{
193
230
  if(parentBoard)return;
194
231
  return registerOperatorBoard(target=>{
195
232
  if(target.taskId!==task.id)return false;
196
233
  if(target.kind==='file'){if(!available.includes('files'))return false;openFile(target.taskId,target.path);return true;}
197
- if(!panels.show('browser'))return false;setTab('browser');return true;
234
+ if(!panels.show('browser'))return false;setTab('browser');inlineRef.current?.onActivate();return true;
198
235
  });
199
236
  },[task.id,parentBoard,available.join(','),panels.show]);
200
237
  const seen=useRef<number|null>(null);
@@ -203,58 +240,60 @@ export function SessionRail({ project, task, sessions, running, onResolveWithAI,
203
240
  seen.current=openRequest.sequence;
204
241
  const target=openRequest.target;
205
242
  if(target.kind==='file')openFile(target.taskId,target.path);
206
- else if(target.taskId===task.id&&panels.show('browser'))setTab('browser');
243
+ else if(target.taskId===task.id&&panels.show('browser')){setTab('browser');inlineLayout?.onActivate();}
207
244
  onRequestHandled?.(openRequest.sequence);
208
245
  },[openRequest,restored]); // Requests originate in the owning session, including when this panel was collapsed.
209
- const lastRefresh=useRef(0);
210
- useEffect(()=>{const timer=setTimeout(()=>{lastRefresh.current=Date.now();setTick(n=>n+1);},Math.max(0,700-(Date.now()-lastRefresh.current)));return()=>clearTimeout(timer);},[activity,task.updated_at,running]);
211
- useEffect(()=>{if(!running)return;const timer=setInterval(()=>{if(document.visibilityState==='visible')setTick(n=>n+1);},15000);return()=>clearInterval(timer);},[running]);
212
246
  useEffect(()=>{
213
247
  if(!file)return;let alive=true;
214
248
  void backend.stat(file.taskId,file.path).then(stat=>{
215
249
  if(!alive||stat.type!=='file')return;
216
- setFiles(previous=>previous.map(f=>fileKey(f)===fileKey(file)&&f.mtime!==stat.mtime?{...f,mtime:stat.mtime,size:stat.size,version:f.mtime===undefined?f.version:f.version+1}:f));
250
+ setFiles(previous=>previous.map(f=>fileKey(f)===fileKey(file)&&(f.mtime!==stat.mtime||f.name!==stat.name)?{...f,name:stat.name,mtime:stat.mtime,size:stat.size,version:f.mtime===undefined?f.version:f.version+1}:f));
217
251
  },()=>{if(alive)setFiles(previous=>previous.map(f=>fileKey(f)===fileKey(file)&&f.mtime!==-1?{...f,mtime:-1,version:f.version+1}:f));});
218
252
  return()=>{alive=false;};
219
253
  },[backend,active,tick]);
220
254
  const boardView:BoardView={tab:active,dir,file:file?.path??null,files:files.map(file=>file.path),
221
255
  current:file?{path:file.path,sourceId:file.taskId}:null,opened:files.map(file=>({path:file.path,sourceId:file.taskId})),
222
- history:boardState?.history??[],browser:active==='browser'?browserPage:null,visible:true,autoContext:autoContext!==false};
256
+ history:boardState?.history??[],browser:active==='browser'?browserPage:null,visible:contentVisible,autoContext:autoContext!==false};
223
257
  const viewSignature=JSON.stringify(boardView);
224
- useEffect(()=>{if(restored)reportBoardState(task.id,owner,boardView);},[task.id,owner,restored,viewSignature]); // Metadata changes only; file refresh counters do not claim a new view.
258
+ useEffect(()=>{if(restored)reportBoardState(task.id,owner,boardView,directorySource);},[task.id,owner,restored,viewSignature,directorySource]); // Metadata changes only; file refresh counters do not claim a new view.
225
259
  useEffect(()=>()=>releaseBoardState(task.id,owner),[task.id,owner]);
226
- const label=(id:string)=>LABEL[id]??files.find(f=>fileKey(f)===id)?.path.split('/').pop()??id;
260
+ const label=(id:string)=>LABEL[id]??files.find(f=>fileKey(f)===id)?.name??files.find(f=>fileKey(f)===id)?.path.split('/').pop()??id;
227
261
  const close=(id:string)=>{
228
262
  if(id.startsWith('file:'))setFiles(previous=>previous.filter(f=>fileKey(f)!==id));else panels.close(id);
229
- if(active===id)setTab(shown.find(item=>item!==id)??'');
263
+ if(active===id){const next=shown.find(item=>item!==id)??'';setTab(next);if(!next)inlineLayout?.onChat();}
230
264
  };
231
- return <aside className="sess-rail" aria-label="任务看板" onPointerDown={()=>activateBoardState(task.id,owner)} onClickCapture={()=>activateBoardState(task.id,owner)} onFocusCapture={()=>activateBoardState(task.id,owner)}>
232
- <div className="rail-tabs">
233
- <div className="operator-file-tabs" ref={tabsRef} role="tablist" aria-label="看板模块">
234
- {shown.map(id=><PanelTab key={id} id={id} label={label(id)} active={active===id} onSelect={()=>{const opened=files.find(file=>fileKey(file)===id);if(opened)visitBoardFile(task.id,{path:opened.path,sourceId:opened.taskId});setTab(id);}} onClose={()=>close(id)}/>)}
265
+ const railTabs=<div className="rail-tabs">
266
+ <div className="operator-file-tabs" ref={tabsRef} role={inlineLayout?undefined:"tablist"} aria-label={inlineLayout?undefined:"看板模块"}>
267
+ {shown.map(id=><PanelTab key={id} id={id} label={label(id)} title={files.find(file=>fileKey(file)===id)?.path} active={active===id&&(!inlineLayout||inlineLayout.active)} onSelect={()=>{const opened=files.find(file=>fileKey(file)===id);if(opened)visitBoardFile(task.id,{path:opened.path,sourceId:opened.taskId});setTab(id);inlineLayout?.onActivate();}} onClose={()=>close(id)}/>)}
235
268
  </div>
236
- <PanelControls available={available} open={panels.open} label={label} onShow={id=>{panels.show(id);setTab(id);}} onClose={close}/>
237
- <button className="rail-collapse" title="刷新看板" aria-label="刷新看板" onClick={()=>setTick(n=>n+1)}>{Icon.restore()}</button>
238
- <button className="rail-collapse" onClick={onCollapse} title="隐藏面板" aria-label="隐藏看板">{Icon.chevRight()}</button>
239
- </div>
269
+ <PanelControls toolbarRef={setToolbarHost} available={available} open={panels.open} label={label} onShow={id=>{panels.show(id);setTab(id);inlineLayout?.onActivate();}} onClose={close}
270
+ upload={available.includes('files')?{onChoose:upload.choose,disabled:upload.busy||!uploader,hint:canUpload?undefined:'启动会话并创建工作区后可上传文件'}:undefined}/>
271
+ {inlineLayout ? onExpand&&<button className="rail-collapse" onClick={onExpand} title="展开右侧看板" aria-label="展开看板">{Icon.chevRight()}</button> : <button className="rail-collapse" onClick={onCollapse} title="隐藏面板" aria-label="隐藏看板">{Icon.chevRight()}</button>}
272
+ </div>;
273
+ return <aside className={`sess-rail${inlineLayout?' sess-rail-inline':''}`} aria-label="任务看板" hidden={!contentVisible} style={!contentVisible?{display:'none'}:undefined} onPointerDown={()=>activateBoardState(task.id,owner)} onClickCapture={()=>activateBoardState(task.id,owner)} onFocusCapture={()=>activateBoardState(task.id,owner)}>
274
+ {inlineLayout ? inlineLayout.tabsHost&&createPortal(railTabs,inlineLayout.tabsHost) : railTabs}
275
+ {upload.input}{upload.progress}
240
276
  <div className="rail-scroll">
241
277
  {!active&&<div className="cli-board-empty">看板模块已全部关闭。通过右上角“管理看板模块”重新打开。</div>}
242
278
  {active==='changes'&&<>
243
279
  {task.fork_commit&&<div className="fork-merge-note">基于「{task.fork_source_title}」派生。合并到 {project.branch} 时,也会包含来源分支尚未合并的改动。</div>}
244
280
  <TaskChanges taskId={task.id} running={running} prUrl={task.pr_url} onMerged={onMerged} onPrCreated={onPrCreated} onResolveWithAI={async id=>{const result=await onResolveWithAI(id);if(result.ok&&!result.merged)onSwitchToChat();return result;}}/>
245
281
  </>}
246
- {active==='files'&&<FilesPane backend={backend} jobId={folderTask} dir={dir} onDir={setDir} onOpen={entry=>openFile(folderTask,entry.path)} refreshKey={tick}/>}
247
- {file&&<>
282
+ {contentVisible&&active==='files'&&<>
283
+ {folderTask!==task.id&&<div className="fork-merge-note cli-source-workspace"><span>正在浏览来源任务的工作区。</span><button className="btn btn-ghost btn-sm" onClick={()=>{setFolderTask(task.id);setDir('');}}>返回当前工作区</button></div>}
284
+ <FilesPane toolbarHost={toolbarHost} backend={backend} jobId={folderTask} dir={dir} onDir={setDir} onOpen={entry=>openFile(folderTask,entry.path)} refreshKey={tick}/>
285
+ </>}
286
+ {contentVisible&&file&&<>
248
287
  {file.taskId!==task.id&&<div className="fork-merge-note">正在查看来源任务的文件,不会切换当前执行分支。</div>}
249
- <FileViewer key={fileKey(file)} backend={backend} jobId={file.taskId} path={file.path} size={file.size} mtime={file.mtime} version={file.version} reloadKey={file.reloadKey}
288
+ <FileViewer toolbarHost={toolbarHost} key={fileKey(file)} backend={backend} jobId={file.taskId} path={file.path} displayName={file.name} size={file.size} mtime={file.mtime} version={file.version} reloadKey={file.reloadKey}
250
289
  onOpenPath={path=>openFile(file.taskId,path)}
251
- actions={quote?[{label:'引用到对话',onClick:()=>quote({id:`file:${file.taskId}:${file.path}`,author:`文件 ${file.path}`,text:`${file.taskId===task.id?'当前工作区':`来源任务 ${file.taskId} 的工作区`}文件 ${file.path}`})}]:[]}
252
- onQuoteText={quote?text=>quote({id:`selection:${file.taskId}:${file.path}`,author:`${file.taskId===task.id?"当前工作区":`来源任务 ${file.taskId} 的工作区`}文件 ${file.path}`,text}):undefined}
253
- lead={<button className="cli-fv-back" title="返回文件列表" onClick={()=>{setFolderTask(file.taskId);setDir(file.path.includes('/')?file.path.slice(0,file.path.lastIndexOf('/')):'');panels.show('files');setTab('files');}}>‹</button>}/>
290
+ actions={quote?[{label:'引用到对话',onClick:()=>quote({id:`file:${file.taskId}:${file.path}`,sourceTaskId:file.taskId,author:`文件 ${file.path}`,text:`${file.taskId===task.id?'当前工作区':`来源任务 ${file.taskId} 的工作区`}文件 ${file.path}`})}]:[]}
291
+ onQuoteText={quote?(text, context)=>quote({id:`selection:${file.taskId}:${file.path}`,sourceTaskId:file.taskId,author:`${file.taskId===task.id?"当前工作区":`来源任务 ${file.taskId} 的工作区`}文件 ${file.path}`,text:context?`文件修订:${context.revision}\n${text}`:text}):undefined}
292
+ onBack={()=>{setFolderTask(file.taskId);setDir(file.path.includes('/')?file.path.slice(0,file.path.lastIndexOf('/')):'');panels.show('files');setTab('files');}}/>
254
293
  </>}
255
294
  {active==='preview'&&features.livePreview&&<PreviewPane project={project}/>}
256
295
  {active==='context'&&<ContextPane task={task} sessions={sessions} running={running} onClear={onClear}/>}
257
- {active==='browser'&&features.browser&&<BrowserPane key={task.id} taskId={task.id} onPage={setBrowserPage} onQuote={quote?page=>quote({id:`browser:${task.id}`,author:"浏览器页面",text:`${page.title?`${page.title} — `:""}${page.url}`}):undefined} onQuoteText={quote?(text,page)=>quote({id:`browser-selection:${task.id}`,author:`网页 ${page.title||page.url}`,text}):undefined}/>}
296
+ {active==='browser'&&features.browser&&contentVisible&&<BrowserPane key={task.id} taskId={task.id} onPage={setBrowserPage} onQuote={quote?page=>quote({id:`browser:${task.id}`,sourceTaskId:task.id,author:"浏览器页面",text:`${page.title?`${page.title} — `:""}${page.url}`}):undefined} onQuoteText={quote?(text,page)=>quote({id:`browser-selection:${task.id}`,sourceTaskId:task.id,author:`网页 ${page.title||page.url}`,text}):undefined}/>}
258
297
  </div>
259
298
  </aside>;
260
299
  }