@spzxg/cli-ui 0.6.2 → 0.6.6
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 +49 -0
- package/README.md +165 -19
- package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
- package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-b8wF7wrN.js} +2 -2
- package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
- package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
- package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
- package/dist/cli-ui.css +32 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +17 -10
- package/src/board/BrowserPane.tsx +346 -115
- package/src/board/ChangesPane.tsx +2 -2
- package/src/board/CliBoard.tsx +79 -50
- package/src/board/FileViewer.tsx +54 -31
- 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 +94 -0
- package/src/board/folderDownload.ts +30 -14
- package/src/board/frameQuote.ts +2 -2
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +9 -7
- package/src/board/workspace.ts +53 -14
- package/src/chat/CliChat.tsx +93 -40
- package/src/chat/Composer.tsx +44 -42
- 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 +17 -6
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +103 -19
- package/src/core/conversation.ts +4 -3
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +33 -0
- package/src/core/provider.tsx +31 -14
- package/src/core/store.ts +151 -53
- package/src/core/types.ts +64 -9
- 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/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +19 -13
- 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 +468 -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 +82 -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 +280 -0
- package/src/operator.base.css +1806 -0
- package/src/operator.css +194 -16
- package/src/styles.css +49 -1
- package/src/ui/Markdown.tsx +7 -2
- package/src/ui/Popover.tsx +82 -0
- package/src/ui/TextBlock.tsx +3 -3
- package/src/ui/Transcript.tsx +17 -10
- 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,530 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { Skel, ErrNote } from "./shared";
|
|
5
|
+
|
|
6
|
+
interface DiffFile {
|
|
7
|
+
path: string;
|
|
8
|
+
status: string;
|
|
9
|
+
additions: number;
|
|
10
|
+
deletions: number;
|
|
11
|
+
binary: boolean;
|
|
12
|
+
patch: string;
|
|
13
|
+
truncated?: boolean;
|
|
14
|
+
}
|
|
15
|
+
interface DiffResp {
|
|
16
|
+
isolated: boolean;
|
|
17
|
+
reason?: string;
|
|
18
|
+
branch?: string;
|
|
19
|
+
baseLabel?: string;
|
|
20
|
+
merged_at?: number;
|
|
21
|
+
alreadyMerged?: boolean;
|
|
22
|
+
files: DiffFile[];
|
|
23
|
+
isDirty: boolean;
|
|
24
|
+
ahead: number;
|
|
25
|
+
error?: string;
|
|
26
|
+
mergeInProgress?: boolean; // a conflict resolution is staged, awaiting accept/discard
|
|
27
|
+
unresolved?: string[]; // files still flagged unmerged
|
|
28
|
+
}
|
|
29
|
+
interface MergeResp {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
targetBranch: string;
|
|
32
|
+
committed: boolean;
|
|
33
|
+
alreadyMerged?: boolean;
|
|
34
|
+
conflicts?: string[];
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
// Returned by the AI-resolution callback wired from the parent (it runs the
|
|
38
|
+
// /prepare step + streams the resolution turn into the transcript).
|
|
39
|
+
export interface ResolveResult {
|
|
40
|
+
ok: boolean;
|
|
41
|
+
merged?: boolean; // trial merge was clean and landed immediately
|
|
42
|
+
error?: string;
|
|
43
|
+
conflicts?: string[];
|
|
44
|
+
binaryConflicts?: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const STATUS_LABEL: Record<string, string> = { A: "新增", M: "修改", D: "删除", R: "重命名", "?": "未跟踪" };
|
|
48
|
+
|
|
49
|
+
// The merge routes always answer JSON, but a layer above them can still hand
|
|
50
|
+
// back HTML (a tunnel 502, a request killed at maxDuration) — parse defensively
|
|
51
|
+
// so the banner shows the HTTP status, not JSON.parse's "Unexpected token '<'".
|
|
52
|
+
const mergeJson = (r: Response): Promise<MergeResp> =>
|
|
53
|
+
r.json().catch(() => ({ ok: false, targetBranch: "", committed: false, error: `合并请求失败(HTTP ${r.status})` }));
|
|
54
|
+
|
|
55
|
+
// Last fetched diff per task, module-level so it survives unmounts. The rail
|
|
56
|
+
// remounts this component on every collapse/expand, DIFF↔CONTEXT tab switch,
|
|
57
|
+
// and chat/changes toggle — without a cache each of those pays a fresh
|
|
58
|
+
// diff-endpoint round trip behind a skeleton. With it, reopening renders the
|
|
59
|
+
// previous diff instantly and revalidates in the background.
|
|
60
|
+
const diffCache = new Map<string, DiffResp>();
|
|
61
|
+
|
|
62
|
+
// Strip the file-metadata preamble (diff --git / index / --- / +++) and return
|
|
63
|
+
// just the hunk lines, the way GitHub shows them.
|
|
64
|
+
function hunkLines(patch: string): string[] {
|
|
65
|
+
const lines = patch.split("\n");
|
|
66
|
+
const i = lines.findIndex((l) => l.startsWith("@@"));
|
|
67
|
+
return i >= 0 ? lines.slice(i) : [];
|
|
68
|
+
}
|
|
69
|
+
function lineClass(line: string): string {
|
|
70
|
+
if (line.startsWith("@@")) return "hunk";
|
|
71
|
+
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
|
|
72
|
+
if (line.startsWith("-") && !line.startsWith("---")) return "del";
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Files past this many hunk lines start collapsed: a diff with many files near
|
|
77
|
+
// the per-file patch cap would otherwise mount tens of thousands of line divs
|
|
78
|
+
// in one commit and jank the main thread when the rail opens.
|
|
79
|
+
const COLLAPSE_LINES = 400;
|
|
80
|
+
|
|
81
|
+
// One file section. Memoized so the scroll tracker's setActive (which fires on
|
|
82
|
+
// every scroll frame) re-renders only the overview list, not every hunk line
|
|
83
|
+
// of every file.
|
|
84
|
+
const FileDiff = memo(function FileDiff({
|
|
85
|
+
file: f,
|
|
86
|
+
userToggled,
|
|
87
|
+
onToggle,
|
|
88
|
+
refs,
|
|
89
|
+
}: {
|
|
90
|
+
file: DiffFile;
|
|
91
|
+
userToggled: boolean; // user flipped this file away from its default state
|
|
92
|
+
onToggle: (path: string) => void;
|
|
93
|
+
refs: { current: Record<string, HTMLDivElement | null> };
|
|
94
|
+
}) {
|
|
95
|
+
const lines = useMemo(() => (f.binary ? [] : hunkLines(f.patch)), [f]);
|
|
96
|
+
const big = lines.length > COLLAPSE_LINES;
|
|
97
|
+
const isCollapsed = userToggled ? !big : big;
|
|
98
|
+
// Accurate placeholder height for content-visibility while the section is
|
|
99
|
+
// offscreen-unrendered (header ≈34px, hunk lines 12px × 1.55 line-height),
|
|
100
|
+
// so offsetTop-based jump/scroll-spy stay truthful. `auto` pins the real
|
|
101
|
+
// size once rendered.
|
|
102
|
+
const est = Math.round(34 + (isCollapsed ? 38 : Math.max(1, lines.length) * 18.6));
|
|
103
|
+
return (
|
|
104
|
+
<div
|
|
105
|
+
className="tc-file"
|
|
106
|
+
style={{ containIntrinsicSize: `auto ${est}px` }}
|
|
107
|
+
ref={(el) => { refs.current[f.path] = el; }}
|
|
108
|
+
>
|
|
109
|
+
<button className="tc-fhead" onClick={() => onToggle(f.path)}>
|
|
110
|
+
<span className={`tc-chev ${isCollapsed ? "" : "open"}`}>▸</span>
|
|
111
|
+
<span className={`tc-st s-${f.status === "?" ? "new" : f.status}`}>{f.status}</span>
|
|
112
|
+
<span className="tc-fpath">{f.path}</span>
|
|
113
|
+
<span className="tc-cnt">
|
|
114
|
+
<b className="add">+{f.additions}</b> <b className="del">−{f.deletions}</b>
|
|
115
|
+
</span>
|
|
116
|
+
</button>
|
|
117
|
+
{isCollapsed ? (
|
|
118
|
+
// A big file collapsed by default still needs to say why it's empty.
|
|
119
|
+
big && (
|
|
120
|
+
<button className="tc-bigdiff" onClick={() => onToggle(f.path)}>
|
|
121
|
+
大型 diff({lines.length.toLocaleString()} 行)—— 点击展开
|
|
122
|
+
</button>
|
|
123
|
+
)
|
|
124
|
+
) : (
|
|
125
|
+
<div className="tc-hunks">
|
|
126
|
+
{f.binary ? (
|
|
127
|
+
<div className="tc-empty">二进制文件 —— 不予显示</div>
|
|
128
|
+
) : lines.length === 0 ? (
|
|
129
|
+
<div className="tc-empty">没有文本改动(仅权限或重命名)。</div>
|
|
130
|
+
) : (
|
|
131
|
+
lines.map((ln, i) => (
|
|
132
|
+
<div key={i} className={`dl ${lineClass(ln)}`}>
|
|
133
|
+
{ln || " "}
|
|
134
|
+
</div>
|
|
135
|
+
))
|
|
136
|
+
)}
|
|
137
|
+
{f.truncated && <div className="tc-empty">… 文件 diff 已截断</div>}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
export default function TaskChanges({
|
|
145
|
+
taskId,
|
|
146
|
+
running,
|
|
147
|
+
prUrl,
|
|
148
|
+
onMerged,
|
|
149
|
+
onPrCreated,
|
|
150
|
+
onResolveWithAI,
|
|
151
|
+
}: {
|
|
152
|
+
taskId: string;
|
|
153
|
+
running?: boolean;
|
|
154
|
+
prUrl?: string; // GitHub PR already opened from this branch ("" / undefined = none)
|
|
155
|
+
onMerged?: () => void;
|
|
156
|
+
onPrCreated?: (url: string) => void;
|
|
157
|
+
onResolveWithAI?: (taskId: string) => Promise<ResolveResult>;
|
|
158
|
+
}) {
|
|
159
|
+
const [data, setData] = useState<DiffResp | null>(() => diffCache.get(taskId) ?? null);
|
|
160
|
+
const [loading, setLoading] = useState(true);
|
|
161
|
+
const [merging, setMerging] = useState(false);
|
|
162
|
+
const [prBusy, setPrBusy] = useState(false);
|
|
163
|
+
const [prErr, setPrErr] = useState<string | null>(null);
|
|
164
|
+
const [resolving, setResolving] = useState(false);
|
|
165
|
+
const [manualOpen, setManualOpen] = useState(false);
|
|
166
|
+
const [binaryConflicts, setBinaryConflicts] = useState<string[]>([]);
|
|
167
|
+
const [mergeRes, setMergeRes] = useState<MergeResp | null>(null);
|
|
168
|
+
const [active, setActive] = useState<string | null>(null);
|
|
169
|
+
// Paths the user flipped away from their default state (expanded normally,
|
|
170
|
+
// collapsed for big files) — override semantics so a background revalidate
|
|
171
|
+
// doesn't reset the user's choices.
|
|
172
|
+
const [toggled, setToggled] = useState<Set<string>>(new Set());
|
|
173
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
174
|
+
const secRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
|
175
|
+
|
|
176
|
+
const load = useCallback(async () => {
|
|
177
|
+
setLoading(true);
|
|
178
|
+
try {
|
|
179
|
+
const r = await fetch(`/api/tasks/${taskId}/diff`, { cache: "no-store" });
|
|
180
|
+
const j: DiffResp = await r.json();
|
|
181
|
+
if (!j.error) diffCache.set(taskId, j); // errors are worth retrying, not replaying
|
|
182
|
+
setData(j);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
setData({ isolated: false, files: [], isDirty: false, ahead: 0, error: e instanceof Error ? e.message : String(e) });
|
|
185
|
+
} finally {
|
|
186
|
+
setLoading(false);
|
|
187
|
+
}
|
|
188
|
+
}, [taskId]);
|
|
189
|
+
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
setMergeRes(null);
|
|
192
|
+
setPrErr(null);
|
|
193
|
+
setToggled(new Set());
|
|
194
|
+
setManualOpen(false);
|
|
195
|
+
setBinaryConflicts([]);
|
|
196
|
+
// Task switched without a remount: show the new task's cached diff (or the
|
|
197
|
+
// skeleton), never the previous task's stale files, while we revalidate.
|
|
198
|
+
setData(diffCache.get(taskId) ?? null);
|
|
199
|
+
load();
|
|
200
|
+
}, [taskId, load]);
|
|
201
|
+
|
|
202
|
+
// The diff moves while the agent works — refetch when a turn finishes so a
|
|
203
|
+
// just-written change appears without a manual Refresh (same trigger the
|
|
204
|
+
// SyncBanner uses). Only on the running→idle transition; mount already loads.
|
|
205
|
+
const wasRunning = useRef(running);
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (wasRunning.current && !running) load();
|
|
208
|
+
wasRunning.current = running;
|
|
209
|
+
}, [running, load]);
|
|
210
|
+
|
|
211
|
+
// Track which file is at the top of the scroll area to highlight it in the list.
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
const sc = scrollRef.current;
|
|
214
|
+
if (!sc || !data?.files?.length) return;
|
|
215
|
+
let raf = 0;
|
|
216
|
+
const onScroll = () => {
|
|
217
|
+
cancelAnimationFrame(raf);
|
|
218
|
+
raf = requestAnimationFrame(() => {
|
|
219
|
+
const top = sc.scrollTop + 16;
|
|
220
|
+
let cur = data.files[0]?.path ?? null;
|
|
221
|
+
for (const f of data.files) {
|
|
222
|
+
const el = secRefs.current[f.path];
|
|
223
|
+
if (el && el.offsetTop <= top) cur = f.path;
|
|
224
|
+
}
|
|
225
|
+
setActive(cur);
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
sc.addEventListener("scroll", onScroll, { passive: true });
|
|
229
|
+
onScroll();
|
|
230
|
+
return () => {
|
|
231
|
+
sc.removeEventListener("scroll", onScroll);
|
|
232
|
+
cancelAnimationFrame(raf);
|
|
233
|
+
};
|
|
234
|
+
}, [data]);
|
|
235
|
+
|
|
236
|
+
const jump = (path: string) => {
|
|
237
|
+
const el = secRefs.current[path];
|
|
238
|
+
const sc = scrollRef.current;
|
|
239
|
+
if (!el || !sc) return;
|
|
240
|
+
setActive(path);
|
|
241
|
+
// content-visibility placeholders make offsetTop an estimate until the
|
|
242
|
+
// sections near the target actually render, and that rendering lags the
|
|
243
|
+
// scroll by a frame or two — so instead of one smooth scroll to a
|
|
244
|
+
// coordinate that goes stale mid-flight, jump instantly and keep
|
|
245
|
+
// re-targeting for a few frames while the layout settles.
|
|
246
|
+
let frames = 0;
|
|
247
|
+
const settle = () => {
|
|
248
|
+
const top = Math.max(0, el.offsetTop - 4);
|
|
249
|
+
if (Math.abs(sc.scrollTop - top) > 1) sc.scrollTop = top;
|
|
250
|
+
if (++frames < 12) requestAnimationFrame(settle);
|
|
251
|
+
};
|
|
252
|
+
settle();
|
|
253
|
+
};
|
|
254
|
+
const toggle = useCallback(
|
|
255
|
+
(path: string) =>
|
|
256
|
+
setToggled((s) => {
|
|
257
|
+
const n = new Set(s);
|
|
258
|
+
n.has(path) ? n.delete(path) : n.add(path);
|
|
259
|
+
return n;
|
|
260
|
+
}),
|
|
261
|
+
[]
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const doMerge = async () => {
|
|
265
|
+
setMerging(true);
|
|
266
|
+
setMergeRes(null);
|
|
267
|
+
try {
|
|
268
|
+
const r = await fetch(`/api/tasks/${taskId}/merge`, { method: "POST" });
|
|
269
|
+
const res = await mergeJson(r);
|
|
270
|
+
setMergeRes(res);
|
|
271
|
+
if (res.ok) {
|
|
272
|
+
onMerged?.();
|
|
273
|
+
load();
|
|
274
|
+
}
|
|
275
|
+
} catch (e) {
|
|
276
|
+
setMergeRes({ ok: false, targetBranch: "", committed: false, error: e instanceof Error ? e.message : String(e) });
|
|
277
|
+
} finally {
|
|
278
|
+
setMerging(false);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// Review-on-GitHub path: push the branch + open (or update) a PR. The server
|
|
283
|
+
// commits any dirty work first and is idempotent, so a second click on an
|
|
284
|
+
// already-open PR just pushes the new commits to it.
|
|
285
|
+
const doCreatePr = async () => {
|
|
286
|
+
setPrBusy(true);
|
|
287
|
+
setPrErr(null);
|
|
288
|
+
try {
|
|
289
|
+
const r = await fetch(`/api/tasks/${taskId}/pr`, { method: "POST" });
|
|
290
|
+
const res: { ok?: boolean; url?: string; error?: string } = await r.json();
|
|
291
|
+
if (res.ok && res.url) onPrCreated?.(res.url);
|
|
292
|
+
else setPrErr(res.error || "无法创建 PR");
|
|
293
|
+
} catch (e) {
|
|
294
|
+
setPrErr(e instanceof Error ? e.message : String(e));
|
|
295
|
+
} finally {
|
|
296
|
+
setPrBusy(false);
|
|
297
|
+
load(); // the push may have committed dirty work — refresh the diff state
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// Fix with AI: prepare the trial merge + stream a resolution turn (handled by
|
|
302
|
+
// the parent so it shows in the transcript), then reload into review state.
|
|
303
|
+
const doResolveWithAI = async () => {
|
|
304
|
+
if (!onResolveWithAI) return;
|
|
305
|
+
setResolving(true);
|
|
306
|
+
setManualOpen(false);
|
|
307
|
+
setMergeRes(null);
|
|
308
|
+
try {
|
|
309
|
+
const res = await onResolveWithAI(taskId);
|
|
310
|
+
setBinaryConflicts(res.binaryConflicts ?? []);
|
|
311
|
+
if (res.merged) onMerged?.();
|
|
312
|
+
else if (!res.ok)
|
|
313
|
+
setMergeRes({ ok: false, targetBranch: "", committed: false, error: res.error || "AI 解决冲突失败" });
|
|
314
|
+
} finally {
|
|
315
|
+
setResolving(false);
|
|
316
|
+
load(); // reload → mergeInProgress review state (Accept/Discard) or merged
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// Accept a resolution: commit + land the (now clean) branch into the base.
|
|
321
|
+
const doComplete = async () => {
|
|
322
|
+
setMerging(true);
|
|
323
|
+
try {
|
|
324
|
+
const r = await fetch(`/api/tasks/${taskId}/merge/complete`, { method: "POST" });
|
|
325
|
+
const res = await mergeJson(r);
|
|
326
|
+
setMergeRes(res);
|
|
327
|
+
if (res.ok) onMerged?.();
|
|
328
|
+
} catch (e) {
|
|
329
|
+
setMergeRes({ ok: false, targetBranch: "", committed: false, error: e instanceof Error ? e.message : String(e) });
|
|
330
|
+
} finally {
|
|
331
|
+
setMerging(false);
|
|
332
|
+
load();
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// Discard a resolution: abort the trial merge, back to a clean worktree.
|
|
337
|
+
const doAbort = async () => {
|
|
338
|
+
setMerging(true);
|
|
339
|
+
try {
|
|
340
|
+
await fetch(`/api/tasks/${taskId}/merge/abort`, { method: "POST" });
|
|
341
|
+
setMergeRes(null);
|
|
342
|
+
setBinaryConflicts([]);
|
|
343
|
+
} finally {
|
|
344
|
+
setMerging(false);
|
|
345
|
+
load();
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
if (loading && !data) {
|
|
350
|
+
// Diffing shells out to git — sketch the toolbar + file list so the tab
|
|
351
|
+
// reads "computing the diff", not "empty".
|
|
352
|
+
return (
|
|
353
|
+
<div className="tc-root" aria-hidden>
|
|
354
|
+
<div className="tc-bar">
|
|
355
|
+
<Skel w={150} h={13} />
|
|
356
|
+
<Skel w={70} h={11} />
|
|
357
|
+
<span className="tc-spacer" />
|
|
358
|
+
<Skel w={130} h={26} r="var(--r-sm)" />
|
|
359
|
+
</div>
|
|
360
|
+
<div className="tc-scroll">
|
|
361
|
+
<div className="tc-list">
|
|
362
|
+
{[62, 44, 54].map((w, i) => (
|
|
363
|
+
<div key={i} className="skel-lrow">
|
|
364
|
+
<Skel w={13} h={13} r={4} />
|
|
365
|
+
<Skel w={`${w}%`} h={11} />
|
|
366
|
+
<span style={{ flex: 1 }} />
|
|
367
|
+
<Skel w={46} h={10} />
|
|
368
|
+
</div>
|
|
369
|
+
))}
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
if (!data) return <div className="tc-note">暂无数据。</div>;
|
|
376
|
+
if (data.error) return <div className="tc-note"><ErrNote onRetry={load}>{data.error}</ErrNote></div>;
|
|
377
|
+
if (!data.isolated) return <div className="tc-note">{data.reason || "该任务没有独立分支。"}</div>;
|
|
378
|
+
|
|
379
|
+
const merged = !!data.merged_at || !!data.alreadyMerged;
|
|
380
|
+
const totalAdd = data.files.reduce((n, f) => n + f.additions, 0);
|
|
381
|
+
const totalDel = data.files.reduce((n, f) => n + f.deletions, 0);
|
|
382
|
+
const nothing = data.files.length === 0;
|
|
383
|
+
// Something to merge if the branch isn't fully in the base branch yet, or
|
|
384
|
+
// there are uncommitted edits in the worktree. `alreadyMerged` also catches
|
|
385
|
+
// merges done outside the app, so an already-landed branch won't re-offer.
|
|
386
|
+
const pending = !data.alreadyMerged || data.isDirty;
|
|
387
|
+
// A conflict resolution is staged in the worktree, awaiting accept/discard.
|
|
388
|
+
const reviewing = !!data.mergeInProgress;
|
|
389
|
+
|
|
390
|
+
return (
|
|
391
|
+
<div className="tc-root">
|
|
392
|
+
<div className="tc-bar">
|
|
393
|
+
<code className="tc-branch">{data.branch}</code>
|
|
394
|
+
<span className="tc-arrow">→ {data.baseLabel}</span>
|
|
395
|
+
{!nothing && (
|
|
396
|
+
<span className="tc-stat">
|
|
397
|
+
<b className="add">+{totalAdd}</b> <b className="del">−{totalDel}</b>
|
|
398
|
+
</span>
|
|
399
|
+
)}
|
|
400
|
+
{data.isDirty && <span className="tc-dirty">● 未提交</span>}
|
|
401
|
+
{data.ahead > 0 && <span className="tc-ahead">领先 {data.ahead} 个提交</span>}
|
|
402
|
+
<span className="tc-spacer" />
|
|
403
|
+
<button className="tc-btn" onClick={load} disabled={loading || merging || resolving}>
|
|
404
|
+
{loading ? "…" : "刷新"}
|
|
405
|
+
</button>
|
|
406
|
+
{reviewing ? (
|
|
407
|
+
<>
|
|
408
|
+
<button className="tc-btn" onClick={doAbort} disabled={merging || resolving}>
|
|
409
|
+
丢弃
|
|
410
|
+
</button>
|
|
411
|
+
<button className="tc-btn primary" onClick={doComplete} disabled={merging || resolving}>
|
|
412
|
+
{merging ? "合并中…" : "接受并合并"}
|
|
413
|
+
</button>
|
|
414
|
+
</>
|
|
415
|
+
) : resolving ? (
|
|
416
|
+
<span className="tc-merged faint">正在用 AI 解决冲突…</span>
|
|
417
|
+
) : (
|
|
418
|
+
<>
|
|
419
|
+
{merged && !pending && <span className="tc-merged">✓ 已合并 · 已是最新</span>}
|
|
420
|
+
{prUrl && (
|
|
421
|
+
<a className="tc-btn tc-pr" href={prUrl} target="_blank" rel="noreferrer" title="在 GitHub 上打开该任务的 Pull Request">
|
|
422
|
+
PR ↗
|
|
423
|
+
</a>
|
|
424
|
+
)}
|
|
425
|
+
{(data.ahead > 0 || data.isDirty) && (
|
|
426
|
+
<button
|
|
427
|
+
className="tc-btn"
|
|
428
|
+
onClick={doCreatePr}
|
|
429
|
+
disabled={prBusy || merging}
|
|
430
|
+
title={prUrl ? "把分支的新提交推送到已打开的 PR" : "把分支推送到 origin 并创建 GitHub PR"}
|
|
431
|
+
>
|
|
432
|
+
{prBusy ? (prUrl ? "推送中…" : "创建 PR 中…") : prUrl ? "更新 PR" : "创建 PR"}
|
|
433
|
+
</button>
|
|
434
|
+
)}
|
|
435
|
+
{pending && (
|
|
436
|
+
<button className="tc-btn primary" onClick={doMerge} disabled={merging || prBusy}>
|
|
437
|
+
{merging ? "合并中…" : merged ? "合并新改动" : `合并到 ${data.baseLabel}`}
|
|
438
|
+
</button>
|
|
439
|
+
)}
|
|
440
|
+
{!pending && !merged && !nothing && <span className="tc-merged faint">已是最新</span>}
|
|
441
|
+
</>
|
|
442
|
+
)}
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
{reviewing && (
|
|
446
|
+
<div className="tc-mergebar review">
|
|
447
|
+
冲突已解决 —— 请审阅下方的合并结果,然后选择<b>接受并合并</b>或<b>丢弃</b>。
|
|
448
|
+
{data.unresolved && data.unresolved.length > 0 && (
|
|
449
|
+
<div className="tc-conflicts">
|
|
450
|
+
{`⚠ 仍有 ${data.unresolved.length} 个文件未解决:\n${data.unresolved.join("\n")}`}
|
|
451
|
+
</div>
|
|
452
|
+
)}
|
|
453
|
+
{binaryConflicts.length > 0 && (
|
|
454
|
+
<div className="tc-conflicts">
|
|
455
|
+
{`二进制文件冲突保留了任务分支的版本 —— 请手动检查:\n${binaryConflicts.join("\n")}`}
|
|
456
|
+
</div>
|
|
457
|
+
)}
|
|
458
|
+
</div>
|
|
459
|
+
)}
|
|
460
|
+
|
|
461
|
+
{prErr && <div className="tc-mergebar bad">⚠ {prErr}</div>}
|
|
462
|
+
|
|
463
|
+
{mergeRes && (
|
|
464
|
+
<div className={`tc-mergebar ${mergeRes.ok ? "ok" : "bad"}`}>
|
|
465
|
+
{mergeRes.ok
|
|
466
|
+
? mergeRes.alreadyMerged
|
|
467
|
+
? `已与 ${mergeRes.targetBranch} 保持一致。`
|
|
468
|
+
: `已合并到 ${mergeRes.targetBranch}。`
|
|
469
|
+
: `⚠ ${mergeRes.error || "合并失败"}`}
|
|
470
|
+
{mergeRes.conflicts && mergeRes.conflicts.length > 0 && (
|
|
471
|
+
<div className="tc-conflicts">{mergeRes.conflicts.join("\n")}</div>
|
|
472
|
+
)}
|
|
473
|
+
{mergeRes.conflicts && mergeRes.conflicts.length > 0 && !reviewing && (
|
|
474
|
+
<>
|
|
475
|
+
<div className="tc-conflict-actions">
|
|
476
|
+
<button className="tc-btn" onClick={() => setManualOpen((v) => !v)} disabled={resolving || merging}>
|
|
477
|
+
手动解决
|
|
478
|
+
</button>
|
|
479
|
+
{onResolveWithAI && (
|
|
480
|
+
<button className="tc-btn primary" onClick={doResolveWithAI} disabled={resolving || merging}>
|
|
481
|
+
{resolving ? "解决中…" : "用 AI 解决"}
|
|
482
|
+
</button>
|
|
483
|
+
)}
|
|
484
|
+
</div>
|
|
485
|
+
{manualOpen && (
|
|
486
|
+
<div className="tc-manual">
|
|
487
|
+
你可以在该任务的 worktree 中自行解决这些冲突(用内置终端):把{" "}
|
|
488
|
+
<code>{data.baseLabel}</code> 合并进分支,处理冲突标记,提交,然后再次点击「合并」。
|
|
489
|
+
</div>
|
|
490
|
+
)}
|
|
491
|
+
</>
|
|
492
|
+
)}
|
|
493
|
+
</div>
|
|
494
|
+
)}
|
|
495
|
+
|
|
496
|
+
{nothing ? (
|
|
497
|
+
<div className="tc-note">该分支上还没有改动。</div>
|
|
498
|
+
) : (
|
|
499
|
+
<div className="tc-scroll" ref={scrollRef}>
|
|
500
|
+
{/* overview list — click a file to jump to its diff */}
|
|
501
|
+
<div className="tc-list">
|
|
502
|
+
<div className="tc-list-h">
|
|
503
|
+
{data.files.length} 个文件有改动
|
|
504
|
+
</div>
|
|
505
|
+
{data.files.map((f) => (
|
|
506
|
+
<button key={f.path} className={`tc-frow ${active === f.path ? "on" : ""}`} onClick={() => jump(f.path)} title={f.path}>
|
|
507
|
+
<span className={`tc-st s-${f.status === "?" ? "new" : f.status}`} title={STATUS_LABEL[f.status] || f.status}>
|
|
508
|
+
{f.status}
|
|
509
|
+
</span>
|
|
510
|
+
<span className="tc-fpath">{f.path}</span>
|
|
511
|
+
{f.binary ? (
|
|
512
|
+
<span className="tc-bin">bin</span>
|
|
513
|
+
) : (
|
|
514
|
+
<span className="tc-cnt">
|
|
515
|
+
<b className="add">+{f.additions}</b> <b className="del">−{f.deletions}</b>
|
|
516
|
+
</span>
|
|
517
|
+
)}
|
|
518
|
+
</button>
|
|
519
|
+
))}
|
|
520
|
+
</div>
|
|
521
|
+
|
|
522
|
+
{/* per-file diffs with sticky headers */}
|
|
523
|
+
{data.files.map((f) => (
|
|
524
|
+
<FileDiff key={f.path} file={f} userToggled={toggled.has(f.path)} onToggle={toggle} refs={secRefs} />
|
|
525
|
+
))}
|
|
526
|
+
</div>
|
|
527
|
+
)}
|
|
528
|
+
</div>
|
|
529
|
+
);
|
|
530
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Icon } from "./icons";
|
|
4
|
+
import { SLABEL, type TaskRow, type ProjectRow } from "./types";
|
|
5
|
+
import { StatusDot } from "./shared";
|
|
6
|
+
|
|
7
|
+
export function TaskHero({ task, project, onStart, onEdit, onSetSendContext, running, blockedBy }: { task: TaskRow; project: ProjectRow; onStart: () => void; onEdit: () => void; onSetSendContext: (v: boolean) => void; running: boolean; blockedBy?: string[] }) {
|
|
8
|
+
const carried = task.generation > 1;
|
|
9
|
+
const blocked = !!blockedBy?.length && !task.started;
|
|
10
|
+
const sendContext = task.send_context !== 0;
|
|
11
|
+
const statusLine = carried ? "全新上下文窗口 · 已带入摘要" : `${SLABEL[task.status]} · 尚未开始会话`;
|
|
12
|
+
return (
|
|
13
|
+
<div className="hero">
|
|
14
|
+
<div className="h-ic">{Icon.bolt()}</div>
|
|
15
|
+
<div className="h-status"><StatusDot status={task.status} /> {statusLine}</div>
|
|
16
|
+
<h2>{task.title}</h2>
|
|
17
|
+
{task.description && <p className="h-desc">{task.description}</p>}
|
|
18
|
+
<div className="h-prompt">
|
|
19
|
+
<div className="hp-h">智能体将收到的初始提示</div>
|
|
20
|
+
<div className="hp-b">
|
|
21
|
+
<span className="ctx-pre">↳ {sendContext ? `${project.name} 项目上下文` : "仅任务详情"}{carried ? " + 上一次会话摘要" : ""}(自动前置)</span>
|
|
22
|
+
<strong>{task.title}.</strong> {task.description}
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12.5, color: "var(--ink-2)", cursor: running ? "not-allowed" : "pointer" }}
|
|
26
|
+
title="取消勾选则不发送已保存的项目上下文。任务详情与编排工具始终会包含在内。">
|
|
27
|
+
<input type="checkbox" checked={sendContext} disabled={running} onChange={(e) => onSetSendContext(e.target.checked)} />
|
|
28
|
+
向智能体发送已保存的项目上下文
|
|
29
|
+
</label>
|
|
30
|
+
{blocked && (task.auto_start ? (
|
|
31
|
+
<div className="hero-blocked auto" title={`以下任务完成后自动开始:${blockedBy!.join("、")}`}>
|
|
32
|
+
{Icon.bolt()} 已排队 —— {blockedBy!.length === 1 ? <strong>{blockedBy![0]}</strong> : `${blockedBy!.length} 个任务`}完成后会自动开始。可编辑任务修改此设置。
|
|
33
|
+
</div>
|
|
34
|
+
) : (
|
|
35
|
+
<div className="hero-blocked" title={`以下任务完成前受阻:${blockedBy!.join("、")}`}>
|
|
36
|
+
{Icon.lock()} 需等 {blockedBy!.length === 1 ? <strong>{blockedBy![0]}</strong> : `${blockedBy!.length} 个任务`}完成后才能开始。可编辑任务修改其依赖。
|
|
37
|
+
</div>
|
|
38
|
+
))}
|
|
39
|
+
<div style={{ display: "flex", gap: 10 }}>
|
|
40
|
+
<button className="btn btn-accent" style={{ height: 38, padding: "0 20px", fontSize: 14 }} onClick={onStart} disabled={running || blocked} title={blocked ? `以下任务完成前受阻:${blockedBy!.join("、")}` : undefined}>
|
|
41
|
+
{Icon.play()} {running ? "启动中…" : blocked ? (task.auto_start ? "已排队" : "受阻") : "开始会话"}
|
|
42
|
+
</button>
|
|
43
|
+
<button className="btn btn-line" style={{ height: 38, padding: "0 16px", fontSize: 14 }} onClick={onEdit} disabled={running} title="开始前编辑标题与描述">
|
|
44
|
+
{Icon.edit()} 编辑
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|