@spzxg/cli-ui 0.1.0 → 0.3.1

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.
@@ -1,24 +1,30 @@
1
1
  // cli-msglist:消息对话列表。界面取自 Operator 的任务栏(TasksColumn 的列表视图,不做看板),
2
- // 一张卡片是 center 里的一个对话任务(mode=conversation)。
2
+ // 一张卡片是 center 里的一个对话任务(mode=conversation)。对话任务里开了几个对话时,卡片下面按对话展开,
3
+ // 点哪个对话选哪个(选中值是对话键 "job#task");卡片上的「+」在这个对话任务里新开一个对话(同一个工作区)。
3
4
  import { useEffect, useMemo, useState, type CSSProperties } from "react";
4
5
  import type { CliEvent, JobEntry } from "../core/store.js";
5
- import type { ConversationCallback } from "../core/types.js";
6
6
  import type { CliStoreOptions, CliStore } from "../core/store.js";
7
- import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider.js";
8
- import { TERMINAL_JOB } from "../core/conversation.js";
7
+ import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider.js";
8
+ import { convKey, NEW_TASK, parseConvKey, TERMINAL_JOB } from "../core/conversation.js";
9
+ import type { ConversationInfo } from "../core/types.js";
9
10
  import { Icon } from "../ui/icons.js";
10
11
  import { agentLabel, relTime, toMs } from "../ui/format.js";
11
12
  import { AgentBadge, ErrNote, Skel, StatusDot, type DotCls } from "../ui/shared.js";
12
13
  import { resolveMsgListConfig, type CliMsgListConfig, type ResolvedMsgListConfig } from "../core/config.js";
13
14
 
