@xgspz/cli-ui 0.6.14 → 0.6.16
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 +37 -4
- package/README.md +29 -4
- package/dist/{cli-ui-embed-D-3DvyTN.js → cli-ui-embed-D9xgur2y.js} +18376 -18648
- package/dist/{cli-ui-index-C34qFb4u.js → cli-ui-index-WBtxmw23.js} +2 -2
- package/dist/{cli-ui-jszip.min-DojbZPiE.js → cli-ui-jszip.min-CU3ZzoXb.js} +1 -1
- package/dist/{cli-ui-jszip.min-BObcE2nn.js → cli-ui-jszip.min-DdU0X3VJ.js} +2 -2
- package/dist/{cli-ui-xlsx-JVLtx0qR.js → cli-ui-xlsx-Dx6T7qhC.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +1 -1
- package/src/board/ChangesPane.tsx +10 -4
- package/src/board/CliBoard.tsx +48 -16
- package/src/board/FileViewer.tsx +34 -1
- package/src/board/diffRefresh.ts +63 -0
- package/src/board/linkage.ts +68 -0
- package/src/board/pathResolve.ts +108 -0
- package/src/chat/CliChat.tsx +105 -182
- package/src/chat/Composer.tsx +4 -3
- package/src/core/conversation.ts +16 -4
- package/src/core/store.ts +120 -40
- package/src/core/types.ts +2 -0
- package/src/msglist/CliMsgList.tsx +3 -2
- package/src/operator/node/AgentConnect.tsx +19 -15
- package/src/operator/node/AppearancePanel.tsx +1 -1
- package/src/operator/node/BranchNavigator.tsx +1 -1
- package/src/operator/node/BrowserPane.tsx +1 -1
- package/src/operator/node/Chat.tsx +16 -261
- package/src/operator/node/CommandPalette.tsx +1 -1
- package/src/operator/node/Composer.tsx +1 -1
- package/src/operator/node/Layout.tsx +1 -1
- package/src/operator/node/Markdown.tsx +6 -36
- package/src/operator/node/Modal.tsx +1 -1
- package/src/operator/node/OnboardingWizard.tsx +9 -5
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/ProjectLanding.tsx +1 -1
- package/src/operator/node/ProjectsColumn.tsx +1 -1
- package/src/operator/node/ProviderAuth.tsx +12 -10
- package/src/operator/node/RunSwitch.tsx +1 -1
- package/src/operator/node/Services.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +27 -8
- package/src/operator/node/SessionView.tsx +1 -1
- package/src/operator/node/SettingsView.tsx +1 -1
- package/src/operator/node/TaskBoard.tsx +1 -1
- package/src/operator/node/TaskChanges.tsx +34 -5
- package/src/operator/node/TaskHero.tsx +1 -1
- package/src/operator/node/TasksColumn.tsx +1 -1
- package/src/operator/node/Transcript.tsx +10 -237
- package/src/operator/node/Welcome.tsx +1 -1
- package/src/operator/node/WorktreePrune.tsx +1 -1
- package/src/operator/node/boardInteraction.ts +6 -3
- package/src/operator/node/boardState.ts +12 -13
- package/src/operator/node/fileLinks.ts +4 -38
- package/src/operator/node/format.ts +12 -104
- package/src/operator/node/github.tsx +10 -8
- package/src/operator/node/modals.tsx +15 -13
- package/src/operator/node/recheckOnReturn.ts +28 -0
- package/src/operator/node/shared.tsx +10 -50
- package/src/operator/node/useTaskStream.ts +2 -2
- package/src/operator.base.css +3 -0
- package/src/operator.css +3 -0
- package/src/styles.css +5 -0
- package/src/ui/Markdown.tsx +36 -21
- package/src/ui/Transcript.tsx +8 -5
- package/src/ui/fileLinks.ts +6 -3
- package/src/ui/format.ts +11 -5
- package/src/ui/shared.tsx +11 -7
- package/src/ui/transcriptModel.ts +40 -9
- package/src/ui/useTranscriptWindow.ts +253 -0
- package/src/operator/node/boundedPoll.ts +0 -47
- package/src/operator/node/icons.tsx +0 -88
- package/src/operator/node/transcriptModel.ts +0 -129
|
@@ -5,26 +5,17 @@
|
|
|
5
5
|
// 打不开。这里改写到 /api/tasks/<id>/files/…(服务端见 lib/taskFiles.ts),
|
|
6
6
|
// Markdown 则改到 /view 预览页渲染。
|
|
7
7
|
import { createContext } from "react";
|
|
8
|
+
import { localFilePath } from "../../ui/fileLinks";
|
|
9
|
+
|
|
10
|
+
// 本地文件路径的识别规则与中枢适配共用(ui/fileLinks 的 localFilePath);这里只管改写成节点的文件地址。
|
|
11
|
+
export { localFilePath };
|
|
8
12
|
|
|
9
13
|
/** 当前渲染的 Markdown 属于哪个任务、相对哪个目录(预览页里是 md 文件所在目录)。 */
|
|
10
14
|
export type FileLinkScope = { taskId: string; baseDir?: string } | null;
|
|
11
15
|
export const FileLinkContext = createContext<FileLinkScope>(null);
|
|
12
16
|
|
|
13
|
-
const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
14
17
|
const WIN_ABS = /^[a-z]:[\\/]/i;
|
|
15
18
|
|
|
16
|
-
function normalize(p: string): string {
|
|
17
|
-
const abs = p.startsWith("/");
|
|
18
|
-
const out: string[] = [];
|
|
19
|
-
for (const seg of p.split("/")) {
|
|
20
|
-
if (!seg || seg === ".") continue;
|
|
21
|
-
if (seg === ".." && out.length && out[out.length - 1] !== "..") out.pop();
|
|
22
|
-
else if (seg === ".." && abs) continue;
|
|
23
|
-
else out.push(seg);
|
|
24
|
-
}
|
|
25
|
-
return (abs ? "/" : "") + out.join("/");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
19
|
export function isMarkdownPath(p: string): boolean {
|
|
29
20
|
return /\.(md|markdown)$/i.test(p);
|
|
30
21
|
}
|
|
@@ -40,31 +31,6 @@ export function markdownViewUrl(taskId: string, p: string): string {
|
|
|
40
31
|
return `/view?task=${encodeURIComponent(taskId)}&path=${encodeURIComponent(p)}`;
|
|
41
32
|
}
|
|
42
33
|
|
|
43
|
-
/**
|
|
44
|
-
* 链接指向本地文件时,返回它的路径(相对任务工作目录,或绝对路径);
|
|
45
|
-
* 网址、锚点、应用自己的地址等返回 null(保持原样)。
|
|
46
|
-
*/
|
|
47
|
-
export function localFilePath(href: string | undefined | null, baseDir = ""): string | null {
|
|
48
|
-
let h = String(href ?? "").trim();
|
|
49
|
-
if (!h || h.startsWith("#") || h.startsWith("//") || h.startsWith("/api/") || h.startsWith("/view?")) return null;
|
|
50
|
-
if (/^file:/i.test(h)) h = h.replace(/^file:(\/\/localhost)?(\/\/)?/i, "");
|
|
51
|
-
else if (SCHEME.test(h) && !WIN_ABS.test(h)) return null;
|
|
52
|
-
// 去掉查询和锚点(#L12 之类),再解码(Markdown 解析器会把中文转成 %XX)。
|
|
53
|
-
h = h.replace(/[?#].*$/, "");
|
|
54
|
-
try {
|
|
55
|
-
h = decodeURI(h);
|
|
56
|
-
} catch {
|
|
57
|
-
// 保持原样
|
|
58
|
-
}
|
|
59
|
-
h = h.replace(/\\/g, "/");
|
|
60
|
-
// 编辑器风格的行号:src/a.ts:12 或 :12:5
|
|
61
|
-
h = h.replace(/:(\d+)(:\d+)?$/, "");
|
|
62
|
-
if (!h) return null;
|
|
63
|
-
if (h === "~" || h.startsWith("~/")) return h;
|
|
64
|
-
if (h.startsWith("/") || WIN_ABS.test(h)) return WIN_ABS.test(h) ? h : normalize(h);
|
|
65
|
-
return normalize(baseDir ? `${baseDir.replace(/\/+$/, "")}/${h}` : h);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
34
|
/** 链接改写的最终结果:Markdown → 预览页;其他文件 → 文件接口;不是本地文件 → null。 */
|
|
69
35
|
export function rewriteFileHref(scope: FileLinkScope, href: string | undefined | null): string | null {
|
|
70
36
|
if (!scope) return null;
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
// Pure formatting + derivation helpers
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// Pure formatting + derivation helpers for the node adapter. The ones both adapters
|
|
2
|
+
// use (tokens, times, sessions, answers, model labels, attachment parsing) live in
|
|
3
|
+
// ui/format and are re-exported here.
|
|
4
|
+
export { buildSessions, clockTime, diffCls, fmtTokens, formatAnswersText, relTime, modelLabel } from "../../ui/format";
|
|
5
|
+
import { fmtTokens, splitAttachments as splitSharedAttachments } from "../../ui/format";
|
|
5
6
|
|
|
6
|
-
//
|
|
7
|
-
export function
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return String(n);
|
|
7
|
+
// Attachment chips: parsed by the shared rule (ui/format), linked to the node's upload route.
|
|
8
|
+
export function splitAttachments(content: string): { text: string; attachments: MsgAttachment[] } {
|
|
9
|
+
const { text, attachments } = splitSharedAttachments(content);
|
|
10
|
+
return { text, attachments: attachments.map(({ path, kind, name, taskId }) => ({ path, kind, name, url: `/api/tasks/${encodeURIComponent(taskId)}/uploads/${encodeURIComponent(name)}` })) };
|
|
11
11
|
}
|
|
12
|
+
import type { TaskRow, AgentCapabilities, AgentInfo } from "./types";
|
|
13
|
+
import type { InternalUsageEstimate } from "./types";
|
|
14
|
+
|
|
12
15
|
// Human byte size: 1536 → "1.5 KB", 5_242_880 → "5.0 MB". Base-1024.
|
|
13
16
|
export function fmtBytes(n: number): string {
|
|
14
17
|
if (n <= 0) return "0 B";
|
|
@@ -137,61 +140,7 @@ export function contextPct(tokens: number, model: string | null | undefined, cap
|
|
|
137
140
|
return Math.round((tokens / contextWindowOf(model, caps)) * 1000) / 10;
|
|
138
141
|
}
|
|
139
142
|
|
|
140
|
-
// Friendly name for a resolved model id — the badge that answers "which model
|
|
141
|
-
// did this turn actually run on?". The VERSION is the point: family aliases move
|
|
142
|
-
// (today "opus" resolves to claude-opus-5, last month claude-opus-4-8), so a bare
|
|
143
|
-
// "Opus" badge tells you nothing. Parse family + version out of the id first
|
|
144
|
-
// ("claude-opus-5" -> "Opus 5", "claude-opus-4-8-20251101" -> "Opus 4.8") and
|
|
145
|
-
// keep the `[1m]` marker, since the 1M variant is a distinct run mode.
|
|
146
|
-
// Non-Claude ids (Codex's "gpt-5.1-codex-max") carry no such version shape —
|
|
147
|
-
// those fall through to the agent's capability labels, matched longest-first so
|
|
148
|
-
// a shorter value can't shadow a more specific one. Raw id is the last resort.
|
|
149
|
-
export function modelLabel(id: string | null, caps?: AgentCapabilities): string {
|
|
150
|
-
if (!id) return "";
|
|
151
|
-
const s = id.toLowerCase();
|
|
152
|
-
const long = s.includes("[1m]") ? " (1M)" : "";
|
|
153
|
-
const fam = ["fable", "opus", "sonnet", "haiku"].find((f) => s.includes(f));
|
|
154
|
-
if (fam) {
|
|
155
|
-
const cap = fam[0].toUpperCase() + fam.slice(1);
|
|
156
|
-
const v = s.match(new RegExp(`${fam}-(\\d+)(?:-(\\d+))?`));
|
|
157
|
-
// Deliberately NOT the capability label here: an id we can't read a version
|
|
158
|
-
// out of shouldn't be badged with a version we're only guessing at.
|
|
159
|
-
return v ? `${cap} ${v[1]}${v[2] ? `.${v[2]}` : ""}${long}` : `${cap}${long}`;
|
|
160
|
-
}
|
|
161
|
-
const hit = (caps?.models ?? [])
|
|
162
|
-
.filter((o) => s.includes(o.value.toLowerCase()))
|
|
163
|
-
.sort((a, b) => b.value.length - a.value.length)[0];
|
|
164
|
-
return hit ? hit.label : id;
|
|
165
|
-
}
|
|
166
143
|
|
|
167
|
-
// Phrase AskUserQuestion answers as a reply, for the reload fallback where the
|
|
168
|
-
// turn is no longer parked and we resume the session with a normal message.
|
|
169
|
-
export function formatAnswersText(questions: AskQuestion[], answers: AskAnswers): string {
|
|
170
|
-
const lines = questions.map((q, i) => {
|
|
171
|
-
const picked = (answers[i] ?? []).filter((s) => s && s.trim());
|
|
172
|
-
return `- ${q.header || q.question}:${picked.length ? picked.join("、") : "(未选择)"}`;
|
|
173
|
-
});
|
|
174
|
-
return `回答你的问题:\n${lines.join("\n")}`;
|
|
175
|
-
}
|
|
176
|
-
// Absolute wall-clock stamp for a transcript message ("3:42 PM", locale-aware).
|
|
177
|
-
// Messages from an earlier day prefix the date, so a task resumed days later
|
|
178
|
-
// still reads right. Absolute on purpose: MessageView is memoized, so a
|
|
179
|
-
// relative "2m ago" would freeze at whatever it said when the row rendered.
|
|
180
|
-
export function clockTime(ts: number): string {
|
|
181
|
-
const d = new Date(ts);
|
|
182
|
-
const time = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
183
|
-
if (d.toDateString() === new Date().toDateString()) return time;
|
|
184
|
-
return `${d.toLocaleDateString([], { month: "short", day: "numeric" })}, ${time}`;
|
|
185
|
-
}
|
|
186
|
-
export function relTime(ts: number): string {
|
|
187
|
-
const d = Date.now() - ts;
|
|
188
|
-
const m = Math.floor(d / 60000);
|
|
189
|
-
if (m < 1) return "刚刚";
|
|
190
|
-
if (m < 60) return `${m} 分钟前`;
|
|
191
|
-
const h = Math.floor(m / 60);
|
|
192
|
-
if (h < 24) return `${h} 小时前`;
|
|
193
|
-
return `${Math.floor(h / 24)} 天前`;
|
|
194
|
-
}
|
|
195
144
|
// How long a task has been waiting on the user, spelled out for the "need you"
|
|
196
145
|
// dropdown ("waiting for 3 hours"). Coarser and more verbose than relTime — this
|
|
197
146
|
// is the only subline a row gets, so it reads as prose rather than a chip.
|
|
@@ -233,24 +182,7 @@ export const blockerTitles = (t: TaskRow, byId: Map<string, TaskRow>): string[]
|
|
|
233
182
|
.filter((b): b is TaskRow => !!b && b.status !== "done" && b.status !== "cancelled")
|
|
234
183
|
.map((b) => b.title);
|
|
235
184
|
|
|
236
|
-
// add/del/ctx class for a diff line's sign — shared by the peek and full views.
|
|
237
|
-
export const diffCls = (sign: "+" | "-" | " ") => (sign === "+" ? "add" : sign === "-" ? "del" : "ctx");
|
|
238
185
|
|
|
239
|
-
// group flat messages into per-generation sessions, pulling out the /clear summaries
|
|
240
|
-
export function buildSessions(messages: Msg[]) {
|
|
241
|
-
const summaryByGen: Record<number, string> = {};
|
|
242
|
-
for (const m of messages) if (m.role === "session_break") summaryByGen[m.generation] = m.content;
|
|
243
|
-
// Queued follow-ups are excluded here — they haven't run yet, so SessionView
|
|
244
|
-
// renders them in a pinned block below the live "thinking" indicator instead
|
|
245
|
-
// of interleaved with the committed transcript.
|
|
246
|
-
const committed = messages.filter((m) => m.role !== "queued");
|
|
247
|
-
const gens = Array.from(new Set(committed.filter((m) => m.role !== "session_break").map((m) => m.generation))).sort((a, b) => a - b);
|
|
248
|
-
return gens.map((n) => ({
|
|
249
|
-
n,
|
|
250
|
-
summaryBefore: summaryByGen[n - 1] ?? null,
|
|
251
|
-
messages: committed.filter((m) => m.generation === n && m.role !== "session_break"),
|
|
252
|
-
}));
|
|
253
|
-
}
|
|
254
186
|
|
|
255
187
|
// ---------- chat attachments (images + large text pastes) ----------
|
|
256
188
|
// An upload travels inside the message text as one marker line per file:
|
|
@@ -263,30 +195,6 @@ export function buildSessions(messages: Msg[]) {
|
|
|
263
195
|
// uploads/<task>/<file> tail, so no extra columns or event fields are needed.
|
|
264
196
|
export const attachmentMarker = (absPath: string) => `[Attached image: ${absPath}]`;
|
|
265
197
|
export const fileAttachmentMarker = (absPath: string) => `[Attached file: ${absPath}]`;
|
|
266
|
-
const ATTACHMENT_RE = /^\[Attached (image|file): (.+)\]$/;
|
|
267
198
|
|
|
268
199
|
export interface MsgAttachment { path: string; url: string; kind: "image" | "file"; name: string }
|
|
269
200
|
|
|
270
|
-
// Split a user message into displayable text + attachment chips. Marker lines
|
|
271
|
-
// whose path doesn't end in uploads/<task>/<file> (hand-typed lookalikes) stay
|
|
272
|
-
// in the text untouched.
|
|
273
|
-
export function splitAttachments(content: string): { text: string; attachments: MsgAttachment[] } {
|
|
274
|
-
if (!content.includes("[Attached image: ") && !content.includes("[Attached file: ")) {
|
|
275
|
-
return { text: content, attachments: [] };
|
|
276
|
-
}
|
|
277
|
-
const attachments: MsgAttachment[] = [];
|
|
278
|
-
const kept: string[] = [];
|
|
279
|
-
for (const line of content.split("\n")) {
|
|
280
|
-
const m = ATTACHMENT_RE.exec(line.trim());
|
|
281
|
-
const parts = m ? m[2].split(/[\\/]/).filter(Boolean) : [];
|
|
282
|
-
const upload = parts.length >= 3 && parts[parts.length - 3] === "uploads";
|
|
283
|
-
const managed = parts.length >= 4 && parts[parts.length - 4] === ".workspace" && parts[parts.length - 3] === "attachments";
|
|
284
|
-
if (m && (upload || managed) && parts.slice(-2).every(part => part !== "." && part !== "..")) {
|
|
285
|
-
const [taskId, file] = parts.slice(-2);
|
|
286
|
-
attachments.push({ path: m[2], url: `/api/tasks/${encodeURIComponent(taskId)}/uploads/${encodeURIComponent(file)}`, kind: m[1] === "image" ? "image" : "file", name: file });
|
|
287
|
-
} else {
|
|
288
|
-
kept.push(line);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
return { text: kept.join("\n").trim(), attachments };
|
|
292
|
-
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
-
import { Icon } from "
|
|
4
|
+
import { Icon } from "../../ui/icons";
|
|
5
5
|
import { jget, jsend } from "./api";
|
|
6
6
|
import { LoadNote, Skel, ErrNote } from "./shared";
|
|
7
7
|
import type { GhStatusT, GhLoginT, GhRepoT } from "./types";
|
|
8
|
-
import {
|
|
8
|
+
import { useRecheckOnReturn } from "./recheckOnReturn";
|
|
9
9
|
|
|
10
10
|
// The "Clone from GitHub" pane of the new-project modal: shows the guided
|
|
11
11
|
// connect step when the workspace isn't logged in, the user's repo list when
|
|
@@ -70,14 +70,15 @@ export function GitHubConnect({ onConnected }: { onConnected: () => void }) {
|
|
|
70
70
|
setLogin(null);
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
-
// Re-check while gh waits for the user to authorize on github.com —
|
|
74
|
-
//
|
|
75
|
-
|
|
73
|
+
// Re-check while gh waits for the user to authorize on github.com — when the
|
|
74
|
+
// user comes back from github.com, or presses 检查状态 (no polling).
|
|
75
|
+
const checkLogin = () => {
|
|
76
76
|
jget<GhLoginT>("/api/github/login").then((l) => {
|
|
77
77
|
setLogin(l);
|
|
78
78
|
if (l.status === "success" && !done.current) { done.current = true; onConnected(); }
|
|
79
79
|
}).catch(() => {});
|
|
80
|
-
}
|
|
80
|
+
};
|
|
81
|
+
useRecheckOnReturn(!!login && (login.status === "starting" || login.status === "awaiting"), checkLogin);
|
|
81
82
|
|
|
82
83
|
const copyCode = () => {
|
|
83
84
|
if (!login?.code) return;
|
|
@@ -99,7 +100,7 @@ export function GitHubConnect({ onConnected }: { onConnected: () => void }) {
|
|
|
99
100
|
</div>
|
|
100
101
|
);
|
|
101
102
|
}
|
|
102
|
-
if (login.status === "starting") return <LoadNote style={{ padding: 0, marginBottom:
|
|
103
|
+
if (login.status === "starting") return <div className="field"><LoadNote style={{ padding: 0, marginBottom: 8 }}>正在启动 GitHub 登录…</LoadNote><button className="btn btn-ghost btn-sm" onClick={checkLogin} style={{ alignSelf: "flex-start" }}>检查状态</button></div>;
|
|
103
104
|
if (login.status === "awaiting") {
|
|
104
105
|
return (
|
|
105
106
|
<div className="field" style={{ border: "1px solid var(--line-strong)", borderRadius: "var(--r)", background: "var(--raise)", padding: "14px 16px" }}>
|
|
@@ -115,9 +116,10 @@ export function GitHubConnect({ onConnected }: { onConnected: () => void }) {
|
|
|
115
116
|
<a className="btn btn-accent" href={login.url ?? "https://github.com/login/device"} target="_blank" rel="noreferrer">
|
|
116
117
|
{Icon.github()} 打开 github.com/login/device
|
|
117
118
|
</a>
|
|
119
|
+
<button className="btn btn-line" onClick={checkLogin}>我已完成授权,检查状态</button>
|
|
118
120
|
<button className="btn btn-ghost" onClick={cancel}>取消</button>
|
|
119
121
|
</div>
|
|
120
|
-
<div className="hlp" style={{ marginBottom: 0 }}>等待你在 github.com
|
|
122
|
+
<div className="hlp" style={{ marginBottom: 0 }}>等待你在 github.com 上完成授权…回到这个页面时会自动检查一次。</div>
|
|
121
123
|
</div>
|
|
122
124
|
);
|
|
123
125
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import type { Priority } from "./protocol/types";
|
|
5
|
-
import { Icon } from "
|
|
5
|
+
import { Icon } from "../../ui/icons";
|
|
6
6
|
import { jget, jsend } from "./api";
|
|
7
7
|
import { relTime, duration, fmtJobCost } from "./format";
|
|
8
8
|
import { type ProjectRow, type ProjectSession, type TaskRow, type AgentsBundle, type InternalUsageEstimate, type ProjectDeletePreview } from "./types";
|
|
@@ -12,7 +12,7 @@ import { Modal, BrowseDirButton, PrioritySeg, DepPicker } from "./Modal";
|
|
|
12
12
|
import { GitHubClonePicker } from "./github";
|
|
13
13
|
import { Markdown } from "./Markdown";
|
|
14
14
|
import { clientFeatures } from "./protocol/features";
|
|
15
|
-
import {
|
|
15
|
+
import { useRecheckOnReturn } from "./recheckOnReturn";
|
|
16
16
|
|
|
17
17
|
// One-click copy, with the button itself confirming. Used for the project id and
|
|
18
18
|
// for the ready-to-run curl snippets below it.
|
|
@@ -331,7 +331,7 @@ export function EditTaskModal({ task, tasks, agents, onClose, onSave, onDelete,
|
|
|
331
331
|
}
|
|
332
332
|
|
|
333
333
|
// Mirror of the server's RefreshState (lib/contextRefresh.ts) — the detached
|
|
334
|
-
// "Refresh with AI" job state the modal
|
|
334
|
+
// "Refresh with AI" job state the modal re-reads (on return / 检查结果).
|
|
335
335
|
type RefreshState = { status: "idle" | "running" | "done" | "error"; draft: string; error: string; started_at: number; estimate?: InternalUsageEstimate | null };
|
|
336
336
|
|
|
337
337
|
export function ContextModal({ project, agents, onSetDefaultAgent, onClose, onSave, onDelete, onDeprecate }: { project: ProjectRow; agents: AgentsBundle; onSetDefaultAgent: (agent: string) => void; onClose: () => void; onSave: (p: { name: string; context: string; send_context: number; repo_path: string; branch: string; dev_command: string; setup_command: string; test_command: string }) => void; onDelete: (confirm?: { delete_storage: string }) => Promise<void> | void; onDeprecate: () => void }) {
|
|
@@ -370,7 +370,7 @@ export function ContextModal({ project, agents, onSetDefaultAgent, onClose, onSa
|
|
|
370
370
|
const showServices = clientFeatures().services;
|
|
371
371
|
// AI context refresh: let Claude read the repo and draft fresh context. The
|
|
372
372
|
// draft now runs as a DETACHED server-side job (it can take minutes and must
|
|
373
|
-
// survive sleep/reload), so the client starts it and
|
|
373
|
+
// survive sleep/reload), so the client starts it and re-reads the result
|
|
374
374
|
// rather than holding one long request open. The drafted text replaces the
|
|
375
375
|
// textarea but isn't saved until Save — we stash the prior text for Undo.
|
|
376
376
|
const [refreshing, setRefreshing] = useState(false);
|
|
@@ -383,18 +383,18 @@ export function ContextModal({ project, agents, onSetDefaultAgent, onClose, onSa
|
|
|
383
383
|
const showPreview = preview && !refreshing;
|
|
384
384
|
|
|
385
385
|
// Latest edited context, read inside async handlers without making them
|
|
386
|
-
// depend on `context` (which would churn the
|
|
386
|
+
// depend on `context` (which would churn the re-check effect / stale-close it).
|
|
387
387
|
const contextRef = useRef(context);
|
|
388
388
|
contextRef.current = context;
|
|
389
389
|
// started_at of the job whose result we've already applied — so a draft is
|
|
390
|
-
// consumed exactly once even if a POST reply and a
|
|
390
|
+
// consumed exactly once even if a POST reply and a re-check race.
|
|
391
391
|
const appliedRef = useRef(0);
|
|
392
392
|
|
|
393
393
|
const ackRefresh = useCallback(() => {
|
|
394
394
|
jsend(`/api/projects/${project.id}/refresh-context`, "DELETE").catch(() => {});
|
|
395
395
|
}, [project.id]);
|
|
396
396
|
|
|
397
|
-
// Fold a
|
|
397
|
+
// Fold a re-read job state into the UI. Idempotent: applies a finished draft at
|
|
398
398
|
// most once, then acks it so it doesn't resurface on the next modal open.
|
|
399
399
|
const handleState = useCallback((s: RefreshState) => {
|
|
400
400
|
setRefreshEstimate(s.estimate ?? null);
|
|
@@ -425,12 +425,13 @@ export function ContextModal({ project, agents, onSetDefaultAgent, onClose, onSa
|
|
|
425
425
|
return () => { alive = false; };
|
|
426
426
|
}, [project.id, handleState]);
|
|
427
427
|
|
|
428
|
-
// While a job runs, re-check its result
|
|
429
|
-
//
|
|
430
|
-
// (terminal state) or the modal unmounts — the job keeps running server-side.
|
|
431
|
-
|
|
428
|
+
// While a job runs, re-check its result when the user comes back to this tab or
|
|
429
|
+
// presses 检查结果 (useRecheckOnReturn; no polling). Stops when refreshing flips
|
|
430
|
+
// false (terminal state) or the modal unmounts — the job keeps running server-side.
|
|
431
|
+
const checkRefresh = () => {
|
|
432
432
|
jget<RefreshState>(`/api/projects/${project.id}/refresh-context`).then(handleState).catch(() => {});
|
|
433
|
-
}
|
|
433
|
+
};
|
|
434
|
+
useRecheckOnReturn(refreshing, checkRefresh);
|
|
434
435
|
|
|
435
436
|
const refreshContext = async () => {
|
|
436
437
|
if (refreshing) return;
|
|
@@ -510,7 +511,8 @@ export function ContextModal({ project, agents, onSetDefaultAgent, onClose, onSa
|
|
|
510
511
|
) : refreshing ? (
|
|
511
512
|
<div className="hlp" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
512
513
|
<span className="typing"><i /><i /><i /></span>
|
|
513
|
-
正在浏览 {repo.split("/").pop() || "仓库"} 以起草新的上下文 ——
|
|
514
|
+
正在浏览 {repo.split("/").pop() || "仓库"} 以起草新的上下文 —— 可能需要一分钟;切走再回来时会自动检查。
|
|
515
|
+
<button className="linkbtn" onClick={checkRefresh}>检查结果</button>
|
|
514
516
|
</div>
|
|
515
517
|
) : prevContext != null ? (
|
|
516
518
|
<div className="hlp">已根据仓库起草。请审阅并修改后保存 —— 或点击「撤销」还原。</div>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Re-check something that settles elsewhere (a browser-side login approval, a
|
|
7
|
+
* server job) without polling: no timer ever calls `check`. While `active`, it
|
|
8
|
+
* runs once whenever the user comes back — the tab becomes visible, the window
|
|
9
|
+
* regains focus, the network is back — which is when a login approved in
|
|
10
|
+
* another tab (or a job that finished meanwhile) has most likely settled.
|
|
11
|
+
* Callers also show an explicit "检查状态" button wired to the same `check`.
|
|
12
|
+
*/
|
|
13
|
+
export function useRecheckOnReturn(active: boolean, check: () => void): void {
|
|
14
|
+
const latest = useRef(check);
|
|
15
|
+
latest.current = check;
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!active) return;
|
|
18
|
+
const wake = () => { if (document.visibilityState !== "hidden") latest.current(); };
|
|
19
|
+
window.addEventListener("focus", wake);
|
|
20
|
+
window.addEventListener("online", wake);
|
|
21
|
+
document.addEventListener("visibilitychange", wake);
|
|
22
|
+
return () => {
|
|
23
|
+
window.removeEventListener("focus", wake);
|
|
24
|
+
window.removeEventListener("online", wake);
|
|
25
|
+
document.removeEventListener("visibilitychange", wake);
|
|
26
|
+
};
|
|
27
|
+
}, [active]);
|
|
28
|
+
}
|
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import type { Priority, Status } from "./protocol/types";
|
|
4
|
-
import { Icon
|
|
4
|
+
import { Icon } from "../../ui/icons";
|
|
5
|
+
import { Spinner, StatusDot as SharedStatusDot } from "../../ui/shared";
|
|
5
6
|
import { SCLS, SLABEL, AWAIT_LABEL } from "./types";
|
|
6
7
|
|
|
8
|
+
// Signal language (mission-control): "needs your input" is an alert coral, a
|
|
9
|
+
// *live* working session is blue (both pulse), an idle status falls back to its
|
|
10
|
+
// base color; awaiting wins over running. Drawn by the shared dot (ui/shared),
|
|
11
|
+
// keyed here by the node's task status.
|
|
7
12
|
export function StatusDot({ status, running, awaiting, lg }: { status: Status; running?: boolean; awaiting?: boolean; lg?: boolean }) {
|
|
8
|
-
|
|
9
|
-
// *live* working session is blue (both pulse to draw the eye), and an idle
|
|
10
|
-
// status falls back to its base color. Awaiting wins over running — a turn
|
|
11
|
-
// parked on a question is technically live but it's really waiting on you.
|
|
12
|
-
const cls = awaiting ? "c" : running ? "b" : SCLS[status];
|
|
13
|
-
return (
|
|
14
|
-
<span
|
|
15
|
-
className={`sdot ${cls} ${lg ? "lg" : ""} ${awaiting || running ? "pulse" : ""}`}
|
|
16
|
-
title={awaiting ? AWAIT_LABEL : running ? "运行中" : SLABEL[status]}
|
|
17
|
-
/>
|
|
18
|
-
);
|
|
13
|
+
return <SharedStatusDot cls={SCLS[status]} running={running} awaiting={awaiting} lg={lg} title={awaiting ? AWAIT_LABEL : running ? "运行中" : SLABEL[status]} />;
|
|
19
14
|
}
|
|
20
15
|
|
|
21
16
|
export function PriPill({ p }: { p: Priority }) {
|
|
@@ -37,53 +32,18 @@ export function SearchBar({ value, onChange, placeholder }: { value: string; onC
|
|
|
37
32
|
);
|
|
38
33
|
}
|
|
39
34
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
export function Avatar({ who, agent }: { who: "user" | "cc"; agent?: string | null }) {
|
|
44
|
-
if (who === "user") return <span className="av you">A</span>;
|
|
45
|
-
const mark = agent ? AgentMark[agent] : undefined;
|
|
46
|
-
return <span className={`av cc${mark ? ` ${agent}` : ""}`}>{mark ? mark() : Icon.bolt()}</span>;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Which agent driver a task runs under (Claude Code / Codex …). Hidden when only
|
|
50
|
-
// one agent is available (nothing to disambiguate) so single-agent workspaces
|
|
51
|
-
// stay clutter-free. `multi` is passed by the caller from the agents bundle.
|
|
52
|
-
export function AgentBadge({ label, multi }: { label: string; multi: boolean }) {
|
|
53
|
-
if (!multi) return null;
|
|
54
|
-
return <span className="agent-badge" title={`运行于 ${label}`}>{label}</span>;
|
|
55
|
-
}
|
|
35
|
+
// Avatar (the agent's brand mark), AgentBadge (hidden when only one agent is
|
|
36
|
+
// configured: pass `multi`), Spinner, Skel and ErrNote are the shared ones.
|
|
37
|
+
export { Avatar, AgentBadge, Spinner, Skel, ErrNote } from "../../ui/shared";
|
|
56
38
|
|
|
57
39
|
// ---- async-state primitives (pair with the .spinner/.load-note/.skel/.err-note
|
|
58
40
|
// styles in globals.css) — every panel that fetches uses these, so loading and
|
|
59
41
|
// error presentation stays uniform across the app. ----
|
|
60
42
|
|
|
61
|
-
export function Spinner({ size }: { size?: number }) {
|
|
62
|
-
return <span className="spinner" role="status" aria-label="加载中" style={size ? { width: size, height: size } : undefined} />;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
43
|
// Standard "we're fetching" line: spinner + quiet text. Replaces bare "Loading…".
|
|
66
44
|
export function LoadNote({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
|
|
67
45
|
return <div className="load-note" style={style}><Spinner size={13} />{children}</div>;
|
|
68
46
|
}
|
|
69
47
|
|
|
70
|
-
// One shimmer bar. Compose a few for card/list/transcript skeletons.
|
|
71
|
-
export function Skel({ w, h = 10, r, style }: { w: number | string; h?: number; r?: number | string; style?: React.CSSProperties }) {
|
|
72
|
-
return <span className="skel" aria-hidden style={{ width: w, height: h, ...(r !== undefined ? { borderRadius: r } : null), ...style }} />;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Recoverable-error line: the message plus an inline Retry when the caller can
|
|
76
|
-
// simply refetch. Same warm-red voice as transcript system errors.
|
|
77
|
-
export function ErrNote({ children, onRetry, retryLabel = "重试", style }: {
|
|
78
|
-
children: React.ReactNode; onRetry?: () => void; retryLabel?: string; style?: React.CSSProperties;
|
|
79
|
-
}) {
|
|
80
|
-
return (
|
|
81
|
-
<div className="err-note" style={style}>
|
|
82
|
-
<span className="err-msg">⚠ {children}</span>
|
|
83
|
-
{onRetry && <button className="btn btn-line btn-sm" onClick={onRetry}>{Icon.restore()} {retryLabel}</button>}
|
|
84
|
-
</div>
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
48
|
// One shared accessible popover for the full workspace and standalone controls.
|
|
89
49
|
export { Popover } from "../../ui/Popover";
|
|
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from
|
|
|
4
4
|
import type { TaskStreamEvent, ToolData, AskAnswers, Message } from "./protocol/types";
|
|
5
5
|
import { jget } from "./api";
|
|
6
6
|
import { openLive } from "./liveStream";
|
|
7
|
-
import { HISTORY_PAGE, SNAPSHOT_TAIL, indexFromEnd, mergeSnapshot, toolDataOf, touchRecent, trimToTail } from "
|
|
7
|
+
import { HISTORY_PAGE, SNAPSHOT_TAIL, indexFromEnd, mergeSnapshot, toolDataOf, touchRecent, trimToTail } from "../../ui/transcriptModel";
|
|
8
8
|
import { contextPct } from "./format";
|
|
9
9
|
import { capsFor } from "./agents";
|
|
10
10
|
import type { AgentsBundle, Msg, ProjectRow, TaskRow } from "./types";
|
|
@@ -96,7 +96,7 @@ export function useTaskStream({ selTask, selProjRef, agentsRef, setTaskRunning,
|
|
|
96
96
|
// objects, so a reconnect doesn't re-render the whole transcript.
|
|
97
97
|
const committed: Msg[] = ev.messages.map(toMsg);
|
|
98
98
|
const queued: Msg[] = ev.pending.map((p) => ({ id: p.id, role: "queued" as const, content: p.content, generation: p.generation, ts: p.created_at }));
|
|
99
|
-
const merged = mergeSnapshot(msgsRef.current[taskId], committed,
|
|
99
|
+
const merged = mergeSnapshot(msgsRef.current[taskId], committed, ev.older ?? 0, queued);
|
|
100
100
|
msgsRef.current = { ...msgsRef.current, [taskId]: merged.msgs };
|
|
101
101
|
setMsgsByTask((prev) => ({ ...prev, [taskId]: merged.msgs }));
|
|
102
102
|
setOlderByTask((prev) => ({ ...prev, [taskId]: merged.older }));
|
package/src/operator.base.css
CHANGED
|
@@ -1476,6 +1476,9 @@ button.pop-item:hover{background:var(--surface-2);}
|
|
|
1476
1476
|
.composer .comp-model .comp-setting>svg{width:12px;height:12px;}
|
|
1477
1477
|
.composer .comp-foot{flex-wrap:wrap;gap:6px;}
|
|
1478
1478
|
.composer .comp-foot>.send-group{margin-left:auto;}
|
|
1479
|
+
/* 智能体紧挨发送按钮:它和发送都用 margin-left:auto 会平分空位,智能体被挤到中间(PC 窄聊天栏也会进到这一档)。
|
|
1480
|
+
改为智能体占满空位、按钮靠右;换行时发送仍靠右。 */
|
|
1481
|
+
.composer .comp-foot>.comp-model{flex:1 1 auto;display:flex;justify-content:flex-end;margin-left:0;}
|
|
1479
1482
|
}
|
|
1480
1483
|
@media(pointer:coarse){
|
|
1481
1484
|
.composer-popover .composer-option{min-height:52px;}
|
package/src/operator.css
CHANGED
|
@@ -1476,6 +1476,9 @@
|
|
|
1476
1476
|
.cli-ui .composer .comp-model .comp-setting>svg{width:12px;height:12px;}
|
|
1477
1477
|
.cli-ui .composer .comp-foot{flex-wrap:wrap;gap:6px;}
|
|
1478
1478
|
.cli-ui .composer .comp-foot>.send-group{margin-left:auto;}
|
|
1479
|
+
/* 智能体紧挨发送按钮:它和发送都用 margin-left:auto 会平分空位,智能体被挤到中间(PC 窄聊天栏也会进到这一档)。
|
|
1480
|
+
改为智能体占满空位、按钮靠右;换行时发送仍靠右。 */
|
|
1481
|
+
.cli-ui .composer .comp-foot>.comp-model{flex:1 1 auto;display:flex;justify-content:flex-end;margin-left:0;}
|
|
1479
1482
|
}
|
|
1480
1483
|
@media(pointer:coarse){
|
|
1481
1484
|
.cli-ui .composer-popover .composer-option{min-height:52px;}
|
package/src/styles.css
CHANGED
|
@@ -183,6 +183,11 @@
|
|
|
183
183
|
.cli-ui .cli-fv-csv th{position:sticky;top:0;background:var(--surface-2);font-weight:600;}
|
|
184
184
|
.cli-ui .cli-fv-note{padding:18px 16px;font-size:13px;color:var(--ink-3);line-height:1.7;}
|
|
185
185
|
.cli-ui .cli-fv-note.err{color:var(--red);}
|
|
186
|
+
/* 相对路径打不开时按对话线索补全:查找中 / 几个候选(见 board/pathResolve.ts)。 */
|
|
187
|
+
.cli-ui .cli-fv-resolve{margin-top:10px;color:var(--ink-2);display:flex;flex-wrap:wrap;align-items:center;gap:6px;}
|
|
188
|
+
.cli-ui .cli-fv-candidate{max-width:100%;overflow:hidden;text-overflow:ellipsis;font-family:var(--mono);border:1px solid var(--line-2);}
|
|
189
|
+
.cli-ui .cli-fv-resolved{color:var(--ink-2);overflow-wrap:anywhere;}
|
|
190
|
+
.cli-ui .cli-fv-resolved code{font-family:var(--mono);font-size:11.5px;}
|
|
186
191
|
.cli-ui .cli-zip-bar{display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px;}
|
|
187
192
|
.cli-ui .cli-zip-meter{flex:1 1 100%;height:3px;border-radius:2px;background:var(--line-2);overflow:hidden;}
|
|
188
193
|
.cli-ui .cli-zip-meter>span{display:block;height:100%;background:var(--accent);transition:width .2s;}
|
package/src/ui/Markdown.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useContext, type ComponentProps, type MouseEvent } from "react";
|
|
1
|
+
import { memo, useContext, type ComponentProps, type MouseEvent, type ReactNode } from "react";
|
|
2
2
|
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
|
|
3
3
|
import remarkGfm from "remark-gfm";
|
|
4
4
|
import rehypeHighlight from "rehype-highlight";
|
|
@@ -24,11 +24,6 @@ function MdLink({ href, children, ...props }: ComponentProps<"a">) {
|
|
|
24
24
|
return <a {...props} href={href} target="_blank" rel="noreferrer">{children}</a>;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
function MdCode({children, ...props}: ComponentProps<"code">) {
|
|
28
|
-
const path=inlineFilePath(children);
|
|
29
|
-
return path ? <MdLink href={path}><code {...props}>{children}</code></MdLink> : <code {...props}>{children}</code>;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
27
|
function MdImg({ src, alt, ...props }: ComponentProps<"img">) {
|
|
33
28
|
const resolve = useContext(FileLinkContext);
|
|
34
29
|
const local = typeof src === "string" ? localFilePath(src) : null;
|
|
@@ -41,20 +36,40 @@ function MdImg({ src, alt, ...props }: ComponentProps<"img">) {
|
|
|
41
36
|
return <img {...props} alt={alt ?? ""} src={src} />;
|
|
42
37
|
}
|
|
43
38
|
|
|
44
|
-
// 缺省的地址处理会把 file: 等非网页协议清空;放行 file: 和 Windows
|
|
39
|
+
// 缺省的地址处理会把 file: 等非网页协议清空;放行 file: 和 Windows 路径,交给链接 / 图片组件按本地文件处理(与 Operator 一致)。
|
|
45
40
|
const urlTransform = (url: string) => (/^file:/i.test(url) || /^[a-z]:[\\/]/i.test(url) ? url : defaultUrlTransform(url));
|
|
46
41
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
)
|
|
60
|
-
|
|
42
|
+
/** 本地文件链接 / 图片怎么打开:各适配自己给(中枢:看板或宿主给的地址;节点:任务的文件接口)。其余渲染规则共用。 */
|
|
43
|
+
export interface MarkdownLinks {
|
|
44
|
+
Link: (props: ComponentProps<"a">) => ReactNode;
|
|
45
|
+
Image: (props: ComponentProps<"img">) => ReactNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 智能体 markdown 输出的渲染器(中枢与节点适配共用):标题、列表、表格、带语言的代码块(高亮)、行内代码、链接。
|
|
50
|
+
* 只高亮写明语言的代码块(自动识别在流式输出时很耗),并 memo,文字没变的消息重渲染时跳过整个解析。
|
|
51
|
+
* 长得像文件路径的行内代码(report.md、src/a.ts:12)按本地文件链接处理。
|
|
52
|
+
*/
|
|
53
|
+
export function createMarkdown({ Link, Image }: MarkdownLinks) {
|
|
54
|
+
function Code({ children, ...props }: ComponentProps<"code">) {
|
|
55
|
+
const path = inlineFilePath(children);
|
|
56
|
+
return path ? <Link href={path}><code {...props}>{children}</code></Link> : <code {...props}>{children}</code>;
|
|
57
|
+
}
|
|
58
|
+
const components = { a: Link, img: Image, code: Code };
|
|
59
|
+
return memo(function Markdown({ children, highlight = true }: { children: string; highlight?: boolean }) {
|
|
60
|
+
return (
|
|
61
|
+
<div className="md">
|
|
62
|
+
<ReactMarkdown
|
|
63
|
+
remarkPlugins={[remarkGfm]}
|
|
64
|
+
rehypePlugins={highlight ? [[rehypeHighlight, { detect: false, ignoreMissing: true }]] : []}
|
|
65
|
+
components={components}
|
|
66
|
+
urlTransform={urlTransform}
|
|
67
|
+
>
|
|
68
|
+
{children}
|
|
69
|
+
</ReactMarkdown>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const Markdown = createMarkdown({ Link: MdLink, Image: MdImg });
|