@spzxg/cli-ui 0.6.6 → 0.6.8
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 +40 -1
- package/README.md +90 -21
- package/dist/cli-ui-embed-Be9_d9jB.js +49691 -0
- package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-CzNfiynu.js} +2 -2
- package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-BL0mSekN.js} +2 -2
- package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-DjCIAfse.js} +1 -1
- package/dist/{cli-ui-xlsx-DEDdHrJJ.js → cli-ui-xlsx-CscI4xk0.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 +121 -51
- package/src/chat/Composer.tsx +54 -22
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +143 -46
- package/src/chat/draft.ts +12 -4
- package/src/chat/useExecutionConfig.ts +93 -0
- package/src/components.tsx +1 -1
- package/src/core/blocks.ts +2 -1
- package/src/core/client.ts +65 -61
- package/src/core/config.ts +6 -2
- package/src/core/documentUpload.ts +196 -0
- package/src/core/execution.ts +24 -0
- package/src/core/store.ts +81 -20
- package/src/core/types.ts +3 -1
- package/src/index.ts +4 -0
- package/src/msglist/CliMsgList.tsx +9 -2
- package/src/operator/node/Chat.tsx +11 -9
- package/src/operator/node/Composer.tsx +103 -75
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +71 -32
- package/src/operator/node/SessionView.tsx +31 -28
- 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 +150 -43
- package/src/operator.css +150 -43
- package/src/styles.css +253 -39
- 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
|
@@ -1,26 +1,35 @@
|
|
|
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, fileUpload = true }: {
|
|
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;
|
|
30
|
+
fileUpload?: boolean;
|
|
21
31
|
}) {
|
|
22
32
|
const boardView=useBoardState(task.id);
|
|
23
|
-
const [includeBoard,setIncludeBoard]=useState(true);
|
|
24
33
|
const [draft, setDraft] = useState(() => readDraft(task.id));
|
|
25
34
|
const draftRef = useRef(draft);
|
|
26
35
|
const [addOpen, setAddOpen] = useState(false);
|
|
@@ -40,6 +49,10 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
|
|
|
40
49
|
const caretRequest = useRef<number | null>(null);
|
|
41
50
|
const composing = useRef(false);
|
|
42
51
|
const uploads = useRef(new Map<string, AbortController>());
|
|
52
|
+
const uploadFiles = useRef(new Map<string, File>());
|
|
53
|
+
const backend = useMemo(() => getOperatorBoardBackend(), []);
|
|
54
|
+
const parentBoard = useContext(OperatorBoardContext);
|
|
55
|
+
const uploadScope = useRef({ taskId: task.id, openBoard: parentBoard ?? openOperatorBoard });
|
|
43
56
|
const previews = useRef(new Set<string>());
|
|
44
57
|
const mounted = useRef(true);
|
|
45
58
|
const update = (fn: (prev: ComposerDraft) => ComposerDraft) => {
|
|
@@ -48,9 +61,12 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
|
|
|
48
61
|
writeDraft(task.id, next);
|
|
49
62
|
if (mounted.current) setDraft(next);
|
|
50
63
|
};
|
|
51
|
-
|
|
64
|
+
useLayoutEffect(() => { uploadScope.current = { taskId: task.id, openBoard: parentBoard ?? openOperatorBoard }; }, [task.id, parentBoard]);
|
|
65
|
+
useLayoutEffect(() => {
|
|
52
66
|
mounted.current = true;
|
|
53
|
-
const
|
|
67
|
+
const current = readDraft(task.id);
|
|
68
|
+
draftRef.current = current; setDraft(current);
|
|
69
|
+
const pending = uploads.current, urls = previews.current, files = uploadFiles.current;
|
|
54
70
|
const sync = (event: Event) => {
|
|
55
71
|
const change = (event as CustomEvent<{ id: string; draft?: ComposerDraft; sending?: boolean }>).detail;
|
|
56
72
|
if (change.id !== task.id) return;
|
|
@@ -69,28 +85,25 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
|
|
|
69
85
|
const current = readDraft(task.id);
|
|
70
86
|
writeDraft(task.id, { ...current, attachments: current.attachments.map((attachment) =>
|
|
71
87
|
interrupted.has(attachment.key) && attachment.status === "uploading"
|
|
72
|
-
? { ...attachment, preview: "", status: "error", error: "
|
|
88
|
+
? { ...attachment, preview: "", status: "error", error: "上传已中断,请重新选择文件。" }
|
|
73
89
|
: attachment) });
|
|
74
90
|
}
|
|
75
91
|
pending.clear();
|
|
92
|
+
files.clear();
|
|
76
93
|
urls.forEach((url) => URL.revokeObjectURL(url));
|
|
77
94
|
urls.clear();
|
|
78
95
|
};
|
|
79
|
-
}, []);
|
|
96
|
+
}, [task.id]);
|
|
80
97
|
useEffect(() => { if (!running) { setStopping(false); stoppingRef.current = false; } }, [running]);
|
|
81
98
|
useEffect(() => {
|
|
82
99
|
if (!quoteRequest) return;
|
|
83
100
|
update((d) => ({ ...d, quote: quoteRequest }));
|
|
84
|
-
ref.current?.focus();
|
|
101
|
+
ref.current?.focus({ preventScroll: true });
|
|
85
102
|
onQuoteHandled();
|
|
86
103
|
// Each request is a new object even when quoting the same message again.
|
|
87
104
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
88
105
|
}, [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]);
|
|
106
|
+
useComposerAutosize(ref, draft.text);
|
|
94
107
|
// A menu action hands keyboard control straight to the edited text. Deferring
|
|
95
108
|
// focus to rAF leaves a visible mention list whose first Escape/arrow is lost.
|
|
96
109
|
useLayoutEffect(() => {
|
|
@@ -103,38 +116,56 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
|
|
|
103
116
|
|
|
104
117
|
const sharedConfigBusy = useConfigurationBusy(task.id);
|
|
105
118
|
const locked = disabled || sending || configBusy || sharedConfigBusy;
|
|
119
|
+
const startUpload = (key: string, file: File) => {
|
|
120
|
+
if (!fileUpload || !backend.uploadFile || locked || uploads.current.has(key)) return;
|
|
121
|
+
const taskId = task.id;
|
|
122
|
+
const preview = draftRef.current.attachments.find(a => a.key === key)?.preview;
|
|
123
|
+
const controller = new AbortController();
|
|
124
|
+
uploads.current.set(key, controller);
|
|
125
|
+
uploadFiles.current.set(key, file);
|
|
126
|
+
const current = () => mounted.current && uploadScope.current.taskId === taskId && uploads.current.get(key) === controller && !controller.signal.aborted && draftRef.current.attachments.some(a => a.key === key);
|
|
127
|
+
update(d => ({ ...d, attachments: d.attachments.map(a => a.key === key ? { ...a, status: "uploading", phase: "queued", bytes: 0, size: file.size, error: undefined } : a) }));
|
|
128
|
+
void Promise.resolve().then(() => backend.uploadFile!(taskId, file, {
|
|
129
|
+
draftId: key, signal: controller.signal,
|
|
130
|
+
onPhase: phase => { if (current()) update(d => ({ ...d, attachments: d.attachments.map(a => a.key === key ? { ...a, phase } : a) })); },
|
|
131
|
+
onBytes: bytes => { if (current()) update(d => ({ ...d, attachments: d.attachments.map(a => a.key === key ? { ...a, bytes } : a) })); },
|
|
132
|
+
})).then(receipt => {
|
|
133
|
+
if (!current()) return;
|
|
134
|
+
if (!receipt.path) throw new Error("文件尚未进入当前工作区,请稍后重试。");
|
|
135
|
+
const path = receipt.path;
|
|
136
|
+
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) }));
|
|
137
|
+
uploadFiles.current.delete(key);
|
|
138
|
+
uploadScope.current.openBoard({ kind: "file", taskId, path });
|
|
139
|
+
}).catch((e: unknown) => {
|
|
140
|
+
if (current()) update(d => ({ ...d, attachments: d.attachments.map(a => a.key === key ? { ...a, status: "error", error: e instanceof Error ? e.message : "上传失败,请重试。" } : a) }));
|
|
141
|
+
}).finally(() => {
|
|
142
|
+
if (uploads.current.get(key) === controller) uploads.current.delete(key);
|
|
143
|
+
if (preview && previews.current.delete(preview)) URL.revokeObjectURL(preview);
|
|
144
|
+
});
|
|
145
|
+
};
|
|
106
146
|
const addFiles = (files: File[]) => {
|
|
107
|
-
if (locked) return;
|
|
147
|
+
if (locked || !fileUpload) return;
|
|
108
148
|
setError("");
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
const isImage =
|
|
114
|
-
if (!
|
|
115
|
-
if (f.size >
|
|
149
|
+
if (!backend.uploadFile) { setError("当前服务暂不支持附件上传。"); return; }
|
|
150
|
+
for (const f of files) {
|
|
151
|
+
const type = uploadFileType(f.name);
|
|
152
|
+
const document = isOriginalDocument(f.name);
|
|
153
|
+
const isImage = !!type?.startsWith("image/");
|
|
154
|
+
if (!type) { setError(`“${f.name}”暂不支持,请选择图片、文档、表格或文本文件。`); continue; }
|
|
155
|
+
if (f.size > (document ? DOCUMENT_MAX_BYTES : UPLOAD_OTHER_MAX_BYTES)) { setError(`“${f.name}”超过 ${document ? "100 MiB" : "10 MiB"},请缩小后重试。`); continue; }
|
|
116
156
|
const key = clientId();
|
|
117
157
|
const preview = isImage ? URL.createObjectURL(f) : "";
|
|
118
158
|
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); } });
|
|
159
|
+
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 }] }));
|
|
160
|
+
startUpload(key, f);
|
|
134
161
|
}
|
|
135
162
|
};
|
|
136
163
|
const removeAtt = (key: string) => {
|
|
137
164
|
uploads.current.get(key)?.abort();
|
|
165
|
+
uploads.current.delete(key);
|
|
166
|
+
uploadFiles.current.delete(key);
|
|
167
|
+
const preview = draftRef.current.attachments.find(a => a.key === key)?.preview;
|
|
168
|
+
if (preview && previews.current.delete(preview)) URL.revokeObjectURL(preview);
|
|
138
169
|
update((d) => ({ ...d, attachments: d.attachments.filter((a) => a.key !== key) }));
|
|
139
170
|
};
|
|
140
171
|
const sources = draft.sources ?? [];
|
|
@@ -158,12 +189,12 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
|
|
|
158
189
|
};
|
|
159
190
|
const uploading = draft.attachments.some((a) => a.status === "uploading");
|
|
160
191
|
const failed = draft.attachments.some((a) => a.status === "error");
|
|
161
|
-
const
|
|
162
|
-
const
|
|
192
|
+
const readyAttachments = draft.attachments.filter(a => a.status === "ready");
|
|
193
|
+
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
194
|
const isClear=draft.text.trim()==='/clear'&&!draft.attachments.length&&!draft.quote;
|
|
164
|
-
const currentSignature=JSON.stringify({payload:ownPayload,ids:sources.map(source=>source.id),...(
|
|
195
|
+
const currentSignature=JSON.stringify({payload:ownPayload,ids:sources.map(source=>source.id),...(boardView?.autoContext===false?{withoutBoardContext:true}:{})});
|
|
165
196
|
const pendingFork=sources.length&&draft.forkRequest?.signature===currentSignature?draft.forkRequest:null;
|
|
166
|
-
const payload=pendingFork?pendingFork.payload??ownPayload:ownPayload&&!isClear
|
|
197
|
+
const payload=pendingFork?pendingFork.payload??ownPayload:ownPayload&&!isClear?appendBoardContext(ownPayload,boardView,task.id):ownPayload;
|
|
167
198
|
const tooLong = payload.length > MAX_MESSAGE_CHARS;
|
|
168
199
|
const canSend = !!payload && !uploading && !failed && !tooLong && !locked;
|
|
169
200
|
const clear = () => {
|
|
@@ -177,23 +208,23 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
|
|
|
177
208
|
let submittedDraft = draftRef.current;
|
|
178
209
|
let requestId = "";
|
|
179
210
|
const currentView=readBoardState(task.id);
|
|
180
|
-
let submittedPayload=
|
|
211
|
+
let submittedPayload=appendBoardContext(ownPayload,currentView,task.id);
|
|
181
212
|
if (sources.length) {
|
|
182
|
-
const signature = JSON.stringify({ payload:ownPayload, ids: sources.map((s) => s.id), ...(
|
|
213
|
+
const signature = JSON.stringify({ payload:ownPayload, ids: sources.map((s) => s.id), ...(currentView?.autoContext===false?{withoutBoardContext:true}:{}) });
|
|
183
214
|
const previous=submittedDraft.forkRequest?.signature===signature?submittedDraft.forkRequest:null;
|
|
184
215
|
requestId=previous?.id??clientId();
|
|
185
216
|
// Once dispatched, retries must keep the exact payload as well as the id.
|
|
186
217
|
// A passive page/file change is not a new user request or a new branch.
|
|
187
218
|
if(previous)submittedPayload=previous.payload??ownPayload;
|
|
188
|
-
if(submittedPayload.length>MAX_MESSAGE_CHARS){setError("
|
|
219
|
+
if(submittedPayload.length>MAX_MESSAGE_CHARS){setError("消息和看板背景合计超过长度限制,请精简消息内容后重试。");return;}
|
|
189
220
|
update((d) => ({ ...d, forkRequest: { id: requestId, signature, payload:submittedPayload } }));
|
|
190
221
|
submittedDraft = draftRef.current;
|
|
191
222
|
}
|
|
192
|
-
if(submittedPayload.length>MAX_MESSAGE_CHARS){setError("
|
|
223
|
+
if(submittedPayload.length>MAX_MESSAGE_CHARS){setError("消息和看板背景合计超过长度限制,请精简消息内容后重试。");return;}
|
|
193
224
|
sendingRef.current = true; setTaskSending(task.id, true); setSending(true); setError("");
|
|
194
225
|
try {
|
|
195
226
|
if (await (sources.length ? onFork(submittedPayload, sources.map((s) => s.id), requestId) : onSend(submittedPayload))) {
|
|
196
|
-
if (mounted.current) {update(current=>acknowledgeDraft(current,submittedDraft));
|
|
227
|
+
if (mounted.current) {update(current=>acknowledgeDraft(current,submittedDraft));}
|
|
197
228
|
else writeDraft(task.id,acknowledgeDraft(readDraft(task.id),submittedDraft));
|
|
198
229
|
}
|
|
199
230
|
else if (mounted.current) setError("发送失败,草稿已保留,请重试。");
|
|
@@ -217,34 +248,24 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
|
|
|
217
248
|
</div>}
|
|
218
249
|
|
|
219
250
|
{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>}
|
|
251
|
+
{queued}
|
|
220
252
|
<div className={`comp-box${dragging ? " dropping" : ""}`}
|
|
221
|
-
onDragEnter={(e) => { if (hasFileDrag(e)) { e.preventDefault(); if (!locked) { dragDepth.current++; setDragging(true); } } }}
|
|
253
|
+
onDragEnter={(e) => { if (hasFileDrag(e)) { e.preventDefault(); if (fileUpload && !locked) { dragDepth.current++; setDragging(true); } } }}
|
|
222
254
|
onDragOver={(e) => { if (hasFileDrag(e)) e.preventDefault(); }}
|
|
223
255
|
onDragLeave={() => { if (dragDepth.current > 0 && --dragDepth.current === 0) setDragging(false); }}
|
|
224
256
|
onDrop={(e) => { if (!hasFileDrag(e)) return; e.preventDefault(); dragDepth.current = 0; setDragging(false); addFiles(Array.from(e.dataTransfer.files)); }}>
|
|
225
257
|
{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}>
|
|
258
|
+
{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} />}
|
|
259
|
+
{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}
|
|
260
|
+
status={a.status === "error" ? "error" : a.phase ?? "queued"} bytes={a.bytes} total={a.size} disabled={locked}
|
|
261
|
+
error={`${a.error || "上传失败,请重试。"}${uploadFiles.current.has(a.key) || a.error?.includes("重新选择文件") ? "" : " 请重新选择文件。"}`}
|
|
262
|
+
onCancel={() => removeAtt(a.key)} onRemove={() => removeAtt(a.key)} onRetry={fileUpload && uploadFiles.current.has(a.key) ? () => startUpload(a.key, uploadFiles.current.get(a.key)!) : undefined} />)}</div>}
|
|
263
|
+
{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
264
|
{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
265
|
<button className="attach-x" disabled={locked} aria-label={`移除 ${a.name}`} onClick={() => removeAtt(a.key)}>×</button>
|
|
245
266
|
</div>)}</div>}
|
|
246
267
|
<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}
|
|
268
|
+
placeholder={configBusy ? "正在保存设置…" : disabled ? "启动会话后即可回复…" : sources.length ? "描述这些新分支要继续完成的工作…" : running ? "补充下一步,当前轮次结束后发送…" : `和 ${agentLabel} 一起工作`}
|
|
248
269
|
onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}
|
|
249
270
|
onChange={(e) => { const text = e.target.value; update((d) => ({ ...d, text })); setSlash(text.trim().startsWith("/")); setCaret(e.target.selectionStart); setMentionClosed(false); setMentionIndex(0); }}
|
|
250
271
|
onSelect={(e) => setCaret(e.currentTarget.selectionStart)}
|
|
@@ -262,25 +283,32 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
|
|
|
262
283
|
const files = Array.from(e.clipboardData.files);
|
|
263
284
|
if (files.length) { e.preventDefault(); addFiles(files); return; }
|
|
264
285
|
const text = e.clipboardData.getData("text/plain");
|
|
265
|
-
if (text.length > PASTE_ATTACH_THRESHOLD) { e.preventDefault(); addFiles([new File([text], "pasted-text.txt", { type: "text/plain" })]); }
|
|
286
|
+
if (fileUpload && text.length > PASTE_ATTACH_THRESHOLD) { e.preventDefault(); addFiles([new File([text], "pasted-text.txt", { type: "text/plain" })]); }
|
|
266
287
|
}} /></div>
|
|
267
288
|
<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"
|
|
289
|
+
{fileUpload && <input ref={fileRef} type="file" accept={FILE_UPLOAD_ACCEPT} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />}
|
|
290
|
+
<div className="comp-control"><button className={`comp-add${addOpen ? " on" : ""}`} disabled={locked} title={fileUpload ? "附件、派生任务与新上下文" : "派生任务与新上下文"} aria-label="添加内容" aria-haspopup="dialog" aria-expanded={addOpen} onClick={(e) => { e.stopPropagation(); setAddOpen(!addOpen); }}>{Icon.plus()}</button>
|
|
291
|
+
{addOpen && <Popover className="composer-popover composer-add-popover" ariaLabel="添加内容" placement="top" align="start" onClose={() => setAddOpen(false)}>
|
|
292
|
+
<div className="composer-menu-heading">添加内容</div>
|
|
293
|
+
{fileUpload && <button className="pop-item composer-option" disabled={locked} onClick={() => { setAddOpen(false); fileRef.current?.click(); }}>
|
|
294
|
+
<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>
|
|
295
|
+
</button>}
|
|
296
|
+
<button className="pop-item composer-option" disabled={locked} onClick={openMention}>
|
|
297
|
+
<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>
|
|
298
|
+
</button>
|
|
299
|
+
<button className="pop-item composer-option" disabled={running || locked || task.started !== 1} onClick={() => { setAddOpen(false); clear(); }}>
|
|
300
|
+
<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>
|
|
301
|
+
</button>
|
|
273
302
|
</Popover>}
|
|
274
303
|
</div>
|
|
275
304
|
<RunControls {...controls} disabled={controls.disabled || sending} />
|
|
276
305
|
<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>}
|
|
306
|
+
{(!running || !!payload || sending) && <button type="button" className={`send${running ? " queue" : ""}`} disabled={!canSend} aria-busy={sending} aria-label={sending ? "正在发送" : sources.length ? `派生 ${sources.length} 个分支` : running ? "排队发送" : "发送消息"} title={sending ? "发送中…" : running ? "本轮结束后发送" : "发送消息(Enter)"} onClick={() => void submit()}>{sending ? <span className="spinner" /> : Icon.send()}</button>}
|
|
307
|
+
{running && <button type="button" className="send stop" disabled={stopping} aria-busy={stopping} aria-label={stopping ? "正在停止" : "停止当前轮次"} title={stopping ? "正在停止…" : "停止当前轮次"} onClick={() => void stop()}>{stopping ? <span className="spinner" /> : Icon.stop()}</button>}
|
|
279
308
|
</div>
|
|
280
309
|
</div>
|
|
281
310
|
</div>
|
|
282
|
-
<div className="comp-
|
|
283
|
-
{(error || failed || tooLong) && <div className="comp-error" role="alert">{error || (tooLong ? "消息过长,请将部分内容作为文本附件添加。" : "请移除上传失败的附件后重新添加,再发送。")}</div>}
|
|
311
|
+
{(error || failed || tooLong) && <div className="comp-error" role="alert">{error || (tooLong ? "消息过长,请将部分内容作为文本附件添加。" : "请重试或移除上传失败的附件,再发送。")}</div>}
|
|
284
312
|
{uploading && <div className="comp-upload-status" role="status">正在上传附件,完成后可发送…</div>}
|
|
285
313
|
</div>
|
|
286
314
|
</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
|
}
|