14
- export interface CliMsgListProps extends CliStoreOptions {
15
+ /** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
16
+ export interface CliMsgListProps extends CliStoreOptions, ResultHandlers {
15
17
  /** 列表显示哪些对话(center 的 job_id)。不给就用本地记住的 + (discover 时)从 center 发现的。 */
16
18
  jobIds?: string[];
17
19
  /** 没给 jobIds 时,挂载后从 center 发现本产品线的对话。缺省 true。 */
18
20
  discover?: boolean;
19
- /** 受控的选中项;不给就用共享仓库里的选中(和 cli-chat 联动)。 */
21
+ /** 受控的选中项(对话键:job_id,或 "job_id#task_id");不给就用共享仓库里的选中(和 cli-chat 联动)。 */
20
22
  selectedId?: string | null;
21
- onSelect?: (jobId: string) => void;
23
+ /**
24
+ * 选了一个对话。id 是对话键:只有一个对话的对话任务是 job_id;对话任务里的某个对话是 "job_id#task_id";
25
+ * 点了「+」是 "job_id#new"(在这个对话任务里新开对话)。ref 是拆开的 jobId / taskId(新开时 taskId 为 "new")。
26
+ */
27
+ onSelect?: (id: string, ref: { jobId: string; taskId: string | null }) => void;
22
28
  /** 点「新对话」:缺省清空选中,cli-chat 会显示空白的新对话。 */
23
29
  onNew?: () => void;
24
30
  /** 顶部名称与色块(如业务 / 项目名)。 */
@@ -37,8 +43,7 @@ export interface CliMsgListProps extends CliStoreOptions {
37
43
  /** 界面配置:顶部栏、搜索、卡片内容、分组标题与空状态文案,见 core/config.ts。 */
38
44
  config?: CliMsgListConfig;
39
45
  onEvent?: (event: CliEvent) => void;
40
- /** 前端回调:载荷与 center 回调 conversation_turn / conversation_closed 的请求体同名同义。 */
41
- onCallback?: (payload: ConversationCallback) => void;
46
+
42
47
  store?: CliStore;
43
48
  }
44
49
 
@@ -48,6 +53,7 @@ export function CliMsgList(props: CliMsgListProps) {
48
53
  }
49
54
 
50
55
  const SEARCH_MIN = 6;
56
+ const BATCH_ACTIVE = new Set(["dispatching", "running", "verifying"]);
51
57
 
52
58
  interface CardView {
53
59
  id: string;
@@ -62,6 +68,8 @@ interface CardView {
62
68
  agent: string | null;
63
69
  turns: number;
64
70
  updatedAt: number;
71
+ /** 一次性任务(含续派,宿主把它的 job_id 放进了列表):只能查看,不能在里面新开对话。 */
72
+ batch: boolean;
65
73
  loading: boolean;
66
74
  error: string | null;
67
75
  }
@@ -70,7 +78,8 @@ export function cardView(id: string, entry: JobEntry | undefined): CardView {
70
78
  const job = entry?.job;
71
79
  const conv = job?.conversation;
72
80
  const updatedAt = toMs(conv?.last_activity_at) ?? 0;
73
- const base = { id, title: job?.title ?? id, desc: entry?.firstInput ?? "", agent: job?.tasks?.[0]?.agent ?? conv?.last_turn?.agent ?? null, turns: conv?.turns ?? 0, updatedAt, loading: !job && !entry?.error, error: entry?.error ?? null };
81
+ const batch = !!job && job.mode !== "conversation";
82
+ const base = { id, title: job?.title ?? id, desc: entry?.firstInput ?? "", agent: job?.tasks?.[0]?.agent ?? conv?.last_turn?.agent ?? null, turns: conv?.turns ?? 0, updatedAt, batch, loading: !job && !entry?.error, error: entry?.error ?? null };
74
83
  const status = job?.status ?? "queued";
75
84
  if (TERMINAL_JOB.has(status)) {
76
85
  const cancelled = status === "cancelled";
@@ -78,19 +87,55 @@ export function cardView(id: string, entry: JobEntry | undefined): CardView {
78
87
  const label = cancelled ? "已取消" : failed ? "已失败" : "已完成";
79
88
  return { ...base, cls: cancelled || failed ? "x" : "g", label, running: false, awaiting: false, group: cancelled ? "cancelled" : "done", activity: updatedAt ? `${label} · ${relTime(updatedAt)}` : label };
80
89
  }
81
- const running = !!conv?.running_turn;
90
+ // 一次性任务:有子任务在节点上跑就是运行中,没有轮次、不会停在提问上。
91
+ if (batch) {
92
+ const running = (job?.tasks ?? []).some((task) => BATCH_ACTIVE.has(task.status));
93
+ return { ...base, cls: "a", label: "进行中", running, awaiting: false, group: "active", activity: running ? "运行中 · 工作中" : status === "queued" ? "等待执行节点" : "进行中" };
94
+ }
95
+ // 开了几个对话:任何一个在跑就算在跑,任何一个停在提问上就要你处理(关掉的对话不算)。
96
+ const open = (conv?.conversations ?? []).filter((c) => !c.closed);
97
+ const many = (conv?.conversations?.length ?? 0) > 1;
98
+ const running = many ? open.some((c) => c.running_turn) : !!conv?.running_turn;
82
99
  // 上一轮以提问结束、后面没有新消息:等你回答。
83
- const awaiting = !running && !conv?.queued_turns && !!conv?.last_turn?.asked;
100
+ const awaiting = many ? open.some((c) => !c.running_turn && !c.queued_turns && !!c.last_turn?.asked) : !running && !conv?.queued_turns && !!conv?.last_turn?.asked;
84
101
  if (awaiting) return { ...base, cls: "a", label: "需要你处理", running, awaiting, group: "needs", activity: updatedAt ? `等你处理 · ${relTime(updatedAt)}` : "等你处理" };
85
102
  if (running) return { ...base, cls: "a", label: "进行中", running, awaiting, group: "active", activity: "运行中 · 工作中" };
86
103
  const waiting = conv?.queued_turns ? `${conv.queued_turns} 条排队中` : status === "queued" ? "等待执行节点" : updatedAt ? relTime(updatedAt) : "未开始";
87
104
  return { ...base, cls: "a", label: "进行中", running, awaiting, group: "active", activity: waiting };
88
105
  }
89
106
 
90
- function MsgCard({ v, selected, onSelect, show }: { v: CardView; selected: boolean; onSelect: () => void; show: ResolvedMsgListConfig["card"] }) {
107
+ /** 对话任务里的一个对话(对话任务开了几个对话时,卡片下面的一行)。 */
108
+ interface ConvRowView {
109
+ key: string;
110
+ title: string;
111
+ cls: DotCls;
112
+ label: string;
113
+ running: boolean;
114
+ awaiting: boolean;
115
+ activity: string;
116
+ turns: number;
117
+ }
118
+
119
+ /** 对话任务里的对话:只有一个时返回 [](整张卡片就是它)。 */
120
+ export function convRows(jobId: string, entry: JobEntry | undefined): ConvRowView[] {
121
+ const list = entry?.job?.conversation?.conversations ?? [];
122
+ if (list.length <= 1) return [];
123
+ const jobEnded = TERMINAL_JOB.has(entry?.job?.status ?? "");
124
+ return list.map((c: ConversationInfo) => {
125
+ const base = { key: convKey(jobId, c.task_id), title: c.title, turns: c.turns };
126
+ if (c.closed || jobEnded) return { ...base, cls: "g" as DotCls, label: "已结束", running: false, awaiting: false, activity: "已结束" };
127
+ const running = c.running_turn;
128
+ const awaiting = !running && !c.queued_turns && !!c.last_turn?.asked;
129
+ if (awaiting) return { ...base, cls: "a" as DotCls, label: "需要你处理", running, awaiting, activity: "等你处理" };
130
+ if (running) return { ...base, cls: "a" as DotCls, label: "进行中", running, awaiting, activity: "运行中" };
131
+ return { ...base, cls: "a" as DotCls, label: "进行中", running, awaiting, activity: c.queued_turns ? `${c.queued_turns} 条排队中` : "空闲" };
132
+ });
133
+ }
134
+
135
+ function MsgCard({ v, selected, onSelect, show, onNewInJob }: { v: CardView; selected: boolean; onSelect: () => void; show: ResolvedMsgListConfig["card"]; onNewInJob?: () => void }) {
91
136
  if (v.loading) return <CardSkeleton i={v.id.length} />;
92
137
  const foot = show.activity || (show.turns && v.turns > 0);
93
- return (
138
+ const card = (
94
139
  <button className={`task ${selected ? "sel" : ""} ${v.awaiting && show.status ? "awaiting" : ""}`} onClick={onSelect} title={v.error ?? undefined}>
95
140
  <div className="task-top">
96
141
  {show.status && <StatusDot cls={v.cls} running={v.running} awaiting={v.awaiting} title={v.label} />}
@@ -103,10 +148,29 @@ function MsgCard({ v, selected, onSelect, show }: { v: CardView; selected: boole
103
148
  {foot && <div className="task-foot">
104
149
  {show.activity && <span className="activity">{v.awaiting ? <span style={{ color: "var(--blue)" }}>●</span> : v.running ? <span style={{ color: "var(--amber)" }}>●</span> : null}{v.activity}</span>}
105
150
  <span className="spacer" />
106
- {show.turns && v.turns > 0 && <span className="activity">{v.turns} 轮对话</span>}
151
+ {show.turns && v.turns > 0 && <span className="activity">{v.turns} 条消息</span>}
107
152
  </div>}
108
153
  </button>
109
154
  );
155
+ if (!onNewInJob) return card;
156
+ // 「+」不能放进卡片按钮里(按钮不能嵌套):叠在卡片右下角。
157
+ return (
158
+ <div className="cli-job-card">
159
+ {card}
160
+ <button className="icon-btn cli-new-in-job" onClick={onNewInJob} title="在这个任务里新开一个对话(共用同一个工作区)" aria-label="在这个任务里新开一个对话">{Icon.plus()}</button>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ function ConvRow({ v, selected, onSelect, show }: { v: ConvRowView; selected: boolean; onSelect: () => void; show: ResolvedMsgListConfig["card"] }) {
166
+ return (
167
+ <button className={`cli-conv-row ${selected ? "sel" : ""} ${v.awaiting && show.status ? "awaiting" : ""}`} onClick={onSelect}>
168
+ {show.status && <StatusDot cls={v.cls} running={v.running} awaiting={v.awaiting} title={v.label} />}
169
+ <span className="cli-conv-title">{v.title}</span>
170
+ {show.activity && <span className="cli-conv-act">{v.activity}</span>}
171
+ {show.turns && v.turns > 0 && <span className="cli-conv-act">{v.turns} 条</span>}
172
+ </button>
173
+ );
110
174
  }
111
175
 
112
176
  function CardSkeleton({ i = 0 }: { i?: number }) {
@@ -119,9 +183,40 @@ function CardSkeleton({ i = 0 }: { i?: number }) {
119
183
  );
120
184
  }
121
185
 
122
- function Group({ label, cards, selected, onSelect, show, accent, collapsible, collapsed, onToggle }: { label: string | false; cards: CardView[]; selected: string | null; onSelect: (id: string) => void; show: ResolvedMsgListConfig["card"]; accent?: boolean; collapsible?: boolean; collapsed?: boolean; onToggle?: () => void }) {
186
+ type GroupProps = {
187
+ label: string | false; cards: CardView[]; selected: string | null; onSelect: (id: string) => void; show: ResolvedMsgListConfig["card"];
188
+ rows: Record<string, ConvRowView[]>; firstTask: Record<string, string | undefined>; onNewInJob?: (jobId: string) => void;
189
+ accent?: boolean; collapsible?: boolean; collapsed?: boolean; onToggle?: () => void;
190
+ };
191
+
192
+ function Group({ label, cards, selected, onSelect, show, rows, firstTask, onNewInJob, accent, collapsible, collapsed, onToggle }: GroupProps) {
123
193
  if (!cards.length || label === false) return null;
124
- const list = cards.map((v) => <MsgCard key={v.id} v={v} selected={v.id === selected} onSelect={() => onSelect(v.id)} show={show} />);
194
+ const sel = selected ? parseConvKey(selected) : null;
195
+ const list = cards.map((v) => {
196
+ const convs = rows[v.id] ?? [];
197
+ // 选中的是这个对话任务:只有 job_id 指它的第一个对话。
198
+ const selectedKey = sel?.jobId === v.id ? convKey(v.id, sel.taskId ?? firstTask[v.id]) : null;
199
+ const newing = sel?.jobId === v.id && sel.taskId === NEW_TASK;
200
+ const card = (
201
+ <MsgCard
202
+ key={v.id} v={v} show={show}
203
+ // 只有一个对话:整张卡片就是它;有几个对话:卡片是标题,选中落在下面的行上。
204
+ selected={!convs.length && !newing && sel?.jobId === v.id}
205
+ onSelect={() => onSelect(convs.length ? convs.find((c) => c.key === selectedKey)?.key ?? convs[0].key : v.id)}
206
+ onNewInJob={onNewInJob && !v.batch && v.group !== "done" && v.group !== "cancelled" ? () => onNewInJob(v.id) : undefined}
207
+ />
208
+ );
209
+ if (!convs.length && !newing) return card;
210
+ return (
211
+ <div key={v.id} className="cli-job-group">
212
+ {card}
213
+ <div className="cli-conv-rows">
214
+ {convs.map((c) => <ConvRow key={c.key} v={c} selected={c.key === selectedKey} onSelect={() => onSelect(c.key)} show={show} />)}
215
+ {newing && <div className="cli-conv-row sel"><span className="cli-conv-title">新对话 · 发第一条消息后开始</span></div>}
216
+ </div>
217
+ </div>
218
+ );
219
+ });
125
220
  if (collapsible) {
126
221
  return (
127
222
  <>
@@ -141,8 +236,8 @@ function Group({ label, cards, selected, onSelect, show, accent, collapsible, co
141
236
  );
142
237
  }
143
238
 
144
- function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, title = "对话", color = "#b8613f", context, onEditContext, onCollapse, pollMs = 10_000, config, theme = "light", className, style, width, onEvent, onCallback, inProvider }: CliMsgListProps & { inProvider: boolean }) {
145
- useComponentEvents(onEvent, onCallback, inProvider);
239
+ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, title = "对话", color = "#b8613f", context, onEditContext, onCollapse, pollMs = 10_000, config, theme = "light", className, style, width, onEvent, onTurnEnd, onConversationEnd, onCallback, inProvider }: CliMsgListProps & { inProvider: boolean }) {
240
+ useComponentEvents(onEvent, { onTurnEnd, onConversationEnd, onCallback }, inProvider);
146
241
  const configKey = JSON.stringify(config ?? {});
147
242
  const cfg = useMemo(() => resolveMsgListConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
148
243
  const h = cfg.header;
@@ -162,7 +257,10 @@ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, ti
162
257
  useEffect(() => { if (jobIds) store.setJobIds(jobIds); }, [store, idsKey]); // eslint-disable-line react-hooks/exhaustive-deps
163
258
  // 宿主选中了列表里还没有的对话(例如旁边独立的 cli-chat 刚新建出来的):加进列表,不用两个组件共享任何东西。
164
259
  // 放在 setJobIds 之后,宿主的 jobIds 更新后也会再补一次。
165
- useEffect(() => { if (selected && !ids.includes(selected)) store.addJob(selected); }, [selected, ids, store]);
260
+ useEffect(() => {
261
+ const jobId = selected ? parseConvKey(selected).jobId : null;
262
+ if (jobId && !ids.includes(jobId)) store.addJob(jobId);
263
+ }, [selected, ids, store]);
166
264
 
167
265
  // 首次:刷新已知的;没给 jobIds 时再去 center 发现。
168
266
  useEffect(() => {
@@ -183,7 +281,8 @@ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, ti
183
281
 
184
282
  const cards = useMemo(() => ids.map((id) => cardView(id, jobs[id])), [ids, jobs]);
185
283
  const q = query.trim().toLowerCase();
186
- const shown = q ? cards.filter((c) => c.title.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)) : cards;
284
+ const shown = q ? cards.filter((c) => c.title.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)
285
+ || (jobs[c.id]?.job?.conversation?.conversations ?? []).some((conv) => conv.title.toLowerCase().includes(q))) : cards;
187
286
  const byRecent = (a: CardView, b: CardView) => b.updatedAt - a.updatedAt;
188
287
  const groups = {
189
288
  needs: shown.filter((c) => c.group === "needs").sort(byRecent),
@@ -191,7 +290,11 @@ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, ti
191
290
  done: shown.filter((c) => c.group === "done").sort(byRecent),
192
291
  cancelled: shown.filter((c) => c.group === "cancelled").sort(byRecent),
193
292
  };
194
- const select = (id: string) => { if (selectedId === undefined) store.select(id); onSelect?.(id); };
293
+ const select = (id: string) => { if (selectedId === undefined) store.select(id); onSelect?.(id, parseConvKey(id)); };
294
+ const newInJob = cfg.card.newInJob ? (jobId: string) => select(convKey(jobId, NEW_TASK)) : undefined;
295
+ const rows = useMemo(() => Object.fromEntries(ids.map((id) => [id, convRows(id, jobs[id])])), [ids, jobs]);
296
+ const firstTask = useMemo(() => Object.fromEntries(ids.map((id) => [id, jobs[id]?.job?.conversation?.conversations?.[0]?.task_id ?? jobs[id]?.job?.tasks?.[0]?.id])), [ids, jobs]);
297
+ const groupProps = { selected, onSelect: select, show: cfg.card, rows, firstTask, onNewInJob: newInJob };
195
298
  const startNew = () => { if (onNew) onNew(); else store.select(null); };
196
299
  const firstLoad = !discovered && !cards.length;
197
300
  const showRow = h.show && (h.title || h.newButton || h.refresh || !!onCollapse);
@@ -234,10 +337,10 @@ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, ti
234
337
  <>
235
338
  {cards.length === 0 && <div className="empty" style={{ padding: "30px 16px" }}><div className="e-t">{cfg.emptyTitle}</div>{cfg.emptyDescription && <div className="e-s">{cfg.emptyDescription}</div>}</div>}
236
339
  {q && shown.length === 0 && <div className="search-empty">没有匹配“{query.trim()}”的对话。</div>}
237
- <Group label={cfg.groups.needs} cards={groups.needs} selected={selected} onSelect={select} show={cfg.card} accent />
238
- <Group label={cfg.groups.active} cards={groups.active} selected={selected} onSelect={select} show={cfg.card} />
239
- <Group label={cfg.groups.done} cards={groups.done} selected={selected} onSelect={select} show={cfg.card} collapsible collapsed={doneCollapsed && !q} onToggle={() => setDoneCollapsed((c) => !c)} />
240
- <Group label={cfg.groups.cancelled} cards={groups.cancelled} selected={selected} onSelect={select} show={cfg.card} collapsible collapsed={cancelledCollapsed && !q} onToggle={() => setCancelledCollapsed((c) => !c)} />
340
+ <Group label={cfg.groups.needs} cards={groups.needs} {...groupProps} accent />
341
+ <Group label={cfg.groups.active} cards={groups.active} {...groupProps} />
342
+ <Group label={cfg.groups.done} cards={groups.done} {...groupProps} collapsible collapsed={doneCollapsed && !q} onToggle={() => setDoneCollapsed((c) => !c)} />
343
+ <Group label={cfg.groups.cancelled} cards={groups.cancelled} {...groupProps} collapsible collapsed={cancelledCollapsed && !q} onToggle={() => setCancelledCollapsed((c) => !c)} />
241
344
  </>
242
345
  )}
243
346
  </div>
package/src/styles.css CHANGED
@@ -48,3 +48,127 @@
48
48
 
49
49
  /* 排队气泡下的等待原因(中枢给的:节点连不上、没有可用节点……) */
50
50
  .cli-ui .cli-queued-note{margin-top:6px;font-size:12px;color:var(--amber);}
51
+
52
+ /* Agent 消息里打不开的本地文件链接:显示成文字(路径在悬停提示里) */
53
+ .cli-ui .md .cli-local-link{color:var(--ink-2);border-bottom:1px dashed var(--line-strong);cursor:help;}
54
+
55
+ /* ── cli-board:右侧看板(Operator 右栏的样子 + 文件浏览 / 预览) ── */
56
+ .cli-ui.cli-file-preview,
57
+ .cli-ui.cli-browser-view{display:flex;flex-direction:column;min-height:0;min-width:0;height:100%;overflow:hidden;background:var(--panel);}
58
+ .cli-ui.cli-browser-view .rail-pad{flex:1;}
59
+ .cli-ui.cli-board{display:flex;flex-direction:column;min-height:0;min-width:0;height:100%;overflow:hidden;border-left:1px solid var(--line);}
60
+ .cli-ui.cli-board .rail-scroll{overflow:hidden;}
61
+ .cli-ui.cli-board.collapsed{background:var(--bg-2);}
62
+ .cli-ui .cli-board-strip{display:flex;flex-direction:column;align-items:center;gap:12px;width:100%;height:100%;padding:14px 0;color:var(--ink-3);}
63
+ .cli-ui .cli-board-strip:hover{background:var(--row-sel);color:var(--ink);}
64
+ .cli-ui .cli-board-strip svg{width:14px;height:14px;transform:rotate(180deg);}
65
+ .cli-ui .cli-board-strip-t{writing-mode:vertical-rl;font-family:var(--mono);font-size:11px;letter-spacing:.1em;}
66
+ .cli-ui.cli-board .tc-note{padding:18px 16px;font-size:13px;color:var(--ink-3);line-height:1.6;}
67
+ .cli-ui .cli-sync-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--ink-4);margin-right:5px;vertical-align:middle;}
68
+ .cli-ui .cli-sync-dot.on{background:var(--green);animation:cli-pulse 1.6s ease-in-out infinite;}
69
+ @keyframes cli-pulse{50%{opacity:.35;}}
70
+ .cli-ui .cli-tc-open{font-size:11px;color:var(--accent);padding:1px 6px;border-radius:5px;margin-left:6px;cursor:pointer;}
71
+ .cli-ui .cli-tc-open:hover{background:var(--accent-soft);}
72
+
73
+ .cli-ui .cli-files{display:flex;flex-direction:column;min-height:0;flex:1;}
74
+ .cli-ui .cli-crumbs{display:flex;align-items:center;flex-wrap:wrap;gap:2px;padding:10px 12px;border-bottom:1px solid var(--line-2);font-size:12.5px;}
75
+ .cli-ui .cli-crumb{display:inline-flex;align-items:center;gap:5px;padding:3px 6px;border-radius:6px;color:var(--ink-3);}
76
+ .cli-ui .cli-crumb svg{width:13px;height:13px;}
77
+ .cli-ui .cli-crumb.on{color:var(--ink);font-weight:600;}
78
+ .cli-ui .cli-crumb:hover{background:var(--row-sel);}
79
+ .cli-ui .cli-crumb-sep{color:var(--ink-4);}
80
+ .cli-ui .cli-files-sync{font-family:var(--mono);font-size:10.5px;color:var(--ink-4);}
81
+ .cli-ui .cli-files-list{flex:1;min-height:0;overflow-y:auto;padding:4px 0;}
82
+ .cli-ui .cli-frow{display:flex;align-items:center;width:100%;}
83
+ .cli-ui .cli-frow:hover{background:var(--row-sel);}
84
+ .cli-ui .cli-frow.fresh .cli-frow-name{color:var(--accent-ink);}
85
+ .cli-ui button.cli-frow,.cli-ui .cli-frow-main{display:flex;align-items:center;gap:8px;flex:1;min-width:0;padding:7px 12px;font-size:13px;text-align:left;color:var(--ink);}
86
+ .cli-ui .cli-frow-ic{display:inline-flex;color:var(--ink-3);}
87
+ .cli-ui .cli-frow.dir .cli-frow-ic{color:var(--accent);}
88
+ .cli-ui .cli-frow-ic svg{width:14px;height:14px;}
89
+ .cli-ui .cli-frow-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
90
+ .cli-ui .cli-frow-new{font-size:10.5px;color:var(--accent);background:var(--accent-soft);padding:1px 6px;border-radius:9px;}
91
+ .cli-ui .cli-frow-meta{font-family:var(--mono);font-size:10.5px;color:var(--ink-4);white-space:nowrap;}
92
+ .cli-ui .cli-frow-dl{display:inline-grid;place-items:center;width:28px;height:28px;margin-right:6px;border-radius:6px;color:var(--ink-3);opacity:0;}
93
+ .cli-ui .cli-frow:hover .cli-frow-dl,.cli-ui .cli-frow-dl:focus-visible{opacity:1;}
94
+ .cli-ui .cli-frow-dl:hover{background:var(--surface-2);color:var(--ink);}
95
+ .cli-ui .cli-frow-dl svg{width:14px;height:14px;}
96
+ @media (hover:none){.cli-ui .cli-frow-dl{opacity:1;}}
97
+
98
+ .cli-ui .cli-fv{display:flex;flex-direction:column;min-height:0;flex:1;background:var(--panel);}
99
+ .cli-ui .cli-fv-h{display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--line-2);font-size:12.5px;min-width:0;}
100
+ .cli-ui .cli-fv-ic{display:inline-flex;color:var(--ink-3);}
101
+ .cli-ui .cli-fv-ic svg{width:14px;height:14px;}
102
+ .cli-ui .cli-fv-name{font-weight:600;color:var(--ink);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
103
+ .cli-ui .cli-fv-meta{font-family:var(--mono);font-size:10.5px;color:var(--ink-4);white-space:nowrap;}
104
+ .cli-ui .cli-fv-sp{flex:1;}
105
+ .cli-ui .cli-fv-back{font-size:18px;line-height:1;width:26px;height:26px;border-radius:6px;color:var(--ink-3);}
106
+ .cli-ui .cli-fv-back:hover{background:var(--row-sel);color:var(--ink);}
107
+ .cli-ui .cli-fv-btn{font-size:12px;color:var(--accent);padding:3px 8px;border-radius:6px;white-space:nowrap;}
108
+ .cli-ui .cli-fv-btn:hover{background:var(--accent-soft);}
109
+ .cli-ui .cli-fv-icon{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:6px;color:var(--ink-3);flex:none;}
110
+ .cli-ui .cli-fv-icon:hover:not(:disabled){background:var(--surface-2);color:var(--ink);}
111
+ .cli-ui .cli-fv-icon:disabled{opacity:.4;}
112
+ .cli-ui .cli-fv-icon svg{width:14px;height:14px;}
113
+ .cli-ui .cli-fv-b{flex:1;min-height:0;overflow:auto;}
114
+ .cli-ui .cli-fv-b.k-pdf,.cli-ui .cli-fv-b.k-html,.cli-ui .cli-fv-b.k-docx{overflow:hidden;display:flex;}
115
+ .cli-ui .cli-fv-frame{flex:1;width:100%;height:100%;min-height:360px;border:0;background:#fff;}
116
+ .cli-ui .cli-fv-md{padding:16px 18px;}
117
+ .cli-ui .cli-fv-code .md pre{margin:0;border:0;border-radius:0;min-height:100%;}
118
+ .cli-ui .cli-fv-pre{margin:0;padding:12px 14px;font-family:var(--mono);font-size:12px;line-height:1.55;color:var(--ink);white-space:pre-wrap;word-break:break-word;}
119
+ .cli-ui .cli-fv-img{display:grid;place-items:center;padding:16px;min-height:100%;background:repeating-conic-gradient(var(--surface-2) 0 25%,transparent 0 50%) 0 0/16px 16px;}
120
+ .cli-ui .cli-fv-img img{max-width:100%;height:auto;box-shadow:var(--sh-1);}
121
+ .cli-ui .cli-fv-csv{overflow:auto;}
122
+ .cli-ui .cli-fv-csv table{border-collapse:collapse;font-size:12px;font-family:var(--mono);}
123
+ .cli-ui .cli-fv-csv th,.cli-ui .cli-fv-csv td{border:1px solid var(--line-2);padding:4px 8px;white-space:nowrap;text-align:left;}
124
+ .cli-ui .cli-fv-csv th{position:sticky;top:0;background:var(--surface-2);font-weight:600;}
125
+ .cli-ui .cli-fv-note{padding:18px 16px;font-size:13px;color:var(--ink-3);line-height:1.7;}
126
+ .cli-ui .cli-fv-note.err{color:var(--red);}
127
+ .cli-ui .cli-fv-trunc{padding:6px 14px;font-size:12px;color:var(--amber);background:var(--surface-2);border-bottom:1px solid var(--line-2);}
128
+
129
+ /* 对话里弹窗打开的文件 / 浏览器 */
130
+ .cli-ui .modal.cli-popup{width:min(960px,calc(100vw - 40px));height:min(82vh,900px);display:flex;flex-direction:column;overflow:hidden;}
131
+ .cli-ui .modal.cli-popup.browser .rail-pad{flex:1;}
132
+ .cli-ui .md a[href="#"]{cursor:pointer;}
133
+
134
+ /* ── 浏览器实时画面(canvas):按比例适应容器;接管时高亮 ── */
135
+ .cli-ui .bp-screen{display:flex;align-items:center;justify-content:center;}
136
+ /* 宽高都用 auto + max-*:替换元素按自身比例缩小,点击坐标和显示区域一致。 */
137
+ .cli-ui .bp-frame{display:block;max-width:100%;max-height:var(--cli-browser-max-h,70vh);width:auto;height:auto;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;}
138
+ .cli-ui .bp-frame.live{cursor:pointer;outline:2px solid var(--coral);outline-offset:-2px;}
139
+ /* 键盘 / 输入法接收框:不放到屏幕外(focus 时会把宿主的滚动容器拖过去),透明盖在左上角,不挡点击。 */
140
+ .cli-ui .bp-keys{position:absolute;left:0;top:0;width:1px;height:1px;opacity:0;pointer-events:none;resize:none;border:0;padding:0;}
141
+
142
+ /* ── 窄容器(手机、很窄的侧栏):预览工具栏换行,次要信息收起 ── */
143
+ .cli-ui .cli-fv{container-type:inline-size;}
144
+ .cli-ui .cli-fv-h{flex-wrap:wrap;row-gap:4px;}
145
+ @container (max-width:380px){
146
+ .cli-ui .cli-fv-meta{display:none;}
147
+ .cli-ui .cli-fv-btn{padding:3px 6px;}
148
+ }
149
+ .cli-ui.cli-board{container-type:inline-size;}
150
+ @container (max-width:340px){
151
+ .cli-ui .cli-frow-meta{display:none;}
152
+ .cli-ui .rail-tab{padding:12px 9px 10px;}
153
+ }
154
+ .cli-ui .scrim{z-index:var(--cli-popup-z,200);}
155
+
156
+ /* ── 对话列表:一个任务里的几个对话 ── */
157
+ .cli-ui .cli-job-card{position:relative;margin-bottom:8px;}
158
+ .cli-ui .cli-job-card .task{width:100%;margin-bottom:0;}
159
+ .cli-ui .cli-job-card .task .task-foot{padding-right:26px;}
160
+ .cli-ui .cli-new-in-job{position:absolute;right:8px;bottom:8px;width:22px;height:22px;opacity:0;transition:opacity .12s;}
161
+ .cli-ui .cli-job-card:hover .cli-new-in-job,
162
+ .cli-ui .cli-new-in-job:focus-visible{opacity:1;}
163
+ @media (hover:none){.cli-ui .cli-new-in-job{opacity:1;}}
164
+ .cli-ui .cli-job-group{margin-bottom:8px;}
165
+ .cli-ui .cli-job-group .cli-job-card,
166
+ .cli-ui .cli-job-group .task{margin-bottom:0;}
167
+ .cli-ui .cli-conv-rows{display:flex;flex-direction:column;gap:2px;margin:4px 0 0 14px;padding-left:8px;border-left:1px solid var(--line);}
168
+ .cli-ui .cli-conv-row{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:1px solid transparent;border-radius:var(--r);background:none;color:inherit;text-align:left;cursor:pointer;font-size:12.5px;}
169
+ .cli-ui div.cli-conv-row{cursor:default;color:var(--ink-3);}
170
+ .cli-ui .cli-conv-row:hover{background:var(--surface-2);}
171
+ .cli-ui .cli-conv-row.sel{border-color:var(--accent);background:var(--surface);}
172
+ .cli-ui .cli-conv-row.awaiting{box-shadow:inset 2px 0 0 var(--blue);}
173
+ .cli-ui .cli-conv-title{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
174
+ .cli-ui .cli-conv-act{font-family:var(--mono);font-size:10px;color:var(--ink-3);white-space:nowrap;}
@@ -1,23 +1,52 @@
1
- import { memo, type ComponentProps } from "react";
2
- import ReactMarkdown from "react-markdown";
1
+ import { memo, useContext, type ComponentProps, type MouseEvent } from "react";
2
+ import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
3
3
  import remarkGfm from "remark-gfm";
4
4
  import rehypeHighlight from "rehype-highlight";
5
+ import { FileLinkContext, FileOpenContext, localFilePath } from "./fileLinks.js";
5
6
 
6
7
  // 渲染 Agent 的 markdown 输出:标题、列表、表格、带语言的代码块(高亮)、行内代码、链接。
7
8
  // 与 Operator 的 Markdown.tsx 一致:只高亮写明语言的代码块(自动识别在流式输出时很耗),
8
9
  // 并 memo,文字没变的消息重渲染时跳过整个解析。
9
- // Operator 里本地文件链接会改走任务的文件接口;这里没有文件接口(见 PLAN.md 的看板组件),链接原样打开。
10
- function MdLink(props: ComponentProps<"a">) {
11
- return <a {...props} target="_blank" rel="noreferrer" />;
10
+ // 本地文件链接按 Operator 的规则识别:有地址(FileLinkContext)就改写过去,没有就显示成不可点的文字。
11
+ function MdLink({ href, children, ...props }: ComponentProps<"a">) {
12
+ const resolve = useContext(FileLinkContext);
13
+ const open = useContext(FileOpenContext);
14
+ const local = localFilePath(href);
15
+ if (local) {
16
+ const url = resolve?.(local, "link") ?? null;
17
+ if (!url && !open) return <span className="cli-local-link" title={`本地文件:${local}`}>{children}</span>;
18
+ const onClick = (e: MouseEvent<HTMLAnchorElement>) => {
19
+ if (!open || (url && (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0))) return;
20
+ if (open(local) || !url) e.preventDefault();
21
+ };
22
+ return <a {...props} href={url ?? "#"} target="_blank" rel="noreferrer" title={`打开文件:${local}`} onClick={onClick}>{children}</a>;
23
+ }
24
+ return <a {...props} href={href} target="_blank" rel="noreferrer">{children}</a>;
12
25
  }
13
26
 
27
+ function MdImg({ src, alt, ...props }: ComponentProps<"img">) {
28
+ const resolve = useContext(FileLinkContext);
29
+ const local = typeof src === "string" ? localFilePath(src) : null;
30
+ if (local) {
31
+ const url = resolve?.(local, "image") ?? null;
32
+ if (!url) return <span className="cli-local-link" title={`本地文件:${local}`}>{alt || local}</span>;
33
+ // eslint-disable-next-line jsx-a11y/alt-text
34
+ return <img {...props} alt={alt ?? ""} src={url} />;
35
+ }
36
+ return <img {...props} alt={alt ?? ""} src={src} />;
37
+ }
38
+
39
+ // 缺省的地址处理会把 file: 等非网页协议清空;放行 file: 和 Windows 路径,交给上面按本地文件处理(与 Operator 一致)。
40
+ const urlTransform = (url: string) => (/^file:/i.test(url) || /^[a-z]:[\\/]/i.test(url) ? url : defaultUrlTransform(url));
41
+
14
42
  export const Markdown = memo(function Markdown({ children }: { children: string }) {
15
43
  return (
16
44
  <div className="md">
17
45
  <ReactMarkdown
18
46
  remarkPlugins={[remarkGfm]}
19
47
  rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
20
- components={{ a: MdLink }}
48
+ components={{ a: MdLink, img: MdImg }}
49
+ urlTransform={urlTransform}
21
50
  >
22
51
  {children}
23
52
  </ReactMarkdown>
@@ -0,0 +1,46 @@
1
+ // Agent 消息里指向本地文件的链接([报告](report.md)、/Users/…/a.ts、file:///…、src/a.ts:12)。
2
+ // 识别规则移植自 Operator 的 app/orchestrator/fileLinks.ts:原样渲染的话浏览器会把它当成宿主网站根目录下的地址,打不开。
3
+ // Operator 把它改写到任务的文件接口;这里由宿主 / 后端给出地址(fileUrl),给不出就显示成不可点的文字,不留坏链接。
4
+ import { createContext } from "react";
5
+
6
+ /** 本地文件 → 能打开的地址;返回 null 表示打不开(显示成文字)。 */
7
+ export type FileUrlResolver = (path: string, kind: "link" | "image") => string | null;
8
+ export const FileLinkContext = createContext<FileUrlResolver | null>(null);
9
+
10
+ /**
11
+ * 点了本地文件链接:交给看板 / 弹窗打开,返回 true 表示接住了(链接不再按网址跳转)。
12
+ * 按住 ⌘/Ctrl/Shift 点、或中键点,仍然按网址在新窗口打开。
13
+ */
14
+ export type FileOpenHandler = (path: string) => boolean;
15
+ export const FileOpenContext = createContext<FileOpenHandler | null>(null);
16
+
17
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
18
+ const WIN_ABS = /^[a-z]:[\\/]/i;
19
+
20
+ function normalize(p: string): string {
21
+ const abs = p.startsWith("/");
22
+ const out: string[] = [];
23
+ for (const seg of p.split("/")) {
24
+ if (!seg || seg === ".") continue;
25
+ if (seg === ".." && out.length && out[out.length - 1] !== "..") out.pop();
26
+ else if (seg === ".." && abs) continue;
27
+ else out.push(seg);
28
+ }
29
+ return (abs ? "/" : "") + out.join("/");
30
+ }
31
+
32
+ /** 链接指向本地文件时返回路径(相对工作目录或绝对路径);网址、锚点等返回 null。规则与 Operator 一致。 */
33
+ export function localFilePath(href: string | undefined | null): string | null {
34
+ let h = String(href ?? "").trim();
35
+ if (!h || h.startsWith("#") || h.startsWith("//") || h.startsWith("/api/") || h.startsWith("/view?")) return null;
36
+ if (/^file:/i.test(h)) h = h.replace(/^file:(\/\/localhost)?(\/\/)?/i, "");
37
+ else if (SCHEME.test(h) && !WIN_ABS.test(h)) return null;
38
+ h = h.replace(/[?#].*$/, "");
39
+ try { h = decodeURI(h); } catch { /* 保持原样 */ }
40
+ h = h.replace(/\\/g, "/");
41
+ h = h.replace(/:(\d+)(:\d+)?$/, "");
42
+ if (!h) return null;
43
+ if (h === "~" || h.startsWith("~/")) return h;
44
+ if (h.startsWith("/") || WIN_ABS.test(h)) return WIN_ABS.test(h) ? h : normalize(h);
45
+ return normalize(h);
46
+ }
@@ -16,12 +16,30 @@ const toolCache = new WeakMap<Msg, ToolData>();
16
16
  export function toolDataOf(m: Msg): ToolData {
17
17
  let data = toolCache.get(m);
18
18
  if (!data) {
19
- try { data = JSON.parse(m.content) as ToolData; } catch { data = { title: m.content }; }
19
+ try { data = sanitizeTool(JSON.parse(m.content)); } catch { data = { title: m.content }; }
20
20
  toolCache.set(m, data);
21
21
  }
22
22
  return data;
23
23
  }
24
24
 
25
+ /**
26
+ * 工具卡片的数据来自节点,格式不对的部分丢掉而不是让整个对话渲染崩掉:
27
+ * 提问卡片要有 questions 数组,浏览器输入卡片要有 fields 数组,其余字段按类型保留。
28
+ */
29
+ function sanitizeTool(raw: unknown): ToolData {
30
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { title: typeof raw === "string" ? raw : String(raw) };
31
+ const d = { ...(raw as Record<string, unknown>) } as Record<string, unknown> & ToolData;
32
+ if (typeof d.title !== "string") d.title = d.title == null ? "" : String(d.title);
33
+ for (const k of ["detail", "result"] as const) if (d[k] != null && typeof d[k] !== "string") d[k] = JSON.stringify(d[k]);
34
+ const ask = d.ask as { questions?: unknown } | undefined;
35
+ if (ask && (typeof ask !== "object" || !Array.isArray(ask.questions))) delete d.ask;
36
+ const input = d.input as { fields?: unknown; id?: unknown } | undefined;
37
+ if (input && (typeof input !== "object" || !Array.isArray(input.fields))) delete d.input;
38
+ if (d.diff && !Array.isArray(d.diff)) delete d.diff;
39
+ if (d.peek && typeof d.peek !== "object") delete d.peek;
40
+ return d;
41
+ }
42
+
25
43
  /** 从最新一端找这个 id 的消息(实时更新都落在末尾)。 */
26
44
  export function indexFromEnd(arr: Msg[], id: string): number {
27
45
  for (let i = arr.length - 1; i >= 0; i--) if (arr[i].id === id) return i;