@spzxg/cli-ui 0.6.1 → 0.6.5
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 +26 -0
- package/README.md +145 -13
- package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
- package/dist/{cli-ui-index-BFpRZXn4.js → cli-ui-index-D4qfPHMd.js} +2 -2
- package/dist/{cli-ui-jszip.min-B9tqMA-e.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
- package/dist/{cli-ui-jszip.min-DIavJzUG.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
- package/dist/{cli-ui-xlsx-BKHhqcoa.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
- package/dist/cli-ui.css +32 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +14 -8
- package/src/board/BrowserPane.tsx +160 -74
- package/src/board/ChangesPane.tsx +2 -2
- package/src/board/CliBoard.tsx +79 -50
- package/src/board/FileViewer.tsx +32 -22
- package/src/board/FilesPane.tsx +32 -15
- package/src/board/PanelControls.tsx +28 -0
- package/src/board/blocks.tsx +5 -5
- package/src/board/context.ts +65 -0
- package/src/board/fileOps.ts +26 -3
- package/src/board/fileWatch.ts +88 -0
- package/src/board/folderDownload.ts +30 -14
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +2 -2
- package/src/board/workspace.ts +38 -9
- package/src/chat/CliChat.tsx +113 -53
- package/src/chat/Composer.tsx +68 -57
- package/src/chat/RunControls.tsx +99 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +41 -0
- package/src/chat/export.ts +2 -2
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +2 -2
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +16 -6
- package/src/core/config.ts +11 -15
- package/src/core/conversation.ts +4 -4
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +33 -0
- package/src/core/provider.tsx +24 -14
- package/src/core/store.ts +83 -26
- package/src/core/types.ts +34 -0
- package/src/elements.tsx +135 -29
- package/src/embed.ts +6 -8
- package/src/index.ts +24 -17
- package/src/layout/CliSplit.tsx +1 -1
- package/src/msglist/CliMsgList.tsx +10 -10
- package/src/operator/branchFlow.ts +35 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/liveStream.ts +1 -0
- package/src/operator/node/AgentConnect.tsx +624 -0
- package/src/operator/node/AppearancePanel.tsx +35 -0
- package/src/operator/node/BranchHistory.tsx +67 -0
- package/src/operator/node/BranchNavigator.tsx +36 -0
- package/src/operator/node/BrowserPane.tsx +128 -0
- package/src/operator/node/BrowserPopup.tsx +22 -0
- package/src/operator/node/Chat.tsx +517 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +287 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +190 -0
- package/src/operator/node/Markdown.tsx +61 -0
- package/src/operator/node/Modal.tsx +179 -0
- package/src/operator/node/NeedsYouMenu.tsx +54 -0
- package/src/operator/node/OnboardingWizard.tsx +250 -0
- package/src/operator/node/Orchestrator.tsx +634 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +561 -0
- package/src/operator/node/RunControls.tsx +2 -0
- package/src/operator/node/RunSwitch.tsx +113 -0
- package/src/operator/node/Services.tsx +245 -0
- package/src/operator/node/SessionRail.tsx +260 -0
- package/src/operator/node/SessionView.tsx +288 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +530 -0
- package/src/operator/node/TaskHero.tsx +50 -0
- package/src/operator/node/TasksColumn.tsx +200 -0
- package/src/operator/node/Terminal.tsx +143 -0
- package/src/operator/node/Transcript.tsx +463 -0
- package/src/operator/node/ViewFile.tsx +79 -0
- package/src/operator/node/Welcome.tsx +101 -0
- package/src/operator/node/WorktreePrune.tsx +333 -0
- package/src/operator/node/agents.ts +58 -0
- package/src/operator/node/api.ts +40 -0
- package/src/operator/node/boardBackend.ts +80 -0
- package/src/operator/node/boardEvents.ts +22 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +57 -0
- package/src/operator/node/boardWorkspace.ts +12 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +52 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +290 -0
- package/src/operator/node/github.tsx +234 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +270 -0
- package/src/operator/node/modals.tsx +720 -0
- package/src/operator/node/navHistory.ts +70 -0
- package/src/operator/node/persist.ts +26 -0
- package/src/operator/node/protocol/agents/optIn.ts +9 -0
- package/src/operator/node/protocol/agents/types.ts +394 -0
- package/src/operator/node/protocol/approvalFailure.ts +77 -0
- package/src/operator/node/protocol/authFailure.ts +66 -0
- package/src/operator/node/protocol/customModels.ts +23 -0
- package/src/operator/node/protocol/events.ts +13 -0
- package/src/operator/node/protocol/features.ts +70 -0
- package/src/operator/node/protocol/internalUsage.ts +1 -0
- package/src/operator/node/protocol/promptLimits.ts +52 -0
- package/src/operator/node/protocol/storageRetention.ts +13 -0
- package/src/operator/node/protocol/types.ts +332 -0
- package/src/operator/node/protocol/usageLimit.ts +72 -0
- package/src/operator/node/shared.tsx +89 -0
- package/src/operator/node/transcriptModel.ts +129 -0
- package/src/operator/node/types.ts +296 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +696 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +91 -0
- package/src/operator/node/useTaskStream.ts +279 -0
- package/src/operator.base.css +1798 -0
- package/src/operator.css +186 -16
- package/src/styles.css +51 -0
- package/src/ui/Markdown.tsx +7 -2
- package/src/ui/Popover.tsx +77 -0
- package/src/ui/TextBlock.tsx +3 -3
- package/src/ui/Transcript.tsx +10 -8
- package/src/ui/fileLinks.ts +6 -0
- package/src/ui/format.ts +1 -1
- package/src/ui/icons.tsx +1 -0
- package/src/ui/shared.tsx +1 -1
- package/src/ui/transcriptModel.ts +2 -2
- package/dist/cli-ui-embed-Dk2QGHxn.js +0 -41146
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { Icon } from "./icons";
|
|
5
|
+
import { jget } from "./api";
|
|
6
|
+
import type { PaletteTaskRow, ProjectRow } from "./types";
|
|
7
|
+
import { StatusDot } from "./shared";
|
|
8
|
+
|
|
9
|
+
// One executable command surfaced by the palette. Callers pass only the
|
|
10
|
+
// commands that currently make sense (e.g. no "Toggle Terminal" without a
|
|
11
|
+
// project selected), so the palette never renders a dead row.
|
|
12
|
+
export interface PaletteCommand {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
hint?: string; // right-aligned annotation, e.g. what state the toggle lands on
|
|
16
|
+
keywords?: string; // aliases the fuzzy matcher sees but the row doesn't show
|
|
17
|
+
icon: React.ReactNode;
|
|
18
|
+
run: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Subsequence fuzzy matcher. Exact-substring hits dominate (earlier = better,
|
|
22
|
+
// word-start better still); otherwise every query char must appear in order,
|
|
23
|
+
// with bonuses for word starts and consecutive runs and a small gap penalty.
|
|
24
|
+
// Returns -Infinity when the query doesn't match at all.
|
|
25
|
+
export function fuzzyScore(query: string, text: string): number {
|
|
26
|
+
const q = query.toLowerCase().trim();
|
|
27
|
+
if (!q) return 0;
|
|
28
|
+
const t = text.toLowerCase();
|
|
29
|
+
const sub = t.indexOf(q);
|
|
30
|
+
if (sub >= 0) return 1000 - sub + (sub === 0 || /[\s\-_/·.]/.test(t[sub - 1]) ? 50 : 0);
|
|
31
|
+
let score = 0;
|
|
32
|
+
let from = 0;
|
|
33
|
+
let prev = -2;
|
|
34
|
+
for (const ch of q) {
|
|
35
|
+
if (ch === " ") continue; // spaces in the query just separate words
|
|
36
|
+
const at = t.indexOf(ch, from);
|
|
37
|
+
if (at < 0) return -Infinity;
|
|
38
|
+
score += 1;
|
|
39
|
+
if (at === prev + 1) score += 2; // consecutive run
|
|
40
|
+
if (at === 0 || /[\s\-_/·.]/.test(t[at - 1])) score += 3; // word start
|
|
41
|
+
score -= Math.min(10, at - from) * 0.05;
|
|
42
|
+
prev = at;
|
|
43
|
+
from = at + 1;
|
|
44
|
+
}
|
|
45
|
+
return score;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type Entry =
|
|
49
|
+
| { kind: "project"; project: ProjectRow }
|
|
50
|
+
| { kind: "task"; task: PaletteTaskRow }
|
|
51
|
+
| { kind: "command"; command: PaletteCommand };
|
|
52
|
+
|
|
53
|
+
const entryKey = (e: Entry) =>
|
|
54
|
+
e.kind === "project" ? `p:${e.project.id}` : e.kind === "task" ? `t:${e.task.id}` : `c:${e.command.id}`;
|
|
55
|
+
|
|
56
|
+
// With no query the palette is a launcher (a few recent things + every command);
|
|
57
|
+
// once you type, it's a search (more room for matches per group).
|
|
58
|
+
const EMPTY_LIMITS = { project: 5, task: 6 };
|
|
59
|
+
const QUERY_LIMITS = { project: 6, task: 10 };
|
|
60
|
+
|
|
61
|
+
// The ⌘K command palette: fuzzy search over projects, sessions (tasks across
|
|
62
|
+
// ALL active projects) and commands, grouped, keyboard-first. The overlay is a
|
|
63
|
+
// centered top sheet over a scrim — Esc or an outside click dismisses, ↑/↓
|
|
64
|
+
// move, ⏎ runs the active row. Every color comes from theme vars, so it works
|
|
65
|
+
// in both themes for free.
|
|
66
|
+
export function CommandPalette({ projects, commands, onPickProject, onPickTask, onClose }: {
|
|
67
|
+
projects: ProjectRow[];
|
|
68
|
+
commands: PaletteCommand[];
|
|
69
|
+
onPickProject: (projectId: string) => void;
|
|
70
|
+
onPickTask: (projectId: string, taskId: string) => void;
|
|
71
|
+
onClose: () => void;
|
|
72
|
+
}) {
|
|
73
|
+
const [query, setQuery] = useState("");
|
|
74
|
+
const [active, setActive] = useState(0);
|
|
75
|
+
const [tasks, setTasks] = useState<PaletteTaskRow[]>([]);
|
|
76
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
77
|
+
|
|
78
|
+
// Sessions across every project are server state (the client only holds the
|
|
79
|
+
// selected project's tasks) — fetched fresh each open, like NeedsYouMenu.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
let alive = true;
|
|
82
|
+
jget<{ tasks: PaletteTaskRow[] }>("/api/tasks")
|
|
83
|
+
.then((d) => { if (alive) setTasks(d.tasks); })
|
|
84
|
+
.catch(() => {});
|
|
85
|
+
return () => { alive = false; };
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
const { sections, flat } = useMemo(() => {
|
|
89
|
+
const trimmed = query.trim();
|
|
90
|
+
const limits = trimmed ? QUERY_LIMITS : EMPTY_LIMITS;
|
|
91
|
+
const rank = <T,>(items: T[], text: (i: T) => string, limit: number): T[] => {
|
|
92
|
+
if (!trimmed) return items.slice(0, limit);
|
|
93
|
+
return items
|
|
94
|
+
.map((i) => ({ i, s: fuzzyScore(trimmed, text(i)) }))
|
|
95
|
+
.filter((x) => x.s > -Infinity)
|
|
96
|
+
.sort((a, b) => b.s - a.s)
|
|
97
|
+
.slice(0, limit)
|
|
98
|
+
.map((x) => x.i);
|
|
99
|
+
};
|
|
100
|
+
const groups: { title: string; entries: Entry[] }[] = [
|
|
101
|
+
{ title: "项目", entries: rank(projects, (p) => `${p.name} ${p.sub}`, limits.project).map((project) => ({ kind: "project", project })) },
|
|
102
|
+
{ title: "会话", entries: rank(tasks, (t) => `${t.title} ${t.project_name}`, limits.task).map((task) => ({ kind: "task", task })) },
|
|
103
|
+
{ title: "命令", entries: rank(commands, (c) => `${c.label} ${c.keywords ?? ""}`, commands.length).map((command) => ({ kind: "command", command })) },
|
|
104
|
+
];
|
|
105
|
+
// Flatten in display order so ↑/↓ walk straight through the groups.
|
|
106
|
+
const flat: Entry[] = groups.flatMap((g) => g.entries);
|
|
107
|
+
let idx = 0;
|
|
108
|
+
const sections = groups
|
|
109
|
+
.filter((g) => g.entries.length > 0)
|
|
110
|
+
.map((g) => ({ title: g.title, rows: g.entries.map((entry) => ({ entry, idx: idx++ })) }));
|
|
111
|
+
return { sections, flat };
|
|
112
|
+
}, [query, projects, tasks, commands]);
|
|
113
|
+
|
|
114
|
+
// Typing (or the async session load) reshapes the list — snap the highlight
|
|
115
|
+
// back to the top / into range rather than leaving it on a stale row.
|
|
116
|
+
useEffect(() => { setActive(0); }, [query]);
|
|
117
|
+
useEffect(() => { if (active >= flat.length) setActive(0); }, [active, flat.length]);
|
|
118
|
+
|
|
119
|
+
const run = (e: Entry) => {
|
|
120
|
+
if (e.kind === "project") onPickProject(e.project.id);
|
|
121
|
+
else if (e.kind === "task") onPickTask(e.task.project_id, e.task.id);
|
|
122
|
+
else e.command.run();
|
|
123
|
+
onClose();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Keyboard driving. A window *capture* listener so it wins regardless of
|
|
127
|
+
// focus, and so Escape can stopPropagation before any stacked Modal's own
|
|
128
|
+
// bubble-phase Escape handler sees the event (the palette may sit on top).
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const onKey = (e: KeyboardEvent) => {
|
|
131
|
+
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); onClose(); return; }
|
|
132
|
+
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => (flat.length ? (a + 1) % flat.length : 0)); return; }
|
|
133
|
+
if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => (flat.length ? (a - 1 + flat.length) % flat.length : 0)); return; }
|
|
134
|
+
if (e.key === "Enter") { e.preventDefault(); const cur = flat[active]; if (cur) run(cur); }
|
|
135
|
+
};
|
|
136
|
+
window.addEventListener("keydown", onKey, true);
|
|
137
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
listRef.current?.querySelector(`[data-idx="${active}"]`)?.scrollIntoView({ block: "nearest" });
|
|
142
|
+
}, [active]);
|
|
143
|
+
|
|
144
|
+
const row = (entry: Entry, idx: number) => {
|
|
145
|
+
const props = {
|
|
146
|
+
"data-idx": idx,
|
|
147
|
+
className: `pal-row${idx === active ? " on" : ""}`,
|
|
148
|
+
onMouseEnter: () => setActive(idx),
|
|
149
|
+
onClick: () => run(entry),
|
|
150
|
+
};
|
|
151
|
+
if (entry.kind === "project") {
|
|
152
|
+
const p = entry.project;
|
|
153
|
+
return (
|
|
154
|
+
<button key={entryKey(entry)} {...props}>
|
|
155
|
+
<span className="pr-chip" style={{ background: p.color }}>{(p.icon || p.name[0] || "?").toUpperCase()}</span>
|
|
156
|
+
<span className="pr-title">{p.name}</span>
|
|
157
|
+
{p.sub && <span className="pr-sub">{p.sub}</span>}
|
|
158
|
+
{idx === active && <span className="pr-hint">⏎ 打开</span>}
|
|
159
|
+
</button>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (entry.kind === "task") {
|
|
163
|
+
const t = entry.task;
|
|
164
|
+
return (
|
|
165
|
+
<button key={entryKey(entry)} {...props}>
|
|
166
|
+
<span className="pr-chip" style={{ background: t.project_color }} title={t.project_name}>
|
|
167
|
+
{(t.project_icon || t.project_name[0] || "?").toUpperCase()}
|
|
168
|
+
</span>
|
|
169
|
+
<StatusDot status={t.status} running={t.running === 1} awaiting={t.awaiting_input === 1 && t.running !== 1} />
|
|
170
|
+
<span className="pr-title">{t.title}</span>
|
|
171
|
+
<span className="pr-sub">{t.project_name}</span>
|
|
172
|
+
{idx === active && <span className="pr-hint">⏎ 打开</span>}
|
|
173
|
+
</button>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const c = entry.command;
|
|
177
|
+
return (
|
|
178
|
+
<button key={entryKey(entry)} {...props}>
|
|
179
|
+
<span className="pr-ic">{c.icon}</span>
|
|
180
|
+
<span className="pr-title">{c.label}</span>
|
|
181
|
+
{c.hint && <span className="pr-sub">{c.hint}</span>}
|
|
182
|
+
{idx === active && <span className="pr-hint">⏎ 执行</span>}
|
|
183
|
+
</button>
|
|
184
|
+
);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div className="palette-scrim" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
189
|
+
<div className="palette">
|
|
190
|
+
<div className="palette-input">
|
|
191
|
+
<span className="p-ic">{Icon.search()}</span>
|
|
192
|
+
<input
|
|
193
|
+
autoFocus
|
|
194
|
+
value={query}
|
|
195
|
+
placeholder="跳转到项目或会话,或执行命令…"
|
|
196
|
+
spellCheck={false}
|
|
197
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
198
|
+
/>
|
|
199
|
+
<span className="palette-kbd">esc</span>
|
|
200
|
+
</div>
|
|
201
|
+
<div className="palette-list" ref={listRef}>
|
|
202
|
+
{flat.length === 0 && <div className="palette-empty">没有匹配“{query.trim()}”的结果。</div>}
|
|
203
|
+
{sections.map((sec) => (
|
|
204
|
+
<div key={sec.title}>
|
|
205
|
+
<div className="pop-sec">{sec.title}</div>
|
|
206
|
+
{sec.rows.map((r) => row(r.entry, r.idx))}
|
|
207
|
+
</div>
|
|
208
|
+
))}
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { Popover } from "./shared";
|
|
5
|
+
import type { TaskRow } from "./types";
|
|
6
|
+
import { Icon } from "./icons";
|
|
7
|
+
import { attachmentMarker, fileAttachmentMarker } from "./format";
|
|
8
|
+
import { MAX_MESSAGE_CHARS, PASTE_ATTACH_THRESHOLD } from "./protocol/promptLimits";
|
|
9
|
+
import { RunControls, type RunControlProps } from "./RunControls";
|
|
10
|
+
import { appendBoardContext, formatBoardContext } from "../../board/context";
|
|
11
|
+
import { readBoardState, useBoardState } from "./boardState";
|
|
12
|
+
import { clientId } from "../../core/id";
|
|
13
|
+
import { isConfigurationBusy, useConfigurationBusy } from "../../chat/configuration";
|
|
14
|
+
import { acknowledgeDraft, DRAFT_EVENT, isTaskSending, setTaskSending, readDraft, writeDraft, quoteText, type ComposerDraft, type Quote } from "./composerDraft";
|
|
15
|
+
|
|
16
|
+
export function Composer({ task, agentLabel, disabled, running, onSend, onStop, onClear, quoteRequest, onQuoteHandled, controls, configBusy, tasks, onFork }: {
|
|
17
|
+
task: TaskRow; agentLabel: string; disabled: boolean; running: boolean;
|
|
18
|
+
tasks: TaskRow[]; onFork: (text: string, sourceIds: string[], requestId: string) => Promise<boolean>;
|
|
19
|
+
onSend: (text: string) => Promise<boolean>; onStop: () => Promise<boolean>; onClear: () => void;
|
|
20
|
+
configBusy: boolean; quoteRequest: Quote | null; onQuoteHandled: () => void; controls: RunControlProps;
|
|
21
|
+
}) {
|
|
22
|
+
const boardView=useBoardState(task.id);
|
|
23
|
+
const [includeBoard,setIncludeBoard]=useState(true);
|
|
24
|
+
const [draft, setDraft] = useState(() => readDraft(task.id));
|
|
25
|
+
const draftRef = useRef(draft);
|
|
26
|
+
const [addOpen, setAddOpen] = useState(false);
|
|
27
|
+
const [caret, setCaret] = useState(0);
|
|
28
|
+
const [mentionClosed, setMentionClosed] = useState(false);
|
|
29
|
+
const [mentionIndex, setMentionIndex] = useState(0);
|
|
30
|
+
const [slash, setSlash] = useState(false);
|
|
31
|
+
const [sending, setSending] = useState(() => isTaskSending(task.id));
|
|
32
|
+
const [stopping, setStopping] = useState(false);
|
|
33
|
+
const sendingRef = useRef(false);
|
|
34
|
+
const stoppingRef = useRef(false);
|
|
35
|
+
const [error, setError] = useState("");
|
|
36
|
+
const [dragging, setDragging] = useState(false);
|
|
37
|
+
const dragDepth = useRef(0);
|
|
38
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
39
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
40
|
+
const caretRequest = useRef<number | null>(null);
|
|
41
|
+
const composing = useRef(false);
|
|
42
|
+
const uploads = useRef(new Map<string, AbortController>());
|
|
43
|
+
const previews = useRef(new Set<string>());
|
|
44
|
+
const mounted = useRef(true);
|
|
45
|
+
const update = (fn: (prev: ComposerDraft) => ComposerDraft) => {
|
|
46
|
+
const next = fn(draftRef.current);
|
|
47
|
+
draftRef.current = next;
|
|
48
|
+
writeDraft(task.id, next);
|
|
49
|
+
if (mounted.current) setDraft(next);
|
|
50
|
+
};
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
mounted.current = true;
|
|
53
|
+
const pending = uploads.current, urls = previews.current;
|
|
54
|
+
const sync = (event: Event) => {
|
|
55
|
+
const change = (event as CustomEvent<{ id: string; draft?: ComposerDraft; sending?: boolean }>).detail;
|
|
56
|
+
if (change.id !== task.id) return;
|
|
57
|
+
if (change.draft) { draftRef.current = change.draft; setDraft(change.draft); }
|
|
58
|
+
if (change.sending !== undefined) setSending(change.sending);
|
|
59
|
+
};
|
|
60
|
+
window.addEventListener(DRAFT_EVENT, sync);
|
|
61
|
+
return () => {
|
|
62
|
+
mounted.current = false;
|
|
63
|
+
window.removeEventListener(DRAFT_EVENT, sync);
|
|
64
|
+
const interrupted = new Set(pending.keys());
|
|
65
|
+
pending.forEach((controller) => controller.abort());
|
|
66
|
+
// The in-memory draft survives task switches. An aborted upload must not
|
|
67
|
+
// remain "uploading" there forever or keep a revoked blob URL on remount.
|
|
68
|
+
if (interrupted.size) {
|
|
69
|
+
const current = readDraft(task.id);
|
|
70
|
+
writeDraft(task.id, { ...current, attachments: current.attachments.map((attachment) =>
|
|
71
|
+
interrupted.has(attachment.key) && attachment.status === "uploading"
|
|
72
|
+
? { ...attachment, preview: "", status: "error", error: "上传已中断,请移除后重新添加。" }
|
|
73
|
+
: attachment) });
|
|
74
|
+
}
|
|
75
|
+
pending.clear();
|
|
76
|
+
urls.forEach((url) => URL.revokeObjectURL(url));
|
|
77
|
+
urls.clear();
|
|
78
|
+
};
|
|
79
|
+
}, []);
|
|
80
|
+
useEffect(() => { if (!running) { setStopping(false); stoppingRef.current = false; } }, [running]);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!quoteRequest) return;
|
|
83
|
+
update((d) => ({ ...d, quote: quoteRequest }));
|
|
84
|
+
ref.current?.focus();
|
|
85
|
+
onQuoteHandled();
|
|
86
|
+
// Each request is a new object even when quoting the same message again.
|
|
87
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
88
|
+
}, [quoteRequest]);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!ref.current) return;
|
|
91
|
+
ref.current.style.height = "auto";
|
|
92
|
+
ref.current.style.height = Math.min(ref.current.scrollHeight, 200) + "px";
|
|
93
|
+
}, [draft.text]);
|
|
94
|
+
// A menu action hands keyboard control straight to the edited text. Deferring
|
|
95
|
+
// focus to rAF leaves a visible mention list whose first Escape/arrow is lost.
|
|
96
|
+
useLayoutEffect(() => {
|
|
97
|
+
const pos = caretRequest.current;
|
|
98
|
+
if (pos === null || !ref.current) return;
|
|
99
|
+
caretRequest.current = null;
|
|
100
|
+
ref.current.focus({ preventScroll: true });
|
|
101
|
+
ref.current.setSelectionRange(pos, pos);
|
|
102
|
+
}, [draft.text, addOpen]);
|
|
103
|
+
|
|
104
|
+
const sharedConfigBusy = useConfigurationBusy(task.id);
|
|
105
|
+
const locked = disabled || sending || configBusy || sharedConfigBusy;
|
|
106
|
+
const addFiles = (files: File[]) => {
|
|
107
|
+
if (locked) return;
|
|
108
|
+
setError("");
|
|
109
|
+
for (const original of files) {
|
|
110
|
+
const f = !original.type.startsWith("image/") && /\.(txt|md|log|csv|json|ya?ml|[cm]?[jt]sx?|py|sh|css|html)$/i.test(original.name)
|
|
111
|
+
? new File([original], original.name, { type: "text/plain" }) : original;
|
|
112
|
+
const type = f.type.split(";")[0];
|
|
113
|
+
const isImage = ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(type);
|
|
114
|
+
if (!isImage && type !== "text/plain") { setError(`“${f.name}”暂不支持,请选择 PNG、JPEG、GIF、WebP 或纯文本文件。`); continue; }
|
|
115
|
+
if (f.size > 10 * 1024 * 1024) { setError(`“${f.name}”超过 10 MB,请缩小后重试。`); continue; }
|
|
116
|
+
const key = clientId();
|
|
117
|
+
const preview = isImage ? URL.createObjectURL(f) : "";
|
|
118
|
+
if (preview) previews.current.add(preview);
|
|
119
|
+
update((d) => ({ ...d, attachments: [...d.attachments, { key, kind: isImage ? "image" : "file", name: f.name || "附件", preview, path: "", status: "uploading" }] }));
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
uploads.current.set(key, controller);
|
|
122
|
+
const body = new FormData(); body.append("file", f, f.name);
|
|
123
|
+
void fetch(`/api/tasks/${task.id}/uploads`, { method: "POST", body, signal: controller.signal })
|
|
124
|
+
.then(async (res) => {
|
|
125
|
+
const j = await res.json();
|
|
126
|
+
if (!res.ok || !j.path || !j.url) throw new Error(j.error || "上传失败,请重新添加。");
|
|
127
|
+
if (!mounted.current) return;
|
|
128
|
+
update((d) => ({ ...d, attachments: d.attachments.map((a) => a.key === key ? { ...a, path: j.path, preview: isImage ? j.url : "", status: "ready" } : a) }));
|
|
129
|
+
})
|
|
130
|
+
.catch((e: unknown) => {
|
|
131
|
+
if (!mounted.current || controller.signal.aborted) return;
|
|
132
|
+
update((d) => ({ ...d, attachments: d.attachments.map((a) => a.key === key ? { ...a, status: "error", error: e instanceof Error ? e.message : "上传失败" } : a) }));
|
|
133
|
+
}).finally(() => { uploads.current.delete(key); if (preview) { URL.revokeObjectURL(preview); previews.current.delete(preview); } });
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const removeAtt = (key: string) => {
|
|
137
|
+
uploads.current.get(key)?.abort();
|
|
138
|
+
update((d) => ({ ...d, attachments: d.attachments.filter((a) => a.key !== key) }));
|
|
139
|
+
};
|
|
140
|
+
const sources = draft.sources ?? [];
|
|
141
|
+
const mention = draft.text.slice(0, caret).match(/(?:^|\s)@([^\s@]*)$/);
|
|
142
|
+
const mentionOpen = !!mention && !mentionClosed && !locked;
|
|
143
|
+
const candidates = tasks.filter((t) => t.project_id === task.project_id && t.status === "done" && !t.running && !!t.work_branch && !sources.some((s) => s.id === t.id) && (!mention?.[1] || t.title.toLowerCase().includes(mention[1].toLowerCase())));
|
|
144
|
+
const pickSource = (source: TaskRow) => {
|
|
145
|
+
if (sources.length >= 4 || !mention) return;
|
|
146
|
+
const start = caret - mention[1].length - 1;
|
|
147
|
+
caretRequest.current = start;
|
|
148
|
+
update((d) => ({ ...d, text: d.text.slice(0, start) + d.text.slice(caret), sources: [...(d.sources ?? []), { id: source.id, title: source.title }], forkRequest: undefined }));
|
|
149
|
+
setMentionClosed(true); setCaret(start);
|
|
150
|
+
};
|
|
151
|
+
const openMention = () => {
|
|
152
|
+
setAddOpen(false); setMentionClosed(false); setMentionIndex(0);
|
|
153
|
+
const pos = ref.current?.selectionStart ?? draft.text.length;
|
|
154
|
+
const prefix = pos > 0 && !/\s/.test(draft.text[pos - 1]) ? " @" : "@";
|
|
155
|
+
caretRequest.current = pos + prefix.length;
|
|
156
|
+
update((d) => ({ ...d, text: d.text.slice(0, pos) + prefix + d.text.slice(pos) }));
|
|
157
|
+
setCaret(pos + prefix.length);
|
|
158
|
+
};
|
|
159
|
+
const uploading = draft.attachments.some((a) => a.status === "uploading");
|
|
160
|
+
const failed = draft.attachments.some((a) => a.status === "error");
|
|
161
|
+
const ownPayload = [draft.quote ? quoteText(draft.quote) : "", draft.text.trim(), ...draft.attachments.filter((a) => a.status === "ready").map((a) => a.kind === "image" ? attachmentMarker(a.path) : fileAttachmentMarker(a.path))].filter(Boolean).join("\n\n");
|
|
162
|
+
const hasBoardContext=!!boardView&&!!formatBoardContext(boardView,task.id);
|
|
163
|
+
const isClear=draft.text.trim()==='/clear'&&!draft.attachments.length&&!draft.quote;
|
|
164
|
+
const currentSignature=JSON.stringify({payload:ownPayload,ids:sources.map(source=>source.id),...(!includeBoard||boardView?.autoContext===false?{withoutBoardContext:true}:{})});
|
|
165
|
+
const pendingFork=sources.length&&draft.forkRequest?.signature===currentSignature?draft.forkRequest:null;
|
|
166
|
+
const payload=pendingFork?pendingFork.payload??ownPayload:ownPayload&&!isClear&&includeBoard?appendBoardContext(ownPayload,boardView,task.id):ownPayload;
|
|
167
|
+
const tooLong = payload.length > MAX_MESSAGE_CHARS;
|
|
168
|
+
const canSend = !!payload && !uploading && !failed && !tooLong && !locked;
|
|
169
|
+
const clear = () => {
|
|
170
|
+
if (running || locked || task.started !== 1) return;
|
|
171
|
+
onClear(); setSlash(false);
|
|
172
|
+
if (draft.text.trim() === "/clear") update((d) => ({ ...d, text: "" }));
|
|
173
|
+
};
|
|
174
|
+
const submit = async () => {
|
|
175
|
+
if (!canSend || sendingRef.current || isTaskSending(task.id) || isConfigurationBusy(task.id)) return;
|
|
176
|
+
if (draft.text.trim() === "/clear" && !draft.attachments.length && !draft.quote) { clear(); return; }
|
|
177
|
+
let submittedDraft = draftRef.current;
|
|
178
|
+
let requestId = "";
|
|
179
|
+
const currentView=readBoardState(task.id);
|
|
180
|
+
let submittedPayload=includeBoard?appendBoardContext(ownPayload,currentView,task.id):ownPayload;
|
|
181
|
+
if (sources.length) {
|
|
182
|
+
const signature = JSON.stringify({ payload:ownPayload, ids: sources.map((s) => s.id), ...(!includeBoard||currentView?.autoContext===false?{withoutBoardContext:true}:{}) });
|
|
183
|
+
const previous=submittedDraft.forkRequest?.signature===signature?submittedDraft.forkRequest:null;
|
|
184
|
+
requestId=previous?.id??clientId();
|
|
185
|
+
// Once dispatched, retries must keep the exact payload as well as the id.
|
|
186
|
+
// A passive page/file change is not a new user request or a new branch.
|
|
187
|
+
if(previous)submittedPayload=previous.payload??ownPayload;
|
|
188
|
+
if(submittedPayload.length>MAX_MESSAGE_CHARS){setError("消息和看板背景合计超过长度限制,请精简消息或取消本次背景。");return;}
|
|
189
|
+
update((d) => ({ ...d, forkRequest: { id: requestId, signature, payload:submittedPayload } }));
|
|
190
|
+
submittedDraft = draftRef.current;
|
|
191
|
+
}
|
|
192
|
+
if(submittedPayload.length>MAX_MESSAGE_CHARS){setError("消息和看板背景合计超过长度限制,请精简消息或取消本次背景。");return;}
|
|
193
|
+
sendingRef.current = true; setTaskSending(task.id, true); setSending(true); setError("");
|
|
194
|
+
try {
|
|
195
|
+
if (await (sources.length ? onFork(submittedPayload, sources.map((s) => s.id), requestId) : onSend(submittedPayload))) {
|
|
196
|
+
if (mounted.current) {update(current=>acknowledgeDraft(current,submittedDraft));setIncludeBoard(true);}
|
|
197
|
+
else writeDraft(task.id,acknowledgeDraft(readDraft(task.id),submittedDraft));
|
|
198
|
+
}
|
|
199
|
+
else if (mounted.current) setError("发送失败,草稿已保留,请重试。");
|
|
200
|
+
} catch (e) { if (mounted.current) setError(e instanceof Error ? e.message : "发送失败,草稿已保留。"); }
|
|
201
|
+
finally { sendingRef.current = false; setTaskSending(task.id, false); if (mounted.current) { setSending(false); ref.current?.focus(); } }
|
|
202
|
+
};
|
|
203
|
+
const stop = async () => {
|
|
204
|
+
if (stoppingRef.current) return;
|
|
205
|
+
stoppingRef.current = true; setStopping(true); setError("");
|
|
206
|
+
try { if (!await onStop()) throw new Error("停止失败,请重试。"); }
|
|
207
|
+
catch (e) { stoppingRef.current = false; if (mounted.current) { setStopping(false); setError(e instanceof Error ? e.message : "停止失败,请重试。"); } }
|
|
208
|
+
};
|
|
209
|
+
const hasFileDrag = (e: React.DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
|
210
|
+
return <div className="composer">
|
|
211
|
+
<div className="composer-inner">
|
|
212
|
+
{mentionOpen && <div className="mention-menu" id={`mentions-${task.id}`} role="listbox" aria-label="选择派生来源">
|
|
213
|
+
<div className="mention-heading">从已完成任务派生新分支 <span>{sources.length}/4</span></div>
|
|
214
|
+
{sources.length >= 4 ? <p>最多同时派生 4 条分支,请先移除一个来源。</p> : candidates.length ? candidates.map((t, index) => <button key={t.id} id={`mention-${t.id}`} role="option" aria-selected={index === Math.min(mentionIndex, candidates.length - 1)} onMouseDown={(e) => e.preventDefault()} onClick={() => pickSource(t)}>
|
|
215
|
+
{Icon.git()}<span><strong>{t.title}</strong><small>{t.work_branch}</small></span>{Icon.plus()}
|
|
216
|
+
</button>) : <p>没有匹配的已完成任务。请先完成任务并提交代码,再从这里派生。</p>}
|
|
217
|
+
</div>}
|
|
218
|
+
|
|
219
|
+
{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>}
|
|
220
|
+
<div className={`comp-box${dragging ? " dropping" : ""}`}
|
|
221
|
+
onDragEnter={(e) => { if (hasFileDrag(e)) { e.preventDefault(); if (!locked) { dragDepth.current++; setDragging(true); } } }}
|
|
222
|
+
onDragOver={(e) => { if (hasFileDrag(e)) e.preventDefault(); }}
|
|
223
|
+
onDragLeave={() => { if (dragDepth.current > 0 && --dragDepth.current === 0) setDragging(false); }}
|
|
224
|
+
onDrop={(e) => { if (!hasFileDrag(e)) return; e.preventDefault(); dragDepth.current = 0; setDragging(false); addFiles(Array.from(e.dataTransfer.files)); }}>
|
|
225
|
+
{sources.length > 0 && <div className="fork-sources">{sources.map((source) => <span key={source.id}>{Icon.git()} 从 {source.title} 派生<button disabled={locked} aria-label={`移除来源 ${source.title}`} onClick={() => update((d) => ({ ...d, sources: d.sources?.filter((s) => s.id !== source.id), forkRequest: undefined }))}>{Icon.x()}</button></span>)}</div>}
|
|
226
|
+
{hasBoardContext&&<div className="comp-board-context" style={{display:'flex',alignItems:'center',gap:8,padding:'8px 14px',fontSize:12,color:'var(--muted)'}}>
|
|
227
|
+
<label style={{display:'flex',alignItems:'center',gap:6,cursor:'pointer'}} title="发送时自动附带当前文件、打开页签和最近访问记录;不会读取文件正文。">
|
|
228
|
+
<input type="checkbox" checked={includeBoard} disabled={locked} onChange={event=>setIncludeBoard(event.target.checked)}/>看板背景
|
|
229
|
+
</label>
|
|
230
|
+
<details style={{minWidth:0,flex:1}}>
|
|
231
|
+
<summary style={{cursor:'pointer',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{boardView?.current?.path??boardView?.file??(boardView?.browser?'浏览器页面':'无当前文件')} · {boardView?.opened?.length??boardView?.files.length??0} 个打开 · {boardView?.history?.length??0} 个最近访问{!includeBoard?' · 本次不带':''}</summary>
|
|
232
|
+
<div style={{maxHeight:140,overflow:'auto',overflowWrap:'anywhere',paddingTop:6}}>
|
|
233
|
+
<div>{boardView?.visible===false?'看板已收起,以下为上次浏览状态。':'以下浏览背景会随消息提供给 AI。'}</div>
|
|
234
|
+
<div>打开页签:{(boardView?.opened??[]).map(file=>`${file.path}${file.sourceId&&file.sourceId!==task.id?`(来源:${tasks.find(source=>source.id===file.sourceId)?.title??file.sourceId})`:''}`).join('、')||'无'}</div>
|
|
235
|
+
<div>最近访问:{(boardView?.history??[]).map(file=>`${file.path}${file.sourceId&&file.sourceId!==task.id?`(来源:${tasks.find(source=>source.id===file.sourceId)?.title??file.sourceId})`:''}`).join('、')||'无'}</div>
|
|
236
|
+
</div>
|
|
237
|
+
</details>
|
|
238
|
+
</div>}
|
|
239
|
+
{draft.quote && <div className="comp-quote"><div><strong>引用 {draft.quote.author}</strong><p>{draft.quote.text}</p></div><button className="icon-btn" disabled={locked} aria-label="移除引用" onClick={() => update((d) => ({ ...d, quote: null }))}>{Icon.x()}</button></div>}
|
|
240
|
+
{draft.attachments.length > 0 && <div className="attach-row">{draft.attachments.map((a) => <div key={a.key} className={`attach-chip ${a.kind} ${a.status}`} title={a.error || a.name}>
|
|
241
|
+
{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
|
+
<button className="attach-x" disabled={locked} aria-label={`移除 ${a.name}`} onClick={() => removeAtt(a.key)}>×</button>
|
|
245
|
+
</div>)}</div>}
|
|
246
|
+
<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} 一起工作,@ 派生任务分支`}
|
|
248
|
+
onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}
|
|
249
|
+
onChange={(e) => { const text = e.target.value; update((d) => ({ ...d, text })); setSlash(text.trim().startsWith("/")); setCaret(e.target.selectionStart); setMentionClosed(false); setMentionIndex(0); }}
|
|
250
|
+
onSelect={(e) => setCaret(e.currentTarget.selectionStart)}
|
|
251
|
+
onKeyDown={(e) => {
|
|
252
|
+
if (e.nativeEvent.isComposing || composing.current || e.keyCode === 229) return;
|
|
253
|
+
if (mentionOpen) {
|
|
254
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); setMentionIndex((i) => Math.max(0, Math.min(candidates.length - 1, i + (e.key === "ArrowDown" ? 1 : -1)))); return; }
|
|
255
|
+
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setMentionClosed(true); return; }
|
|
256
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (candidates.length) pickSource(candidates[Math.min(mentionIndex, candidates.length - 1)]); return; }
|
|
257
|
+
}
|
|
258
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); }
|
|
259
|
+
if (e.key === "Escape") setSlash(false);
|
|
260
|
+
}}
|
|
261
|
+
onPaste={(e) => {
|
|
262
|
+
const files = Array.from(e.clipboardData.files);
|
|
263
|
+
if (files.length) { e.preventDefault(); addFiles(files); return; }
|
|
264
|
+
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" })]); }
|
|
266
|
+
}} /></div>
|
|
267
|
+
<div className="comp-toolbar">
|
|
268
|
+
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp,text/plain,.txt,.md,.log,.json,.csv,.yaml,.yml,.ts,.tsx,.js,.jsx,.py" multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />
|
|
269
|
+
<div className="comp-control"><button className="comp-add" disabled={locked} title="添加附件或派生分支" aria-label="添加内容" aria-expanded={addOpen} onClick={(e) => { e.stopPropagation(); setAddOpen(!addOpen); }}>{Icon.plus()}</button>
|
|
270
|
+
{addOpen && <Popover onClose={() => setAddOpen(false)}>
|
|
271
|
+
<button className="pop-item" onClick={() => { setAddOpen(false); fileRef.current?.click(); }}>{Icon.clip()} 添加图片或文本文件</button>
|
|
272
|
+
<button className="pop-item" onClick={openMention}>{Icon.git()} 从已完成任务派生 <span>@</span></button>
|
|
273
|
+
</Popover>}
|
|
274
|
+
</div>
|
|
275
|
+
<RunControls {...controls} disabled={controls.disabled || sending} />
|
|
276
|
+
<div className="send-group">
|
|
277
|
+
{(!running || !!payload) && <button className={`send${running ? " queue" : ""}`} disabled={!canSend} aria-label={sources.length ? `派生 ${sources.length} 个分支` : running ? "排队发送" : "发送消息"} title={sending ? "发送中…" : running ? "本轮结束后发送" : "发送消息"} onClick={() => void submit()}>{sending ? <span className="spinner" /> : Icon.send()}</button>}
|
|
278
|
+
{running && <button className="send stop" disabled={stopping} aria-label="停止当前轮次" title={stopping ? "正在停止…" : "停止当前轮次"} onClick={() => void stop()}>{stopping ? <span className="spinner" /> : Icon.stop()}</button>}
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
<div className="comp-caption"><span>{sources.length ? `发送后创建 ${sources.length} 条独立分支并开始执行` : running ? "运行中 · 补充消息将排队" : "Enter 发送 · Shift + Enter 换行"}</span><button disabled={running || locked || task.started !== 1} onClick={clear} title="保存摘要并开启新的上下文窗口">{Icon.clear()} 新上下文</button></div>
|
|
283
|
+
{(error || failed || tooLong) && <div className="comp-error" role="alert">{error || (tooLong ? "消息过长,请将部分内容作为文本附件添加。" : "请移除上传失败的附件后重新添加,再发送。")}</div>}
|
|
284
|
+
{uploading && <div className="comp-upload-status" role="status">正在上传附件,完成后可发送…</div>}
|
|
285
|
+
</div>
|
|
286
|
+
</div>;
|
|
287
|
+
}
|