@xgspz/cli-ui 0.6.15 → 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.
Files changed (68) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +21 -1
  3. package/dist/{cli-ui-embed-vIXVNs6k.js → cli-ui-embed-D9xgur2y.js} +18299 -18587
  4. package/dist/{cli-ui-index-R24x28OX.js → cli-ui-index-WBtxmw23.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-DUghzByz.js → cli-ui-jszip.min-CU3ZzoXb.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-DfH6rdQD.js → cli-ui-jszip.min-DdU0X3VJ.js} +2 -2
  7. package/dist/{cli-ui-xlsx-CP0lJhff.js → cli-ui-xlsx-Dx6T7qhC.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +1 -1
  11. package/src/board/ChangesPane.tsx +10 -4
  12. package/src/board/CliBoard.tsx +48 -16
  13. package/src/board/FileViewer.tsx +34 -1
  14. package/src/board/diffRefresh.ts +63 -0
  15. package/src/board/linkage.ts +68 -0
  16. package/src/board/pathResolve.ts +108 -0
  17. package/src/chat/CliChat.tsx +76 -181
  18. package/src/chat/Composer.tsx +4 -3
  19. package/src/core/conversation.ts +9 -1
  20. package/src/core/store.ts +112 -38
  21. package/src/msglist/CliMsgList.tsx +3 -2
  22. package/src/operator/node/AgentConnect.tsx +19 -15
  23. package/src/operator/node/AppearancePanel.tsx +1 -1
  24. package/src/operator/node/BranchNavigator.tsx +1 -1
  25. package/src/operator/node/BrowserPane.tsx +1 -1
  26. package/src/operator/node/Chat.tsx +16 -261
  27. package/src/operator/node/CommandPalette.tsx +1 -1
  28. package/src/operator/node/Composer.tsx +1 -1
  29. package/src/operator/node/Layout.tsx +1 -1
  30. package/src/operator/node/Markdown.tsx +6 -36
  31. package/src/operator/node/Modal.tsx +1 -1
  32. package/src/operator/node/OnboardingWizard.tsx +9 -5
  33. package/src/operator/node/Orchestrator.tsx +1 -1
  34. package/src/operator/node/ProjectLanding.tsx +1 -1
  35. package/src/operator/node/ProjectsColumn.tsx +1 -1
  36. package/src/operator/node/ProviderAuth.tsx +12 -10
  37. package/src/operator/node/RunSwitch.tsx +1 -1
  38. package/src/operator/node/Services.tsx +1 -1
  39. package/src/operator/node/SessionRail.tsx +27 -8
  40. package/src/operator/node/SessionView.tsx +1 -1
  41. package/src/operator/node/SettingsView.tsx +1 -1
  42. package/src/operator/node/TaskBoard.tsx +1 -1
  43. package/src/operator/node/TaskChanges.tsx +34 -5
  44. package/src/operator/node/TaskHero.tsx +1 -1
  45. package/src/operator/node/TasksColumn.tsx +1 -1
  46. package/src/operator/node/Transcript.tsx +10 -237
  47. package/src/operator/node/Welcome.tsx +1 -1
  48. package/src/operator/node/WorktreePrune.tsx +1 -1
  49. package/src/operator/node/boardInteraction.ts +6 -3
  50. package/src/operator/node/boardState.ts +12 -13
  51. package/src/operator/node/fileLinks.ts +4 -38
  52. package/src/operator/node/format.ts +12 -104
  53. package/src/operator/node/github.tsx +10 -8
  54. package/src/operator/node/modals.tsx +15 -13
  55. package/src/operator/node/recheckOnReturn.ts +28 -0
  56. package/src/operator/node/shared.tsx +10 -50
  57. package/src/operator/node/useTaskStream.ts +2 -2
  58. package/src/styles.css +5 -0
  59. package/src/ui/Markdown.tsx +36 -21
  60. package/src/ui/Transcript.tsx +8 -5
  61. package/src/ui/fileLinks.ts +6 -3
  62. package/src/ui/format.ts +11 -5
  63. package/src/ui/shared.tsx +11 -7
  64. package/src/ui/transcriptModel.ts +40 -9
  65. package/src/ui/useTranscriptWindow.ts +253 -0
  66. package/src/operator/node/boundedPoll.ts +0 -47
  67. package/src/operator/node/icons.tsx +0 -88
  68. 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 shared across the orchestrator modules.
2
- import type { AskQuestion, AskAnswers } from "./protocol/types";
3
- import type { Msg, TaskRow, AgentCapabilities, AgentInfo } from "./types";
4
- import type { InternalUsageEstimate } from "./types";
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
- // Compact token count: 1234 → "1.2k", 1_200_000 → "1.2M".
7
- export function fmtTokens(n: number): string {
8
- if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`;
9
- if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`;
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 "./icons";
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 { useBoundedPoll } from "./boundedPoll";
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 — for a
74
- // bounded window, and again when the user comes back from github.com (useBoundedPoll).
75
- useBoundedPoll(!!login && (login.status === "starting" || login.status === "awaiting"), () => {
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
- }, 2000, 10 * 60_000);
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: 14 }}>正在启动 GitHub 登录…</LoadNote>;
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 上完成授权…</div>
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 "./icons";
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 { useBoundedPoll } from "./boundedPoll";
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 polls.
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 polls for the result
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 polling effect / stale-close it).
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 poll tick race.
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 polled job state into the UI. Idempotent: applies a finished draft at
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 — for a bounded window, and again when
429
- // the user comes back (useBoundedPoll). Stops when refreshing flips false
430
- // (terminal state) or the modal unmounts — the job keeps running server-side.
431
- useBoundedPoll(refreshing, () => {
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
- }, 2500, 15 * 60_000);
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, AgentMark } from "./icons";
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
- // Signal language (mission-control): "needs your input" is an alert coral, a
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
- // Chat avatar. The assistant side wears the brand mark of the agent the task
41
- // actually runs on (Claude / Codex), so a transcript says at a glance who wrote
42
- // it; an unknown or missing agent id falls back to the generic bolt.
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 "./transcriptModel";
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, queued, ev.older ?? 0);
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/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;}
@@ -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 路径,交给上面按本地文件处理(与 Operator 一致)。
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
- export const Markdown = memo(function Markdown({ children, highlight = true }: { children: string; highlight?: boolean }) {
48
- return (
49
- <div className="md">
50
- <ReactMarkdown
51
- remarkPlugins={[remarkGfm]}
52
- rehypePlugins={highlight ? [[rehypeHighlight, { detect: false, ignoreMissing: true }]] : []}
53
- components={{ a: MdLink, img: MdImg, code: MdCode }}
54
- urlTransform={urlTransform}
55
- >
56
- {children}
57
- </ReactMarkdown>
58
- </div>
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 });
@@ -24,7 +24,8 @@ const LOUD_NOTICES = [
24
24
  ];
25
25
  const APPROVAL_BLOCKED = LOUD_NOTICES[3];
26
26
 
27
- function PeekView({ peek, expandable, onExpand }: { peek: ToolPeek; expandable: boolean; onExpand: () => void }) {
27
+ // 工具卡片、提问卡片、浏览器输入卡片、会话分隔:中枢与节点适配共用(节点的 Transcript 只自己画消息行)。
28
+ export function PeekView({ peek, expandable, onExpand }: { peek: ToolPeek; expandable: boolean; onExpand: () => void }) {
28
29
  const corner = <span className="tcorner">⎿</span>;
29
30
  if (peek.kind === "count") {
30
31
  return (
@@ -64,7 +65,7 @@ function PeekView({ peek, expandable, onExpand }: { peek: ToolPeek; expandable:
64
65
  );
65
66
  }
66
67
 
67
- function ToolView({ data }: { data: ToolData }) {
68
+ export function ToolView({ data }: { data: ToolData }) {
68
69
  const [open, setOpen] = useState<boolean | null>(null);
69
70
  const bodyId = useId();
70
71
  const hasDiff = !!data.diff?.length;
@@ -96,7 +97,7 @@ function ToolView({ data }: { data: ToolData }) {
96
97
  }
97
98
 
98
99
  // AskUserQuestion 卡片。对话任务里回答 = 发下一条消息(节点上的回合已经结束),所以提交后由上层组成文字发出。
99
- function AskView({ data, agentLabel, onAnswer }: { data: ToolData; agentLabel: string; onAnswer?: (answers: AskAnswers) => Promise<void> | void }) {
100
+ export function AskView({ data, agentLabel, onAnswer }: { data: ToolData; agentLabel: string; onAnswer?: (answers: AskAnswers) => Promise<void> | void }) {
100
101
  const questions = data.ask?.questions ?? [];
101
102
  const existing = data.ask?.answers;
102
103
  const [state, setState] = useState(() => questions.map(() => ({ picked: [] as string[], other: "" })));
@@ -164,7 +165,8 @@ function AskView({ data, agentLabel, onAnswer }: { data: ToolData; agentLabel: s
164
165
  // browser_ask_user 卡片:移植自 Operator 的 InputView(已答 / 已取消 / 等待中)。
165
166
  // 能不能在这里提交取决于数据后端:Operator 后端可以直接把值交给浏览器脚本(onSubmit);
166
167
  // center 的对话任务在 Agent 等输入时会结束那一轮,卡片随即变成「已取消」,这里不提供提交,只显示等待中的字段。
167
- function InputView({ input, agentLabel, onSubmit, onBrowser }: { input: InputRequest; agentLabel: string; onSubmit?: (id: string, values: string[]) => Promise<string | null>; onBrowser?: () => void }) {
168
+ /** unavailableNote:没有提交能力时的说明(缺省说明去节点界面提交;节点适配自己的只读视图传 null)。 */
169
+ export function InputView({ input, agentLabel, onSubmit, onBrowser, unavailableNote = "请在执行节点的 Operator 界面里提交(密码只交给浏览器脚本,不经对话发送)" }: { input: InputRequest; agentLabel: string; onSubmit?: (id: string, values: string[]) => Promise<string | null>; onBrowser?: () => void; unavailableNote?: string | null }) {
168
170
  const [vals, setVals] = useState<string[]>(() => input.fields.map(() => ""));
169
171
  const [shown, setShown] = useState<boolean[]>(() => input.fields.map(() => false));
170
172
  const [busy, setBusy] = useState(false);
@@ -221,6 +223,7 @@ function InputView({ input, agentLabel, onSubmit, onBrowser }: { input: InputReq
221
223
  placeholder={f.options?.length ? "其他…" : f.placeholder || `输入${f.label}`}
222
224
  value={f.options?.includes(vals[i]) ? "" : vals[i]}
223
225
  disabled={busy || !canSubmit}
226
+ autoFocus={canSubmit && i === 0 && !f.options?.length}
224
227
  onChange={(e) => set(i, e.target.value)}
225
228
  onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) void submit(); }}
226
229
  />
@@ -236,7 +239,7 @@ function InputView({ input, agentLabel, onSubmit, onBrowser }: { input: InputReq
236
239
  <div className="ask-foot" style={{ gap: 8, alignItems: "center" }}>
237
240
  {err && <span className="input-err">{err}</span>}
238
241
  {!err && canSubmit && input.fields.some((f) => f.secret) && <span className="input-note">密码只交给浏览器脚本,不保存、不发给模型</span>}
239
- {!canSubmit && <span className="input-note">请在执行节点的 Operator 界面里提交(密码只交给浏览器脚本,不经对话发送)</span>}
242
+ {!canSubmit && unavailableNote && <span className="input-note">{unavailableNote}</span>}
240
243
  <span style={{ flex: 1 }} />
241
244
  {onBrowser && <button className="btn btn-line btn-sm" onClick={onBrowser}>查看浏览器</button>}
242
245
  <button className="btn btn-accent btn-sm" onClick={() => void submit()} disabled={!complete || busy || !canSubmit}>{busy ? "发送中…" : "提交"}</button>