@spzxg/cli-ui 0.6.2 → 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 +137 -11
- package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
- package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-D4qfPHMd.js} +2 -2
- package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
- package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
- package/dist/{cli-ui-xlsx-CpWicJLT.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 +73 -30
- package/src/chat/Composer.tsx +41 -41
- 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 +8 -6
- package/src/core/conversation.ts +3 -3
- 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 +20 -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/shared.tsx +1 -1
- package/src/ui/transcriptModel.ts +2 -2
- package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { Icon } from "./icons";
|
|
5
|
+
import { jget, jsend } from "./api";
|
|
6
|
+
import { LoadNote, ErrNote } from "./shared";
|
|
7
|
+
import { fmtBytes } from "./format";
|
|
8
|
+
import { DEFAULT_RETENTION_DAYS, retentionDays, validRetentionDays } from "./protocol/storageRetention";
|
|
9
|
+
|
|
10
|
+
interface Candidate {
|
|
11
|
+
taskId: string;
|
|
12
|
+
title: string;
|
|
13
|
+
projectId: string;
|
|
14
|
+
projectName: string;
|
|
15
|
+
branch: string;
|
|
16
|
+
mergedAt: number;
|
|
17
|
+
cleanupAt: number;
|
|
18
|
+
status: string;
|
|
19
|
+
sizeBytes: number;
|
|
20
|
+
running: boolean;
|
|
21
|
+
unsafe: boolean; // dirty or ahead — removing it would lose work
|
|
22
|
+
unsafeReason: string | null;
|
|
23
|
+
canDiscard: boolean; // Done tasks may be explicitly destroyed even when unsafe
|
|
24
|
+
}
|
|
25
|
+
interface PruneList {
|
|
26
|
+
candidates: Candidate[];
|
|
27
|
+
totalBytes: number;
|
|
28
|
+
}
|
|
29
|
+
interface PruneResult {
|
|
30
|
+
pruned: string[];
|
|
31
|
+
discarded: string[];
|
|
32
|
+
skipped: { taskId: string; reason: string }[];
|
|
33
|
+
reclaimedBytes: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DAY = 86_400_000;
|
|
37
|
+
const daysAgo = (at: number) => Math.floor((Date.now() - at) / DAY);
|
|
38
|
+
const cleanupLabel = (c: Candidate) => {
|
|
39
|
+
const d = daysAgo(c.cleanupAt);
|
|
40
|
+
const action = c.mergedAt ? "合并" : "完成";
|
|
41
|
+
if (d <= 0) return `今天${action}`;
|
|
42
|
+
return `${d} 天前${action}`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Settings cleanup for merged worktrees and worktrees belonging to Done tasks.
|
|
46
|
+
// Safe cleanup retains branches by default. Unmerged work in a Done task can be
|
|
47
|
+
// explicitly discarded, which permanently deletes its edits and task branch.
|
|
48
|
+
export function WorktreePrune() {
|
|
49
|
+
const [list, setList] = useState<PruneList | null>(null);
|
|
50
|
+
const [error, setError] = useState<string | null>(null);
|
|
51
|
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
52
|
+
const [minDays, setMinDays] = useState(0);
|
|
53
|
+
const [deleteBranch, setDeleteBranch] = useState(false);
|
|
54
|
+
const [confirming, setConfirming] = useState(false);
|
|
55
|
+
const [busy, setBusy] = useState(false);
|
|
56
|
+
const [result, setResult] = useState<PruneResult | null>(null);
|
|
57
|
+
// null until the app settings load, so the toggle can't flash the wrong state.
|
|
58
|
+
const [autoPrune, setAutoPrune] = useState<boolean | null>(null);
|
|
59
|
+
const [autoDays, setAutoDays] = useState(String(DEFAULT_RETENTION_DAYS));
|
|
60
|
+
const [savedAutoDays, setSavedAutoDays] = useState(String(DEFAULT_RETENTION_DAYS));
|
|
61
|
+
const [autoBusy, setAutoBusy] = useState(false);
|
|
62
|
+
const [autoErr, setAutoErr] = useState<string | null>(null);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
jget<Record<string, string>>("/api/settings")
|
|
66
|
+
.then((s) => {
|
|
67
|
+
setAutoPrune(s.auto_prune_worktrees !== "off");
|
|
68
|
+
const days = String(retentionDays(s.auto_prune_days));
|
|
69
|
+
setAutoDays(days);
|
|
70
|
+
setSavedAutoDays(days);
|
|
71
|
+
})
|
|
72
|
+
.catch((e) => setAutoErr(e instanceof Error ? e.message : String(e)));
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
// Persist off explicitly now that the built-in default is on.
|
|
76
|
+
const toggleAutoPrune = (on: boolean) => {
|
|
77
|
+
setAutoBusy(true);
|
|
78
|
+
setAutoErr(null);
|
|
79
|
+
jsend<Record<string, string>>("/api/settings", "PATCH", { auto_prune_worktrees: on ? "on" : "off" })
|
|
80
|
+
.then((s) => setAutoPrune(s.auto_prune_worktrees !== "off"))
|
|
81
|
+
.catch((e) => setAutoErr(e instanceof Error ? e.message : String(e)))
|
|
82
|
+
.finally(() => setAutoBusy(false));
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const saveAutoDays = () => {
|
|
86
|
+
if (!validRetentionDays(autoDays)) {
|
|
87
|
+
setAutoErr("保留天数必须是大于或等于 1 的整数");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
setAutoBusy(true);
|
|
91
|
+
setAutoErr(null);
|
|
92
|
+
jsend<Record<string, string>>("/api/settings", "PATCH", { auto_prune_days: String(Number(autoDays)) })
|
|
93
|
+
.then((s) => {
|
|
94
|
+
const days = String(retentionDays(s.auto_prune_days));
|
|
95
|
+
setAutoDays(days);
|
|
96
|
+
setSavedAutoDays(days);
|
|
97
|
+
})
|
|
98
|
+
.catch((e) => setAutoErr(e instanceof Error ? e.message : String(e)))
|
|
99
|
+
.finally(() => setAutoBusy(false));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const refresh = useCallback(() => {
|
|
103
|
+
setError(null);
|
|
104
|
+
setResult(null);
|
|
105
|
+
setConfirming(false);
|
|
106
|
+
jget<PruneList>("/api/maintenance/worktrees")
|
|
107
|
+
.then((d) => {
|
|
108
|
+
setList(d);
|
|
109
|
+
// Drop any now-stale selections (pruned elsewhere, or filtered out).
|
|
110
|
+
setSelected((prev) => new Set(d.candidates.filter((c) => prev.has(c.taskId)).map((c) => c.taskId)));
|
|
111
|
+
})
|
|
112
|
+
.catch((e) => setError(e instanceof Error ? e.message : String(e)));
|
|
113
|
+
}, []);
|
|
114
|
+
useEffect(() => { refresh(); }, [refresh]);
|
|
115
|
+
|
|
116
|
+
// Candidates old enough to show, based on merge time or the time marked Done.
|
|
117
|
+
const shown = useMemo(
|
|
118
|
+
() => (list?.candidates ?? []).filter((c) => daysAgo(c.cleanupAt) >= minDays),
|
|
119
|
+
[list, minDays]
|
|
120
|
+
);
|
|
121
|
+
const selectable = useMemo(() => shown.filter((c) => !c.running && (!c.unsafe || c.canDiscard)), [shown]);
|
|
122
|
+
const selectedList = useMemo(() => shown.filter((c) => selected.has(c.taskId)), [shown, selected]);
|
|
123
|
+
const destructiveList = useMemo(() => selectedList.filter((c) => c.unsafe), [selectedList]);
|
|
124
|
+
const selectedBytes = selectedList.reduce((s, c) => s + c.sizeBytes, 0);
|
|
125
|
+
const allSelected = selectable.length > 0 && selectable.every((c) => selected.has(c.taskId));
|
|
126
|
+
|
|
127
|
+
const toggle = (taskId: string) => {
|
|
128
|
+
setConfirming(false);
|
|
129
|
+
setSelected((prev) => {
|
|
130
|
+
const next = new Set(prev);
|
|
131
|
+
if (next.has(taskId)) next.delete(taskId);
|
|
132
|
+
else next.add(taskId);
|
|
133
|
+
return next;
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
const toggleAll = () => {
|
|
137
|
+
setConfirming(false);
|
|
138
|
+
setSelected(allSelected ? new Set() : new Set(selectable.map((c) => c.taskId)));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
async function prune() {
|
|
142
|
+
if (selectedList.length === 0) return;
|
|
143
|
+
if (!confirming) { setConfirming(true); return; }
|
|
144
|
+
setBusy(true);
|
|
145
|
+
setError(null);
|
|
146
|
+
try {
|
|
147
|
+
const res = await jsend<PruneResult>("/api/maintenance/worktrees", "POST", {
|
|
148
|
+
taskIds: selectedList.map((c) => c.taskId),
|
|
149
|
+
deleteBranch,
|
|
150
|
+
discardChanges: destructiveList.length > 0,
|
|
151
|
+
});
|
|
152
|
+
setResult(res);
|
|
153
|
+
setSelected(new Set());
|
|
154
|
+
setConfirming(false);
|
|
155
|
+
// Reload the candidate list without wiping the just-shown result banner.
|
|
156
|
+
const fresh = await jget<PruneList>("/api/maintenance/worktrees");
|
|
157
|
+
setList(fresh);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
160
|
+
} finally {
|
|
161
|
+
setBusy(false);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<>
|
|
167
|
+
<div className="field">
|
|
168
|
+
<div className="lab">{Icon.archive()} 回收任务 worktree</div>
|
|
169
|
+
<div className="hlp" style={{ marginTop: 0, marginBottom: 12 }}>
|
|
170
|
+
每个任务都在自己的 git worktree 中运行。删除已合并或已完成任务的 worktree 可以回收磁盘空间。
|
|
171
|
+
安全清理默认<strong>保留分支</strong>,重新打开时会连同提交一起重建 worktree。
|
|
172
|
+
含未合并改动的已完成任务也可以丢弃,但其改动和分支将被永久删除。
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{error && <ErrNote style={{ marginBottom: 10 }} onRetry={refresh}>{error}</ErrNote>}
|
|
176
|
+
{result && (
|
|
177
|
+
<div className="hlp" style={{ marginBottom: 10 }}>
|
|
178
|
+
{Icon.check()} 已删除 {result.pruned.length} 个 worktree,回收{" "}
|
|
179
|
+
<strong>{fmtBytes(result.reclaimedBytes)}</strong>
|
|
180
|
+
{result.discarded.length > 0 && ` · 永久丢弃了 ${result.discarded.length} 个任务的改动`}
|
|
181
|
+
{result.skipped.length > 0 && ` · 跳过 ${result.skipped.length} 个(${result.skipped.map((s) => s.reason).join("、")})`}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
{list == null && !error ? (
|
|
186
|
+
<LoadNote style={{ padding: 0 }}>正在扫描 worktree…</LoadNote>
|
|
187
|
+
) : list == null ? null : list.candidates.length === 0 ? (
|
|
188
|
+
<div className="hlp" style={{ marginTop: 0 }}>没有可删除的已合并或已完成任务 worktree —— 无需回收。🎉</div>
|
|
189
|
+
) : (
|
|
190
|
+
<>
|
|
191
|
+
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 10, flexWrap: "wrap" }}>
|
|
192
|
+
<label className="hlp" style={{ margin: 0, display: "flex", alignItems: "center", gap: 6 }}>
|
|
193
|
+
完成超过
|
|
194
|
+
<input
|
|
195
|
+
type="number" min={0} value={minDays}
|
|
196
|
+
onChange={(e) => setMinDays(Math.max(0, Math.round(Number(e.target.value) || 0)))}
|
|
197
|
+
style={{ width: 64 }}
|
|
198
|
+
/>
|
|
199
|
+
天
|
|
200
|
+
</label>
|
|
201
|
+
<span className="spacer" style={{ flex: 1 }} />
|
|
202
|
+
<button className="btn btn-ghost btn-sm" onClick={refresh} disabled={busy}>{Icon.restore()} 重新扫描</button>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div style={{ border: "1px solid var(--line, #2a2a2a)", borderRadius: 8, overflow: "hidden" }}>
|
|
206
|
+
<label className="prune-row" style={rowStyle(true)}>
|
|
207
|
+
<input type="checkbox" checked={allSelected} onChange={toggleAll} disabled={selectable.length === 0} />
|
|
208
|
+
<span style={{ flex: 1, fontWeight: 600 }}>
|
|
209
|
+
{shown.length} 个 worktree
|
|
210
|
+
{minDays > 0 && list.candidates.length !== shown.length ? `(共 ${list.candidates.length} 个)` : ""}
|
|
211
|
+
</span>
|
|
212
|
+
<span className="hlp" style={{ margin: 0 }}>合计 {fmtBytes(shown.reduce((s, c) => s + c.sizeBytes, 0))}</span>
|
|
213
|
+
</label>
|
|
214
|
+
{shown.map((c) => (
|
|
215
|
+
<label key={c.taskId} className="prune-row" style={rowStyle(false)} title={c.unsafe ? c.unsafeReason ?? undefined : c.branch}>
|
|
216
|
+
<input
|
|
217
|
+
type="checkbox"
|
|
218
|
+
checked={selected.has(c.taskId)}
|
|
219
|
+
onChange={() => toggle(c.taskId)}
|
|
220
|
+
disabled={c.running || (c.unsafe && !c.canDiscard)}
|
|
221
|
+
/>
|
|
222
|
+
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
223
|
+
<span style={{ opacity: 0.6 }}>{c.projectName} · </span>
|
|
224
|
+
{c.title || "(未命名)"}
|
|
225
|
+
{c.running && <span className="hlp" style={{ margin: 0, marginLeft: 8 }}>· 运行中,已跳过</span>}
|
|
226
|
+
{!c.running && c.unsafe && c.canDiscard && (
|
|
227
|
+
<span className="hlp" style={{ margin: 0, marginLeft: 8, color: "var(--red)" }}>
|
|
228
|
+
· 永久丢弃{c.unsafeReason ? `(${c.unsafeReason})` : ""}
|
|
229
|
+
</span>
|
|
230
|
+
)}
|
|
231
|
+
{!c.running && c.unsafe && !c.canDiscard && (
|
|
232
|
+
<span className="hlp" style={{ margin: 0, marginLeft: 8, color: "var(--red)" }}>
|
|
233
|
+
· 仍有未合并的改动 —— 请先标记为已完成再丢弃
|
|
234
|
+
</span>
|
|
235
|
+
)}
|
|
236
|
+
</span>
|
|
237
|
+
<span className="hlp" style={{ margin: 0, whiteSpace: "nowrap" }}>{cleanupLabel(c)}</span>
|
|
238
|
+
<span style={{ whiteSpace: "nowrap", fontVariantNumeric: "tabular-nums", width: 72, textAlign: "right" }}>
|
|
239
|
+
{fmtBytes(c.sizeBytes)}
|
|
240
|
+
</span>
|
|
241
|
+
</label>
|
|
242
|
+
))}
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
{destructiveList.length > 0 && (
|
|
246
|
+
<div className="err-note" style={{ marginTop: 12 }}>
|
|
247
|
+
<strong>永久丢弃:</strong>所选的 {destructiveList.length} 个已完成任务仍有未合并的改动。
|
|
248
|
+
{" "}删除它们会一并删除未提交的改动和任务分支,且无法撤销。
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
|
|
252
|
+
<label className="hlp" style={{ display: "flex", alignItems: "flex-start", gap: 8, marginTop: 12 }}>
|
|
253
|
+
<input type="checkbox" checked={deleteBranch} onChange={(e) => { setDeleteBranch(e.target.checked); setConfirming(false); }} style={{ marginTop: 2 }} />
|
|
254
|
+
<span>
|
|
255
|
+
<strong>同时删除分支</strong>(默认关闭)。可以多释放一点空间,但你将无法再查看该任务相对原始基线的 diff。
|
|
256
|
+
重新打开仍然可用 —— 只是会新建一个空的 worktree。
|
|
257
|
+
{destructiveList.length > 0 && " 含未合并改动的分支在永久丢弃时总会被删除。"}
|
|
258
|
+
</span>
|
|
259
|
+
</label>
|
|
260
|
+
|
|
261
|
+
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 12 }}>
|
|
262
|
+
<button
|
|
263
|
+
className={`btn ${confirming ? "btn-danger" : "btn-line"}`}
|
|
264
|
+
onClick={prune}
|
|
265
|
+
disabled={busy || selectedList.length === 0}
|
|
266
|
+
>
|
|
267
|
+
{Icon.archive()}{" "}
|
|
268
|
+
{busy
|
|
269
|
+
? "删除中…"
|
|
270
|
+
: confirming
|
|
271
|
+
? destructiveList.length > 0
|
|
272
|
+
? `确认 —— 删除 ${selectedList.length} 个,永久丢弃 ${destructiveList.length} 个 · 回收 ${fmtBytes(selectedBytes)}`
|
|
273
|
+
: `确认 —— 删除 ${selectedList.length} 个并回收 ${fmtBytes(selectedBytes)}${deleteBranch ? " + 删除分支" : ""}`
|
|
274
|
+
: `删除所选 ${selectedList.length} 个 · ${fmtBytes(selectedBytes)}`}
|
|
275
|
+
</button>
|
|
276
|
+
{confirming && !busy && (
|
|
277
|
+
<button className="btn btn-ghost btn-sm" onClick={() => setConfirming(false)}>取消</button>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
</>
|
|
281
|
+
)}
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<div className="field">
|
|
285
|
+
<div className="lab">{Icon.clock()} 自动清理</div>
|
|
286
|
+
<label className="hlp" style={{ display: "flex", alignItems: "flex-start", gap: 8, marginTop: 0 }}>
|
|
287
|
+
<input
|
|
288
|
+
type="checkbox"
|
|
289
|
+
aria-label="开启自动清理"
|
|
290
|
+
checked={autoPrune === true}
|
|
291
|
+
disabled={autoPrune === null || autoBusy}
|
|
292
|
+
onChange={(e) => toggleAutoPrune(e.target.checked)}
|
|
293
|
+
style={{ marginTop: 2 }}
|
|
294
|
+
/>
|
|
295
|
+
<span>
|
|
296
|
+
开启自动清理(默认开启)
|
|
297
|
+
</span>
|
|
298
|
+
</label>
|
|
299
|
+
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", marginTop: 10 }}>
|
|
300
|
+
<label className="hlp" style={{ margin: 0, display: "flex", alignItems: "center", gap: 8 }}>
|
|
301
|
+
保留
|
|
302
|
+
<input type="number" min={1} step={1} aria-label="自动清理保留天数" value={autoDays}
|
|
303
|
+
onChange={(e) => setAutoDays(e.target.value)} disabled={autoPrune === null || autoBusy}
|
|
304
|
+
style={{ width: 80 }} />
|
|
305
|
+
天
|
|
306
|
+
</label>
|
|
307
|
+
<button className="btn btn-line btn-sm" onClick={saveAutoDays}
|
|
308
|
+
disabled={autoPrune === null || autoBusy || autoDays === savedAutoDays || !validRetentionDays(autoDays)}>
|
|
309
|
+
{autoBusy ? "保存中…" : "保存天数"}
|
|
310
|
+
</button>
|
|
311
|
+
<span className="hlp" style={{ margin: 0 }}>默认 30 天,当前已保存 {savedAutoDays} 天</span>
|
|
312
|
+
</div>
|
|
313
|
+
<div className="hlp" style={{ marginTop: 10 }}>
|
|
314
|
+
按任务最后一次运行结束时间计算,每小时检查一次,关闭浏览器后仍会执行。
|
|
315
|
+
到期后只回收已核验可恢复的任务工作区副本;项目仓库、原始资料、成果、附件和对话记录保留。
|
|
316
|
+
正在运行或仍有排队消息的任务会跳过,结束后按最新运行时间重新计算。
|
|
317
|
+
未提交、未合并或被 Git 忽略的文件会阻止回收,避免丢失续聊需要的资料。
|
|
318
|
+
</div>
|
|
319
|
+
{autoErr && <ErrNote>{autoErr}</ErrNote>}
|
|
320
|
+
</div>
|
|
321
|
+
</>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const rowStyle = (header: boolean): React.CSSProperties => ({
|
|
326
|
+
display: "flex",
|
|
327
|
+
alignItems: "center",
|
|
328
|
+
gap: 10,
|
|
329
|
+
padding: "8px 12px",
|
|
330
|
+
borderTop: header ? undefined : "1px solid var(--line, #2a2a2a)",
|
|
331
|
+
background: header ? "var(--panel, rgba(255,255,255,0.03))" : undefined,
|
|
332
|
+
cursor: "default",
|
|
333
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Client-side helpers over the agents capability bundle (GET /api/agents).
|
|
2
|
+
// Every run control + label the UI shows is derived from this data rather than
|
|
3
|
+
// hardcoded per agent, so adding a driver server-side surfaces in the UI with
|
|
4
|
+
// no client edits. Pure functions (no React) so any module can import them.
|
|
5
|
+
import type { AgentsBundle, AgentInfo, AgentCapabilities } from "./types";
|
|
6
|
+
import { isOptInAgent } from "./protocol/agents/optIn";
|
|
7
|
+
|
|
8
|
+
export function findAgent(bundle: AgentsBundle, id: string | null | undefined): AgentInfo | undefined {
|
|
9
|
+
return bundle.agents.find((a) => a.id === id);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The human name for an agent id, e.g. "Claude Code" / "Codex". Falls back to
|
|
13
|
+
// the raw id (or a generic "Agent") so a task carrying an unknown/legacy id
|
|
14
|
+
// still labels sensibly.
|
|
15
|
+
export function agentLabel(bundle: AgentsBundle, id: string | null | undefined): string {
|
|
16
|
+
return findAgent(bundle, id)?.label || id || "智能体";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The capability descriptor for a task's agent — drives its model/reasoning/
|
|
20
|
+
// permission pickers and feature gates (asks, cost display). Undefined until the
|
|
21
|
+
// bundle loads or for an unknown id; callers treat undefined as "no data yet".
|
|
22
|
+
export function capsFor(bundle: AgentsBundle, id: string | null | undefined): AgentCapabilities | undefined {
|
|
23
|
+
return findAgent(bundle, id)?.capabilities;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Whether an agent can actually run a turn: connected AND its CLI present on the
|
|
27
|
+
// server. Mirrors isAgentUsable() server-side (lib/agents/connections.ts) so the
|
|
28
|
+
// New-task dialog and POST /api/tasks pick the same agent. `installed`
|
|
29
|
+
// undefined = an older payload that didn't report it; treat as installed rather
|
|
30
|
+
// than hiding every agent.
|
|
31
|
+
export function agentUsable(a: AgentInfo | undefined): boolean {
|
|
32
|
+
return !!a?.authenticated && a.installed !== false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Why an agent can't run right now, as the short suffix pickers show after its
|
|
36
|
+
// label — or null when it's fine. The two states need different words because
|
|
37
|
+
// they need different fixes: "未连接" is a login, "未安装" is a missing CLI on
|
|
38
|
+
// the server that no login will conjure.
|
|
39
|
+
export function agentStateHint(a: AgentInfo | undefined): string | null {
|
|
40
|
+
if (!a) return null;
|
|
41
|
+
if (!a.authenticated) return "未连接";
|
|
42
|
+
if (a.installed === false) return "未安装";
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The agent a new task should default to: the project's default, else the app
|
|
47
|
+
// default — but only when that agent can actually run. Otherwise fall to the
|
|
48
|
+
// first usable agent (a Codex-only instance must not default new tasks to an
|
|
49
|
+
// unconnected — or uninstalled — Claude), and only when NOTHING is usable fall
|
|
50
|
+
// back to mere existence so the picker still renders (with its connect CTA).
|
|
51
|
+
export function defaultAgentFor(bundle: AgentsBundle, projectDefault: string | null | undefined): string {
|
|
52
|
+
const want = findAgent(bundle, projectDefault || bundle.default);
|
|
53
|
+
if (agentUsable(want)) return want!.id;
|
|
54
|
+
const appDefault = findAgent(bundle, bundle.default);
|
|
55
|
+
if (agentUsable(appDefault)) return appDefault!.id;
|
|
56
|
+
const usable = bundle.agents.find((a) => !isOptInAgent(a.id) && agentUsable(a));
|
|
57
|
+
return usable?.id ?? want?.id ?? bundle.agents[0]?.id ?? bundle.default;
|
|
58
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Tiny fetch helpers used throughout the orchestrator client.
|
|
2
|
+
|
|
3
|
+
// Routes report failures as JSON `{ error }` — unwrap that so surfaced messages
|
|
4
|
+
// read "worktree is dirty", not a raw JSON blob (transcript system errors, modal
|
|
5
|
+
// error notes and ErrNote all show this string verbatim).
|
|
6
|
+
async function fail(r: Response): Promise<never> {
|
|
7
|
+
const raw = await r.text();
|
|
8
|
+
let msg = raw || `${r.status} ${r.statusText}`;
|
|
9
|
+
try {
|
|
10
|
+
const j = JSON.parse(raw);
|
|
11
|
+
if (typeof j?.error === "string" && j.error) msg = j.error;
|
|
12
|
+
} catch { /* not JSON — keep the raw body */ }
|
|
13
|
+
throw new Error(msg);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function jget<T>(url: string): Promise<T> {
|
|
17
|
+
const r = await fetch(url, { cache: "no-store" });
|
|
18
|
+
if (!r.ok) await fail(r);
|
|
19
|
+
return r.json();
|
|
20
|
+
}
|
|
21
|
+
export async function jsend<T>(url: string, method: string, body?: unknown): Promise<T> {
|
|
22
|
+
const r = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
|
|
23
|
+
if (!r.ok) await fail(r);
|
|
24
|
+
return r.json();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Save an export (GET /api/tasks/[id]/export, /api/projects/[id]/export) to
|
|
28
|
+
// disk. A `download` anchor rather than fetch + blob: the browser streams the
|
|
29
|
+
// response straight to a file with its own progress UI, however long the
|
|
30
|
+
// history is, and — unlike assigning window.location — it never tears down the
|
|
31
|
+
// page's open event streams. The server names the file (Content-Disposition);
|
|
32
|
+
// the viewer's time zone rides along so the timestamps read in local time.
|
|
33
|
+
export function downloadExport(path: string) {
|
|
34
|
+
const a = document.createElement("a");
|
|
35
|
+
a.href = `${path}?tz=${encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone)}`;
|
|
36
|
+
a.download = "";
|
|
37
|
+
document.body.appendChild(a);
|
|
38
|
+
a.click();
|
|
39
|
+
a.remove();
|
|
40
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { CenterError, createCenterClient, type BoardBackend } from '../../core/client';
|
|
2
|
+
import type { BrowserMessage, BrowserStatus } from '../../core/types';
|
|
3
|
+
import { clientId } from '../../core/id';
|
|
4
|
+
import { taskFileApiUrl } from './fileLinks';
|
|
5
|
+
import { openLive } from './liveStream';
|
|
6
|
+
import { clientFeatures } from './protocol/features';
|
|
7
|
+
/** Node transport for the shared file and browser UI. No center routes or synthetic conversation data. */
|
|
8
|
+
export function createOperatorBoardBackend(fetcher: typeof fetch = (...args) => fetch(...args)): BoardBackend {
|
|
9
|
+
const client = createCenterClient({ baseUrl: '', fetch: async (input, init) => {
|
|
10
|
+
const url = new URL(String(input), 'http://operator.invalid');
|
|
11
|
+
const target = url.pathname.match(/^\/api\/jobs\/([^/]+)\/(.+)$/);
|
|
12
|
+
if (!target) throw new Error('无效的节点看板请求');
|
|
13
|
+
let suffix=target[2];
|
|
14
|
+
if(suffix==='files')url.searchParams.set('format','json');
|
|
15
|
+
suffix=suffix.replace(/^files\/raw/,'files');
|
|
16
|
+
return fetcher(`/api/tasks/${target[1]}/${suffix}${url.search}`, init);
|
|
17
|
+
} });
|
|
18
|
+
const viewers=new Map<string,WebSocket>();
|
|
19
|
+
return { ...client.board!, directLinks: true, browserLive:undefined,
|
|
20
|
+
async info(id) {
|
|
21
|
+
const response=await fetcher(`/api/tasks/${encodeURIComponent(id)}`,{cache:'no-store'});
|
|
22
|
+
if(!response.ok)throw new CenterError('无法读取任务看板',`HTTP_${response.status}`,response.status);
|
|
23
|
+
const task=await response.json();return{job_id:id,dispatched:!!task.started||!!task.worktree_path,files:true,browser:clientFeatures().browser,remote_task_id:id};
|
|
24
|
+
},
|
|
25
|
+
fileUrl(id, path, options) { const url = taskFileApiUrl(id, path); return options?.download ? `${url}${url.includes('?') ? '&' : '?'}download=1` : url; },
|
|
26
|
+
fileLinkBase: async id => `/api/tasks/${encodeURIComponent(id)}/files`,
|
|
27
|
+
watchFiles(id,handlers) {
|
|
28
|
+
let stopped=false,ended=false;
|
|
29
|
+
const disrupted=(reason:string)=>{if(!stopped&&!ended)handlers.onEvent({type:'resync',reason,complete:false});};
|
|
30
|
+
const subscription=openLive(`/api/tasks/${encodeURIComponent(id)}/files/events`,{
|
|
31
|
+
onMessage:data=>{
|
|
32
|
+
if(stopped||ended)return;
|
|
33
|
+
try{const event=JSON.parse(data);if(['snapshot','changes','resync'].includes(event.type))handlers.onEvent(event);}catch{/* Ignore invalid event. */}
|
|
34
|
+
},
|
|
35
|
+
onEnd:code=>{
|
|
36
|
+
if(stopped||ended)return;
|
|
37
|
+
if(code===0||code===408||code===429||(code>=500&&code!==501)){disrupted(code===429?'文件监听暂时限流,正在等待恢复':'文件监听连接中断,正在重连');return;}
|
|
38
|
+
ended=true;
|
|
39
|
+
const message=code===401?'文件监听登录已过期,请重新登录':code===403?'文件监听被拒绝,请检查任务权限或登录':code===404?'未找到任务或节点不支持文件事件接口':`文件监听请求失败(HTTP ${code})`;
|
|
40
|
+
handlers.onClose(new CenterError(message,`HTTP_${code}`,code));
|
|
41
|
+
},
|
|
42
|
+
onError:error=>disrupted(error.message),
|
|
43
|
+
});
|
|
44
|
+
return()=>{stopped=true;subscription.close();};
|
|
45
|
+
},
|
|
46
|
+
browserEvents(id,handlers) {
|
|
47
|
+
let stopped=false,ended=false;
|
|
48
|
+
const disrupted=(error:CenterError)=>{if(!stopped&&!ended)handlers.onError?.(error);};
|
|
49
|
+
const sub=openLive(`/api/tasks/${encodeURIComponent(id)}/browser/events`,{
|
|
50
|
+
onMessage:data=>{if(stopped||ended)return;try{const status=JSON.parse(data) as BrowserStatus;if(status.state)handlers.onStatus(status);}catch{/* Keep last valid status. */}},
|
|
51
|
+
onEnd:code=>{
|
|
52
|
+
if(stopped||ended)return;
|
|
53
|
+
const error=new CenterError(code===401?'浏览器状态登录已过期':code===403?'没有读取浏览器状态的权限':code===404?'未找到任务或浏览器状态接口':`浏览器状态连接中断(HTTP ${code})`,`HTTP_${code}`,code);
|
|
54
|
+
if(code===0||code===408||code===429||(code>=500&&code!==501)){disrupted(error);return;}
|
|
55
|
+
ended=!!handlers.onClose;handlers.onClose?.(error);
|
|
56
|
+
},
|
|
57
|
+
onError:error=>disrupted(new CenterError(error.message,'NETWORK',0)),
|
|
58
|
+
});return()=>{stopped=true;sub.close();};
|
|
59
|
+
},
|
|
60
|
+
browserFrames(id,handlers) {
|
|
61
|
+
const socket=new WebSocket(`${location.protocol==='https:'?'wss:':'ws:'}//${location.host}/browser/${encodeURIComponent(id)}/stream`);
|
|
62
|
+
const viewer=clientId();let stopped=false;
|
|
63
|
+
viewers.set(viewer,socket);
|
|
64
|
+
const timeout=setTimeout(()=>{if(socket.readyState!==WebSocket.OPEN)socket.close();},10000);
|
|
65
|
+
socket.onopen=()=>{clearTimeout(timeout);if(!stopped)handlers.onViewer(viewer);};
|
|
66
|
+
socket.onmessage=event=>{if(stopped)return;try{handlers.onMessage(JSON.parse(String(event.data)) as BrowserMessage);}catch{/* Next frame can recover. */}};
|
|
67
|
+
socket.onerror=()=>socket.close();
|
|
68
|
+
socket.onclose=event=>{clearTimeout(timeout);viewers.delete(viewer);if(!stopped)handlers.onClose?.(new CenterError(event.reason||'浏览器画面连接已断开,正在重连','NETWORK',0));};
|
|
69
|
+
return()=>{stopped=true;clearTimeout(timeout);viewers.delete(viewer);socket.close();};
|
|
70
|
+
},
|
|
71
|
+
async browserInput(_id,viewer,events) {
|
|
72
|
+
const socket=viewers.get(viewer);
|
|
73
|
+
if(!socket||socket.readyState!==WebSocket.OPEN)throw new CenterError('浏览器画面尚未连接,请稍后重试','NETWORK',0);
|
|
74
|
+
for(const event of events)socket.send(JSON.stringify(event));
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let sharedBoardBackend:BoardBackend|undefined;
|
|
80
|
+
export function getOperatorBoardBackend():BoardBackend { return sharedBoardBackend??=createOperatorBoardBackend(); }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { notifyCallback } from '../../core/callbacks';
|
|
3
|
+
import type { WorkspaceHandlers } from '../../core/provider';
|
|
4
|
+
import type { WorkspaceChangeEvent } from '../../core/types';
|
|
5
|
+
import { cloneBoardView } from '../../board/context';
|
|
6
|
+
import type { CliEvent } from '../../core/store';
|
|
7
|
+
import { cloneWorkspaceChange, subscribeWorkspaceFiles } from '../../board/fileWatch';
|
|
8
|
+
import { getOperatorBoardBackend } from './boardBackend';
|
|
9
|
+
import { readBoardState, subscribeBoardState } from './boardState';
|
|
10
|
+
export type OperatorBoardHandlers=WorkspaceHandlers&{onEvent?:(event:CliEvent)=>void};
|
|
11
|
+
export function useOperatorFileWatch(taskId:string,enabled:boolean,handlers:OperatorBoardHandlers={},onChange?:(event:WorkspaceChangeEvent)=>void,intervalMs?:number){
|
|
12
|
+
const current=useRef({handlers,onChange});current.current={handlers,onChange};
|
|
13
|
+
useEffect(()=>{
|
|
14
|
+
if(!enabled)return;
|
|
15
|
+
return subscribeWorkspaceFiles(getOperatorBoardBackend(),taskId,event=>{current.current.onChange?.(event);notifyCallback(current.current.handlers.onFilesChange,cloneWorkspaceChange(event));notifyCallback(current.current.handlers.onEvent,cloneWorkspaceChange(event));},
|
|
16
|
+
{intervalMs,onStatus:status=>{notifyCallback(current.current.handlers.onFilesWatch,{...status});notifyCallback(current.current.handlers.onEvent,{...status});}});
|
|
17
|
+
},[taskId,enabled,intervalMs]);
|
|
18
|
+
}
|
|
19
|
+
export function useOperatorContextEvents(taskId:string,enabled:boolean,handlers:OperatorBoardHandlers={}){
|
|
20
|
+
const current=useRef(handlers);current.current=handlers;
|
|
21
|
+
useEffect(()=>{if(!enabled)return;return subscribeBoardState(taskId,()=>{const view=readBoardState(taskId);if(!view)return;const event={type:'board_context' as const,jobId:taskId,view:cloneBoardView(view)};notifyCallback(current.current.onBoardContextChange,{...event,view:cloneBoardView(view)});notifyCallback(current.current.onEvent,event);});},[taskId,enabled]);
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
import type { WorkspaceHandlers } from '../../core/provider';
|
|
3
|
+
import type { CliEvent } from '../../core/store';
|
|
4
|
+
import type { Quote } from './composerDraft';
|
|
5
|
+
export type OperatorBoardTab = 'files' | 'changes' | 'context' | 'preview' | 'browser';
|
|
6
|
+
export interface OperatorBoardOptions extends WorkspaceHandlers { watchFiles?:boolean; fileWatchIntervalMs?:number; onEvent?:(event:CliEvent)=>void; tabs?: OperatorBoardTab[]; availableTabs?: OperatorBoardTab[]; defaultTab?: OperatorBoardTab; autoContext?: boolean }
|
|
7
|
+
export type OperatorBoardTarget = { kind: 'file'; taskId: string; path: string } | { kind: 'browser'; taskId: string };
|
|
8
|
+
export interface OperatorBoardRequest { sequence: number; target: OperatorBoardTarget }
|
|
9
|
+
export const OperatorBoardContext = createContext<((target: OperatorBoardTarget) => boolean) | null>(null);
|
|
10
|
+
|
|
11
|
+
export const OperatorBoardQuoteContext = createContext<((quote:Quote)=>void)|null>(null);
|
|
12
|
+
|
|
13
|
+
const surfaces=new Set<(target:OperatorBoardTarget)=>boolean>();
|
|
14
|
+
export function registerOperatorBoard(surface:(target:OperatorBoardTarget)=>boolean):()=>void {surfaces.add(surface);return()=>{surfaces.delete(surface);};}
|
|
15
|
+
export function openOperatorBoard(target:OperatorBoardTarget):boolean {for(const surface of [...surfaces].reverse())if(surface(target))return true;return false;}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import type { BoardFile, BoardView } from '../../board/workspace';
|
|
3
|
+
import { rememberBoardFile, cloneBoardView, reportBoardOwner, activateBoardOwner, selectedBoardOwner } from '../../board/context';
|
|
4
|
+
import { notifyCallback } from '../../core/callbacks';
|
|
5
|
+
const cache=new Map<string,{stamp:string;view:BoardView|null}>();
|
|
6
|
+
const listeners=new Map<string,Set<()=>void>>();
|
|
7
|
+
const owners=new Map<string,Map<string,BoardView>>();
|
|
8
|
+
const key=(id:string)=>`cli-ui:operator-board:${id}`;
|
|
9
|
+
const stamp=(id:string)=>{try{return sessionStorage.getItem(key(id))??'';}catch{return 'blocked';}};
|
|
10
|
+
const validFile=(value:unknown):value is BoardFile=>!!value&&typeof value==='object'&&typeof (value as BoardFile).path==='string'&&!!(value as BoardFile).path&&((value as BoardFile).sourceId===undefined||typeof (value as BoardFile).sourceId==='string');
|
|
11
|
+
const recent=(items:BoardFile[]=[])=>{
|
|
12
|
+
return items.filter(validFile).reduce((history,file)=>rememberBoardFile(history,file),[] as BoardFile[]);
|
|
13
|
+
};
|
|
14
|
+
export function readBoardState(taskId:string):BoardView|null {
|
|
15
|
+
const saved=stamp(taskId),known=cache.get(taskId);if(known?.stamp===saved)return known.view;
|
|
16
|
+
let view:BoardView|null=null;
|
|
17
|
+
try{
|
|
18
|
+
const value=JSON.parse(saved||'null');
|
|
19
|
+
if(value&&typeof value.tab==='string'&&typeof value.dir==='string'&&Array.isArray(value.files))view={...value,file:typeof value.file==='string'?value.file:null,files:value.files.filter((path:unknown)=>typeof path==='string'),current:validFile(value.current)?{...value.current}:null,opened:recent(value.opened),history:recent(value.history),visible:false};
|
|
20
|
+
}catch{/* Broken preferences cannot stop the workspace. */}
|
|
21
|
+
cache.set(taskId,{stamp:saved,view});return view;
|
|
22
|
+
}
|
|
23
|
+
export function subscribeBoardState(taskId:string,listener:()=>void):()=>void {
|
|
24
|
+
const set=listeners.get(taskId)??new Set<()=>void>();set.add(listener);listeners.set(taskId,set);return()=>{set.delete(listener);if(!set.size)listeners.delete(taskId);};
|
|
25
|
+
}
|
|
26
|
+
export function publishBoardState(taskId:string,view:BoardView):void {
|
|
27
|
+
const next=cloneBoardView({...view,current:view.current??null,opened:recent(view.opened),history:recent(view.history)});
|
|
28
|
+
if(JSON.stringify(readBoardState(taskId))===JSON.stringify(next))return;
|
|
29
|
+
try{sessionStorage.setItem(key(taskId),JSON.stringify(next));}catch{/* In-memory state still reaches Chat. */}
|
|
30
|
+
cache.set(taskId,{stamp:stamp(taskId),view:next});for(const listener of [...listeners.get(taskId)??[]])notifyCallback(listener,undefined);
|
|
31
|
+
}
|
|
32
|
+
export function useBoardState(taskId:string):BoardView|null {
|
|
33
|
+
return useSyncExternalStore(listener=>subscribeBoardState(taskId,listener),()=>readBoardState(taskId),()=>null);
|
|
34
|
+
}
|
|
35
|
+
export function visitBoardFile(taskId:string,file:BoardFile):void {
|
|
36
|
+
const current=readBoardState(taskId)??{tab:'files',dir:'',file:null,files:[]};
|
|
37
|
+
publishBoardState(taskId,{...current,history:recent([...(current.history??[]),file])});
|
|
38
|
+
}
|
|
39
|
+
export function reportBoardState(taskId:string,owner:string,view:BoardView):void {
|
|
40
|
+
const sources=owners.get(taskId)??new Map<string,BoardView>();
|
|
41
|
+
const history=readBoardState(taskId)?.history??view.history??[];
|
|
42
|
+
const selected=reportBoardOwner(sources,owner,{...view,history});owners.set(taskId,sources);
|
|
43
|
+
if(selected)publishBoardState(taskId,{...selected[1],history});
|
|
44
|
+
}
|
|
45
|
+
export function activateBoardState(taskId:string,owner:string):void {
|
|
46
|
+
const sources=owners.get(taskId);if(!sources)return;
|
|
47
|
+
const selected=activateBoardOwner(sources,owner);if(!selected)return;
|
|
48
|
+
const previous=readBoardState(taskId),file=selected[1].current;
|
|
49
|
+
const history=file?rememberBoardFile(previous?.history??[],file):previous?.history??[];
|
|
50
|
+
publishBoardState(taskId,{...selected[1],history});
|
|
51
|
+
}
|
|
52
|
+
export function releaseBoardState(taskId:string,owner:string):void {
|
|
53
|
+
const sources=owners.get(taskId);sources?.delete(owner);if(!sources?.size)owners.delete(taskId);
|
|
54
|
+
const previous=readBoardState(taskId);if(!previous)return;
|
|
55
|
+
const remaining=sources?.size?selectedBoardOwner(sources)?.[1]:null;
|
|
56
|
+
publishBoardState(taskId,remaining?{...remaining,history:previous.history,visible:remaining.visible!==false}:{...previous,visible:false});
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createWorkspace } from '../../board/workspace';
|
|
2
|
+
import { cloneBoardView } from '../../board/context';
|
|
3
|
+
import { getOperatorBoardBackend } from './boardBackend';
|
|
4
|
+
import { readBoardState } from './boardState';
|
|
5
|
+
import { openOperatorBoard } from './boardInteraction';
|
|
6
|
+
/** Host API for the node workspace. Uses the same cloud-facing file API as the board. */
|
|
7
|
+
export function createOperatorWorkspace(taskId:string){
|
|
8
|
+
return createWorkspace({backend:getOperatorBoardBackend(),key:taskId,
|
|
9
|
+
view:()=>{const view=readBoardState(taskId);return view?.visible?cloneBoardView(view):undefined;},
|
|
10
|
+
context:()=>{const view=readBoardState(taskId);return view?cloneBoardView(view):null;},
|
|
11
|
+
open:path=>openOperatorBoard({kind:'file',taskId,path})});
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../branchFlow";
|