@spzxg/cli-ui 0.6.5 → 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/README.md +109 -30
- package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
- package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-BmwOLWFI.js} +2 -2
- package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
- package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
- package/dist/{cli-ui-xlsx-zs7oEtPj.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 +11 -8
- package/src/board/BrowserPane.tsx +198 -53
- package/src/board/CliBoard.tsx +128 -100
- package/src/board/FileViewer.tsx +276 -136
- 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 -70
- package/src/board/folderDownload.ts +39 -5
- package/src/board/frameQuote.ts +12 -10
- package/src/board/htmlReading.ts +148 -0
- package/src/board/textLoad.ts +18 -41
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +53 -11
- package/src/chat/CliChat.tsx +105 -55
- package/src/chat/Composer.tsx +55 -21
- 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 +17 -5
- package/src/core/client.ts +88 -19
- package/src/core/conversation.ts +1 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/provider.tsx +7 -0
- package/src/core/store.ts +134 -36
- package/src/core/types.ts +31 -10
- package/src/index.ts +3 -0
- package/src/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +18 -5
- 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 +28 -11
- package/src/operator/node/boardBackend.ts +6 -23
- 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/protocol/types.ts +2 -2
- package/src/operator/node/useTaskStream.ts +8 -5
- package/src/operator.base.css +148 -37
- package/src/operator.css +148 -37
- package/src/styles.css +274 -32
- package/src/ui/Markdown.tsx +2 -2
- package/src/ui/Popover.tsx +32 -14
- 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 +27 -12
- 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-BmLN2Jjg.js +0 -43290
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// cli-msglist:消息对话列表。界面取自 Operator 的任务栏(TasksColumn 的列表视图,不做看板),
|
|
2
2
|
// 一张卡片是 center 里的一个对话任务(mode=conversation)。对话任务里开了几个对话时,卡片下面按对话展开,
|
|
3
3
|
// 点哪个对话选哪个(选中值是对话键 "job#task");卡片上的「+」在这个对话任务里新开一个对话(同一个工作区)。
|
|
4
|
-
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
|
4
|
+
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
5
5
|
import { CliErrorBoundary } from "../ui/ErrorBoundary";
|
|
6
6
|
import type { CliEvent, JobEntry } from "../core/store";
|
|
7
7
|
import type { CliStoreOptions, CliStore } from "../core/store";
|
|
@@ -12,6 +12,7 @@ import { Icon } from "../ui/icons";
|
|
|
12
12
|
import { agentLabel, relTime, toMs } from "../ui/format";
|
|
13
13
|
import { AgentBadge, ErrNote, Skel, StatusDot, type DotCls } from "../ui/shared";
|
|
14
14
|
import { resolveMsgListConfig, type CliMsgListConfig, type ResolvedMsgListConfig } from "../core/config";
|
|
15
|
+
import { useBoardVisibility } from "../board/useBoardVisibility";
|
|
15
16
|
|
|
16
17
|
/** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
|
|
17
18
|
export interface CliMsgListProps extends CliStoreOptions, ResultHandlers {
|
|
@@ -59,6 +60,9 @@ export function CliMsgList(props: CliMsgListProps) {
|
|
|
59
60
|
|
|
60
61
|
const SEARCH_MIN = 6;
|
|
61
62
|
const BATCH_ACTIVE = new Set(["dispatching", "running", "verifying"]);
|
|
63
|
+
// An orphaned task can retain a fresh, authoritative human-wait observation
|
|
64
|
+
// while the center is reconciling its execution state. Do not call it running.
|
|
65
|
+
const BATCH_AWAITING = new Set([...BATCH_ACTIVE, "orphaned"]);
|
|
62
66
|
|
|
63
67
|
interface CardView {
|
|
64
68
|
id: string;
|
|
@@ -95,9 +99,11 @@ export function cardView(id: string, entry: JobEntry | undefined): CardView {
|
|
|
95
99
|
const label = cancelled ? "已取消" : failed ? "已失败" : "已完成";
|
|
96
100
|
return { ...base, cls: cancelled || failed ? "x" : "g", label, running: false, awaiting: false, group: cancelled ? "cancelled" : "done", activity: updatedAt ? `${label} · ${relTime(updatedAt)}` : label };
|
|
97
101
|
}
|
|
98
|
-
//
|
|
102
|
+
// A browser handoff/input card keeps its turn running while requiring a human.
|
|
99
103
|
if (batch) {
|
|
100
104
|
const running = (job?.tasks ?? []).some((task) => BATCH_ACTIVE.has(task.status));
|
|
105
|
+
const awaiting = (job?.tasks ?? []).some((task) => BATCH_AWAITING.has(task.status) && task.awaiting_input === true);
|
|
106
|
+
if (awaiting) return { ...base, cls: "a", label: "需要你处理", running, awaiting, group: "needs", activity: "等你处理" };
|
|
101
107
|
return { ...base, cls: "a", label: "进行中", running, awaiting: false, group: "active", activity: running ? "运行中 · 工作中" : status === "queued" ? "等待执行节点" : "进行中" };
|
|
102
108
|
}
|
|
103
109
|
// 开了几个对话:任何一个在跑就算在跑,任何一个停在提问上就要你处理(关掉的对话不算)。
|
|
@@ -105,7 +111,7 @@ export function cardView(id: string, entry: JobEntry | undefined): CardView {
|
|
|
105
111
|
const many = (conv?.conversations?.length ?? 0) > 1;
|
|
106
112
|
const running = many ? open.some((c) => c.running_turn) : !!conv?.running_turn;
|
|
107
113
|
// 上一轮以提问结束、后面没有新消息:等你回答。
|
|
108
|
-
const awaiting = many ? open.some((c) => !c.running_turn && !c.queued_turns && !!c.last_turn?.asked) : !running && !conv?.queued_turns && !!conv?.last_turn?.asked;
|
|
114
|
+
const awaiting = many ? open.some((c) => c.awaiting_input === true || (!c.running_turn && !c.queued_turns && !!c.last_turn?.asked)) : conv?.awaiting_input === true || (!running && !conv?.queued_turns && !!conv?.last_turn?.asked);
|
|
109
115
|
if (awaiting) return { ...base, cls: "a", label: "需要你处理", running, awaiting, group: "needs", activity: updatedAt ? `等你处理 · ${relTime(updatedAt)}` : "等你处理" };
|
|
110
116
|
if (running) return { ...base, cls: "a", label: "进行中", running, awaiting, group: "active", activity: "运行中 · 工作中" };
|
|
111
117
|
const waiting = conv?.queued_turns ? `${conv.queued_turns} 条排队中` : status === "queued" ? "等待执行节点" : updatedAt ? relTime(updatedAt) : "未开始";
|
|
@@ -143,6 +149,7 @@ export function convRows(jobId: string, entry: JobEntry | undefined): ConvRowVie
|
|
|
143
149
|
if (tasks.length <= 1) return [];
|
|
144
150
|
return tasks.map((t) => {
|
|
145
151
|
const look = BATCH_ROW[t.status] ?? { cls: "a" as DotCls, label: t.status };
|
|
152
|
+
if (BATCH_AWAITING.has(t.status) && t.awaiting_input === true) return { key: convKey(jobId, t.id), title: t.title, cls: "a" as DotCls, label: "需要你处理", running: !!look.running, awaiting: true, activity: "等你处理", turns: 0 };
|
|
146
153
|
return { key: convKey(jobId, t.id), title: t.title, cls: look.cls, label: look.label, running: !!look.running, awaiting: false, activity: look.label, turns: 0 };
|
|
147
154
|
});
|
|
148
155
|
}
|
|
@@ -153,7 +160,7 @@ export function convRows(jobId: string, entry: JobEntry | undefined): ConvRowVie
|
|
|
153
160
|
const base = { key: convKey(jobId, c.task_id), title: c.title, turns: c.turns };
|
|
154
161
|
if (c.closed || jobEnded) return { ...base, cls: "g" as DotCls, label: "已结束", running: false, awaiting: false, activity: "已结束" };
|
|
155
162
|
const running = c.running_turn;
|
|
156
|
-
const awaiting = !running && !c.queued_turns && !!c.last_turn?.asked;
|
|
163
|
+
const awaiting = c.awaiting_input === true || (!running && !c.queued_turns && !!c.last_turn?.asked);
|
|
157
164
|
if (awaiting) return { ...base, cls: "a" as DotCls, label: "需要你处理", running, awaiting, activity: "等你处理" };
|
|
158
165
|
if (running) return { ...base, cls: "a" as DotCls, label: "进行中", running, awaiting, activity: "运行中" };
|
|
159
166
|
return { ...base, cls: "a" as DotCls, label: "进行中", running, awaiting, activity: c.queued_turns ? `${c.queued_turns} 条排队中` : "空闲" };
|
|
@@ -278,6 +285,12 @@ function MsgListInner({ jobIds, selectedId, onSelect, onNew, title = "对话", c
|
|
|
278
285
|
const cfg = useMemo(() => resolveMsgListConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
279
286
|
const h = cfg.header;
|
|
280
287
|
const store = useCliStore();
|
|
288
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
289
|
+
const owner = useId();
|
|
290
|
+
const visible = useBoardVisibility(rootRef, true);
|
|
291
|
+
const expanded = visible && !className?.split(/\s+/).includes("collapsed");
|
|
292
|
+
useLayoutEffect(() => { store.reportListPresence(owner, expanded); }, [store, owner, expanded]);
|
|
293
|
+
useLayoutEffect(() => () => { store.reportListPresence(owner, false); }, [store, owner]);
|
|
281
294
|
const ids = useCliState((s) => s.jobIds);
|
|
282
295
|
const jobs = useCliState((s) => s.jobs);
|
|
283
296
|
const shared = useCliState((s) => s.selected);
|
|
@@ -337,7 +350,7 @@ function MsgListInner({ jobIds, selectedId, onSelect, onNew, title = "对话", c
|
|
|
337
350
|
const showBanner = showRow || showContext;
|
|
338
351
|
|
|
339
352
|
return (
|
|
340
|
-
<div className={`cli-ui cli-msglist ${className ?? ""}`} data-theme={theme} style={{ ...style, ...(width !== undefined ? { width } : null) }}>
|
|
353
|
+
<div ref={rootRef} className={`cli-ui cli-msglist ${className ?? ""}`} data-theme={theme} style={{ ...style, ...(width !== undefined ? { width } : null) }}>
|
|
341
354
|
<div className="col col-tasks">
|
|
342
355
|
{showBanner && <div className="proj-banner">
|
|
343
356
|
{showRow && <div className="pb-row">
|
|
@@ -12,6 +12,8 @@ import { OperatorBoardContext } from "./boardInteraction";
|
|
|
12
12
|
import { toolDataOf } from "./transcriptModel";
|
|
13
13
|
import { FileLinkContext } from "./fileLinks";
|
|
14
14
|
import { Composer } from "./Composer";
|
|
15
|
+
import { QueuedMessages } from "../../ui/QueuedMessages";
|
|
16
|
+
import { useChatScrollbar } from "../../ui/useChatScrollbar";
|
|
15
17
|
import { BranchHistory, useBranchHistory } from "./BranchHistory";
|
|
16
18
|
import { BranchNavigator } from "./BranchNavigator";
|
|
17
19
|
import type { Quote } from "./composerDraft";
|
|
@@ -76,6 +78,7 @@ export function Chat(props: ChatProps) { return <ChatSession key={props.task.id}
|
|
|
76
78
|
function ChatSession({ project, task, tasks, agents, messages, running, transcriptLoading, older=0, onLoadOlder,
|
|
77
79
|
controls, configBusy, active=true, onSelectTask, onFork, onSend, onStop, onClear, onReconnect, onAnswer, onCancelQueued }: ChatProps) {
|
|
78
80
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
81
|
+
useChatScrollbar(scrollRef, task.id);
|
|
79
82
|
const [quoteRequest, setQuoteRequest] = useState<Quote|null>(null);
|
|
80
83
|
const flow=useBranchHistory(task,tasks);
|
|
81
84
|
const fileScope=useMemo(()=>({taskId:task.id}),[task.id]);
|
|
@@ -83,7 +86,7 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
|
|
|
83
86
|
const view=active?'chat':'changes';
|
|
84
87
|
const quote=(m: Msg, source: TaskRow=task, selected?:string)=>{
|
|
85
88
|
const text=selected||splitAttachments(m.content).text;
|
|
86
|
-
if(text.trim()) setQuoteRequest({id:m.id, author:`${m.role==='user'?'你':agentLabel(agents,source.agent)}${source.id===task.id?'':` · ${source.title}`}`,text});
|
|
89
|
+
if(text.trim()) setQuoteRequest({id:m.id, author:`${m.role==='user'?'你':agentLabel(agents,source.agent)}${source.id===task.id?'':` · ${source.title}`}`,text,sourceTaskId:source.id});
|
|
87
90
|
};
|
|
88
91
|
const openBoard=useContext(OperatorBoardContext);
|
|
89
92
|
const handoffs=useRef<Set<string>|null>(null);
|
|
@@ -414,7 +417,7 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
|
|
|
414
417
|
// window sheds rows above. Layout effect, so it lands before paint (no jump).
|
|
415
418
|
useLayoutEffect(() => {
|
|
416
419
|
if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
417
|
-
}, [messages, running, startIdx, endIdx]);
|
|
420
|
+
}, [messages, running, startIdx, endIdx, win]);
|
|
418
421
|
// …and when the pane itself resizes (the composer growing, a banner
|
|
419
422
|
// appearing). Content resizes are left alone, so expanding a tool card at
|
|
420
423
|
// the bottom doesn't yank its top out of view.
|
|
@@ -488,11 +491,6 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
|
|
|
488
491
|
{running && !awaitingAnswer && !hasBelow && (
|
|
489
492
|
<RunningIndicator agent={task.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />
|
|
490
493
|
)}
|
|
491
|
-
{/* Follow-ups queued mid-turn, pinned below the live turn — they
|
|
492
|
-
send in order once it ends. */}
|
|
493
|
-
{!hasBelow && messages.filter((m) => m.role === "queued").map((m) => (
|
|
494
|
-
<MessageView key={m.id} m={m} initial={false} hideWho={false} onAnswer={stableAnswer} onCancelQueued={stableCancelQueued} />
|
|
495
|
-
))}
|
|
496
494
|
{!hasBelow && <BranchNavigator sourceId={task.id} projectId={project.id} tasks={tasks} completed={task.status==='done'} onSelect={onSelectTask} />}
|
|
497
495
|
</div>
|
|
498
496
|
</div>
|
|
@@ -510,7 +508,8 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
|
|
|
510
508
|
)}
|
|
511
509
|
</div>
|
|
512
510
|
</div>
|
|
513
|
-
<Composer tasks={tasks} onFork={onFork} task={task} agentLabel={agentLabel(agents, task.agent)} disabled={task.started!==1} configBusy={configBusy} running={running} onSend={onSend} onStop={onStop} onClear={onClear} quoteRequest={quoteRequest} onQuoteHandled={()=>setQuoteRequest(null)} controls={controls}
|
|
511
|
+
<Composer tasks={tasks} onFork={onFork} task={task} agentLabel={agentLabel(agents, task.agent)} disabled={task.started!==1} configBusy={configBusy} running={running} onSend={async text => { const sent = await onSend(text); if (sent && !running) scrollToBottom("auto"); return sent; }} onStop={onStop} onClear={onClear} quoteRequest={quoteRequest} onQuoteHandled={()=>setQuoteRequest(null)} controls={controls}
|
|
512
|
+
queued={<QueuedMessages scopeKey={task.id} messages={messages.filter(message => message.role === "queued")} onCancel={message => stableCancelQueued(message.id)} attachmentUrl={attachment => `/api/tasks/${encodeURIComponent(attachment.taskId)}/uploads/${encodeURIComponent(attachment.name)}`} renderMessage={message => <MessageView m={message} initial={false} hideWho readOnly />} />} />
|
|
514
513
|
</></FileLinkContext.Provider>
|
|
515
514
|
);
|
|
516
515
|
|
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
import { DOCUMENT_MAX_BYTES, FILE_UPLOAD_ACCEPT, isOriginalDocument, uploadFileType, UPLOAD_OTHER_MAX_BYTES } from "../../core/documentUpload";
|
|
4
5
|
import { Popover } from "./shared";
|
|
5
6
|
import type { TaskRow } from "./types";
|
|
6
7
|
import { Icon } from "./icons";
|
|
7
8
|
import { attachmentMarker, fileAttachmentMarker } from "./format";
|
|
8
9
|
import { MAX_MESSAGE_CHARS, PASTE_ATTACH_THRESHOLD } from "./protocol/promptLimits";
|
|
9
10
|
import { RunControls, type RunControlProps } from "./RunControls";
|
|
10
|
-
import { appendBoardContext
|
|
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".
|