@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.
- package/CHANGELOG.md +33 -1
- package/README.md +79 -20
- package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
- package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-BmwOLWFI.js} +2 -2
- package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
- package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
- package/dist/{cli-ui-xlsx-DEDdHrJJ.js → cli-ui-xlsx-Sl7ypvSe.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +3 -1
- package/src/board/CliBoard.tsx +128 -100
- package/src/board/FileViewer.tsx +259 -132
- package/src/board/FilesPane.tsx +129 -40
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +19 -7
- package/src/board/fileKind.ts +138 -0
- package/src/board/fileOps.ts +9 -2
- package/src/board/fileScope.ts +29 -0
- package/src/board/fileSync.ts +343 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +71 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +177 -76
- package/src/board/folderDownload.ts +39 -5
- package/src/board/frameQuote.ts +10 -8
- package/src/board/htmlReading.ts +148 -0
- package/src/board/textLoad.ts +14 -39
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +38 -6
- package/src/chat/CliChat.tsx +86 -46
- package/src/chat/Composer.tsx +52 -20
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +132 -42
- package/src/chat/draft.ts +5 -2
- package/src/core/blocks.ts +2 -1
- package/src/core/client.ts +44 -57
- package/src/core/documentUpload.ts +196 -0
- package/src/core/store.ts +71 -14
- package/src/core/types.ts +1 -1
- package/src/index.ts +3 -0
- package/src/msglist/CliMsgList.tsx +9 -2
- package/src/operator/node/Chat.tsx +7 -8
- package/src/operator/node/Composer.tsx +99 -72
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +71 -32
- package/src/operator/node/SessionView.tsx +25 -25
- package/src/operator/node/Transcript.tsx +21 -9
- package/src/operator/node/boardBackend.ts +0 -19
- package/src/operator/node/boardEvents.ts +21 -3
- package/src/operator/node/boardState.ts +18 -1
- package/src/operator/node/boardWorkspace.ts +4 -1
- package/src/operator/node/composerDraft.ts +6 -2
- package/src/operator/node/format.ts +4 -2
- package/src/operator/node/liveStream.ts +14 -2
- package/src/operator/node/useTaskStream.ts +3 -1
- package/src/operator.base.css +141 -38
- package/src/operator.css +141 -38
- package/src/styles.css +248 -34
- package/src/ui/Markdown.tsx +2 -2
- package/src/ui/Popover.tsx +25 -12
- package/src/ui/QueuedMessages.tsx +69 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/TextBlock.tsx +6 -4
- package/src/ui/Transcript.tsx +20 -10
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/format.ts +3 -1
- package/src/ui/reference.ts +40 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
- 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
|
|
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
|
-
|
|
63
|
+
useLayoutEffect(() => { uploadScope.current = { taskId: task.id, openBoard: parentBoard ?? openOperatorBoard }; }, [task.id, parentBoard]);
|
|
64
|
+
useLayoutEffect(() => {
|
|
52
65
|
mounted.current = true;
|
|
53
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
const isImage =
|
|
114
|
-
if (!
|
|
115
|
-
if (f.size >
|
|
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
|
-
|
|
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
|
|
162
|
-
const
|
|
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),...(
|
|
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
|
|
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=
|
|
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), ...(
|
|
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("
|
|
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("
|
|
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));
|
|
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
|
-
{
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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=
|
|
269
|
-
<div className="comp-control"><button className=
|
|
270
|
-
{addOpen && <Popover onClose={() => setAddOpen(false)}>
|
|
271
|
-
<
|
|
272
|
-
<button className="pop-item"
|
|
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 ? "本轮结束后发送" : "
|
|
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-
|
|
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:"
|
|
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,
|
|
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
|
|
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:
|
|
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)
|
|
263
|
+
if(active===id){const next=shown.find(item=>item!==id)??'';setTab(next);if(!next)inlineLayout?.onChat();}
|
|
230
264
|
};
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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'
|
|
247
|
-
|
|
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
|
-
|
|
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
|
}
|