@spzxg/cli-ui 0.4.0 → 0.6.0

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.
@@ -2,6 +2,7 @@
2
2
  // 一张卡片是 center 里的一个对话任务(mode=conversation)。对话任务里开了几个对话时,卡片下面按对话展开,
3
3
  // 点哪个对话选哪个(选中值是对话键 "job#task");卡片上的「+」在这个对话任务里新开一个对话(同一个工作区)。
4
4
  import { useEffect, useMemo, useState, type CSSProperties } from "react";
5
+ import { CliErrorBoundary } from "../ui/ErrorBoundary.js";
5
6
  import type { CliEvent, JobEntry } from "../core/store.js";
6
7
  import type { CliStoreOptions, CliStore } from "../core/store.js";
7
8
  import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider.js";
@@ -14,10 +15,11 @@ import { resolveMsgListConfig, type CliMsgListConfig, type ResolvedMsgListConfig
14
15
 
15
16
  /** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
16
17
  export interface CliMsgListProps extends CliStoreOptions, ResultHandlers {
17
- /** 列表显示哪些对话(center 的 job_id)。不给就用本地记住的 + (discover 时)从 center 发现的。 */
18
+ /**
19
+ * 列表显示哪些对话(center 的 job_id),由宿主按自己的用户 / 业务给。
20
+ * 不给就是空列表(只显示本页新建的对话):组件不会去 center 列出这个产品线令牌下的全部对话。
21
+ */
18
22
  jobIds?: string[];
19
- /** 没给 jobIds 时,挂载后从 center 发现本产品线的对话。缺省 true。 */
20
- discover?: boolean;
21
23
  /** 受控的选中项(对话键:job_id,或 "job_id#task_id");不给就用共享仓库里的选中(和 cli-chat 联动)。 */
22
24
  selectedId?: string | null;
23
25
  /**
@@ -30,7 +32,10 @@ export interface CliMsgListProps extends CliStoreOptions, ResultHandlers {
30
32
  /** 顶部名称与色块(如业务 / 项目名)。 */
31
33
  title?: string;
32
34
  color?: string;
33
- /** 顶部的说明文字(对应 Operator 的项目上下文)。 */
35
+ /**
36
+ * 顶部的项目说明(只显示)。要让智能体读到,创建时用 newConversation.workspace.context,之后用 store.appendContext;
37
+ * onEditContext 给了就可以点开(宿主自己做编辑框)。
38
+ */
34
39
  context?: string;
35
40
  onEditContext?: () => void;
36
41
  onCollapse?: () => void;
@@ -49,7 +54,7 @@ export interface CliMsgListProps extends CliStoreOptions, ResultHandlers {
49
54
 
50
55
  export function CliMsgList(props: CliMsgListProps) {
51
56
  const ctx = useCliStoreContext();
52
- return withOwnProvider(props, <MsgListInner {...props} inProvider={!!ctx} />, !!ctx);
57
+ return <CliErrorBoundary component="cli-msglist" theme={props.theme}>{withOwnProvider(props, <MsgListInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
53
58
  }
54
59
 
55
60
  const SEARCH_MIN = 6;
@@ -119,8 +124,28 @@ interface ConvRowView {
119
124
  turns: number;
120
125
  }
121
126
 
122
- /** 对话任务里的对话:只有一个时返回 [](整张卡片就是它)。 */
127
+ /** 一次性任务里子任务的状态 → 行的样子。 */
128
+ const BATCH_ROW: Record<string, { cls: DotCls; label: string; running?: boolean }> = {
129
+ blocked: { cls: "r", label: "等待前置" }, queued: { cls: "r", label: "排队中" },
130
+ dispatching: { cls: "a", label: "运行中", running: true }, running: { cls: "a", label: "运行中", running: true }, verifying: { cls: "a", label: "核对中", running: true },
131
+ orphaned: { cls: "a", label: "等待节点" }, stalled: { cls: "a", label: "等待节点" }, paused: { cls: "r", label: "已暂停" },
132
+ succeeded: { cls: "g", label: "已完成" }, failed: { cls: "x", label: "失败" }, cancelled: { cls: "x", label: "已取消" },
133
+ };
134
+
135
+ /**
136
+ * 卡片下面的行:对话任务里的对话(只有一个时返回 [],整张卡片就是它);
137
+ * 一次性任务(宿主把它的 job_id 放进了列表)有几个子任务时,每个子任务一行——点哪行,对话和看板就看那个子任务。
138
+ */
123
139
  export function convRows(jobId: string, entry: JobEntry | undefined): ConvRowView[] {
140
+ const job = entry?.job;
141
+ if (job && job.mode !== "conversation") {
142
+ const tasks = [...(job.tasks ?? [])].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
143
+ if (tasks.length <= 1) return [];
144
+ return tasks.map((t) => {
145
+ const look = BATCH_ROW[t.status] ?? { cls: "a" as DotCls, label: t.status };
146
+ return { key: convKey(jobId, t.id), title: t.title, cls: look.cls, label: look.label, running: !!look.running, awaiting: false, activity: look.label, turns: 0 };
147
+ });
148
+ }
124
149
  const list = entry?.job?.conversation?.conversations ?? [];
125
150
  if (list.length <= 1) return [];
126
151
  const jobEnded = TERMINAL_JOB.has(entry?.job?.status ?? "");
@@ -166,9 +191,10 @@ function MsgCard({ v, selected, onSelect, show, onNewInJob }: { v: CardView; sel
166
191
  );
167
192
  }
168
193
 
169
- function ConvRow({ v, selected, onSelect, show }: { v: ConvRowView; selected: boolean; onSelect: () => void; show: ResolvedMsgListConfig["card"] }) {
194
+ function ConvRow({ v, selected, following, onSelect, show }: { v: ConvRowView; selected: boolean; following?: boolean; onSelect: () => void; show: ResolvedMsgListConfig["card"] }) {
170
195
  return (
171
- <button className={`cli-conv-row ${selected ? "sel" : ""} ${v.awaiting && show.status ? "awaiting" : ""}`} onClick={onSelect}>
196
+ <button className={`cli-conv-row ${selected ? "sel" : ""} ${following ? "following" : ""} ${v.awaiting && show.status ? "awaiting" : ""}`} onClick={onSelect}
197
+ title={following ? "对话和看板正在看这个子任务(跟着最近开始执行的子任务)" : undefined}>
172
198
  {show.status && <StatusDot cls={v.cls} running={v.running} awaiting={v.awaiting} title={v.label} />}
173
199
  <span className="cli-conv-title">{v.title}</span>
174
200
  {show.activity && show.status && <span className="cli-conv-act">{v.activity}</span>}
@@ -190,25 +216,28 @@ function CardSkeleton({ i = 0 }: { i?: number }) {
190
216
  type GroupProps = {
191
217
  label: string | false; cards: CardView[]; selected: string | null; onSelect: (id: string) => void; show: ResolvedMsgListConfig["card"];
192
218
  rows: Record<string, ConvRowView[]>; firstTask: Record<string, string | undefined>; onNewInJob?: (jobId: string) => void;
219
+ /** 选中一次性任务(不指定子任务)时,对话和看板实际在看的子任务:那一行标出来,三个组件看的是哪个一眼对得上。 */
220
+ following?: string | null;
193
221
  accent?: boolean; collapsible?: boolean; collapsed?: boolean; onToggle?: () => void;
194
222
  /** 不分组:只有卡片,没有组标题。 */
195
223
  plain?: boolean;
196
224
  };
197
225
 
198
- function Group({ label, cards, selected, onSelect, show, rows, firstTask, onNewInJob, accent, collapsible, collapsed, onToggle, plain }: GroupProps) {
226
+ function Group({ label, cards, selected, onSelect, show, rows, firstTask, onNewInJob, accent, collapsible, collapsed, onToggle, plain, following }: GroupProps) {
199
227
  if (!cards.length || label === false) return null;
200
228
  const sel = selected ? parseConvKey(selected) : null;
201
229
  const list = cards.map((v) => {
202
230
  const convs = rows[v.id] ?? [];
203
231
  // 选中的是这个对话任务:只有 job_id 指它的第一个对话。
204
- const selectedKey = sel?.jobId === v.id ? convKey(v.id, sel.taskId ?? firstTask[v.id]) : null;
232
+ // 一次性任务:只有 job_id = 跟着最近开始执行的子任务(卡片本身选中),不落在哪一行上。
233
+ const selectedKey = sel?.jobId === v.id ? (v.batch ? (sel.taskId ? convKey(v.id, sel.taskId) : null) : convKey(v.id, sel.taskId ?? firstTask[v.id])) : null;
205
234
  const newing = sel?.jobId === v.id && sel.taskId === NEW_TASK;
206
235
  const card = (
207
236
  <MsgCard
208
237
  key={v.id} v={v} show={show}
209
238
  // 只有一个对话:整张卡片就是它;有几个对话:卡片是标题,选中落在下面的行上。
210
- selected={!convs.length && !newing && sel?.jobId === v.id}
211
- onSelect={() => onSelect(convs.length ? convs.find((c) => c.key === selectedKey)?.key ?? convs[0].key : v.id)}
239
+ selected={(!convs.length || v.batch) && !newing && sel?.jobId === v.id && (!v.batch || !sel?.taskId)}
240
+ onSelect={() => onSelect(convs.length && !v.batch ? convs.find((c) => c.key === selectedKey)?.key ?? convs[0].key : v.id)}
212
241
  onNewInJob={onNewInJob && !v.batch && v.group !== "done" && v.group !== "cancelled" ? () => onNewInJob(v.id) : undefined}
213
242
  />
214
243
  );
@@ -217,7 +246,7 @@ function Group({ label, cards, selected, onSelect, show, rows, firstTask, onNewI
217
246
  <div key={v.id} className="cli-job-group">
218
247
  {card}
219
248
  <div className="cli-conv-rows">
220
- {convs.map((c) => <ConvRow key={c.key} v={c} selected={c.key === selectedKey} onSelect={() => onSelect(c.key)} show={show} />)}
249
+ {convs.map((c) => <ConvRow key={c.key} v={c} selected={c.key === selectedKey} following={!selectedKey && v.batch && sel?.jobId === v.id && c.key === (following ? convKey(v.id, following) : null)} onSelect={() => onSelect(c.key)} show={show} />)}
221
250
  {newing && <div className="cli-conv-row sel"><span className="cli-conv-title">新对话 · 发第一条消息后开始</span></div>}
222
251
  </div>
223
252
  </div>
@@ -243,7 +272,7 @@ function Group({ label, cards, selected, onSelect, show, rows, firstTask, onNewI
243
272
  );
244
273
  }
245
274
 
246
- 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 }) {
275
+ function MsgListInner({ jobIds, 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 }) {
247
276
  useComponentEvents(onEvent, { onTurnEnd, onConversationEnd, onCallback }, inProvider);
248
277
  const configKey = JSON.stringify(config ?? {});
249
278
  const cfg = useMemo(() => resolveMsgListConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -255,6 +284,7 @@ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, ti
255
284
  const listLoading = useCliState((s) => s.listLoading);
256
285
  const listError = useCliState((s) => s.listError);
257
286
  const selected = selectedId !== undefined ? selectedId : shared;
287
+ const following = useCliState((s) => (selected && !parseConvKey(selected).taskId ? s.convs[selected]?.task?.id ?? null : null));
258
288
  const [query, setQuery] = useState("");
259
289
  const [doneCollapsed, setDoneCollapsed] = useState(false);
260
290
  const [cancelledCollapsed, setCancelledCollapsed] = useState(true);
@@ -269,12 +299,9 @@ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, ti
269
299
  if (jobId && !ids.includes(jobId)) store.addJob(jobId);
270
300
  }, [selected, ids, store]);
271
301
 
272
- // 首次:刷新已知的;没给 jobIds 时再去 center 发现。
302
+ // 首次:拉一遍列表里已有的(宿主给的 jobIds)。
273
303
  useEffect(() => {
274
- void store.refreshAll({ all: true }).then(() => {
275
- if (!jobIds && discover) return store.discover().finally(() => setDiscovered(true));
276
- setDiscovered(true);
277
- });
304
+ void store.refreshAll({ all: true }).finally(() => setDiscovered(true));
278
305
  }, [store]); // eslint-disable-line react-hooks/exhaustive-deps
279
306
 
280
307
  useEffect(() => {
@@ -301,11 +328,12 @@ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, ti
301
328
  const newInJob = cfg.card.newInJob ? (jobId: string) => select(convKey(jobId, NEW_TASK)) : undefined;
302
329
  const rows = useMemo(() => Object.fromEntries(ids.map((id) => [id, convRows(id, jobs[id])])), [ids, jobs]);
303
330
  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]);
304
- const groupProps = { selected, onSelect: select, show: cfg.card, rows, firstTask, onNewInJob: newInJob };
331
+ const groupProps = { selected, onSelect: select, show: cfg.card, rows, firstTask, onNewInJob: newInJob, following };
305
332
  const startNew = () => { if (onNew) onNew(); else store.select(null); };
306
333
  const firstLoad = !discovered && !cards.length;
307
334
  const showRow = h.show && (h.title || h.newButton || h.refresh || !!onCollapse);
308
- const showContext = h.show && h.context && (context !== undefined || !!onEditContext);
335
+ // 只显示:有文字、或者宿主给了编辑入口时才出现(发给智能体的项目背景见 newConversation.workspace.context 和 store.appendContext)。
336
+ const showContext = h.show && h.context && (!!context || !!onEditContext);
309
337
  const showBanner = showRow || showContext;
310
338
 
311
339
  return (
@@ -325,7 +353,7 @@ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, ti
325
353
  </div>}
326
354
  {showContext && (
327
355
  <button className="pb-ctx" onClick={onEditContext} style={onEditContext ? undefined : { cursor: "default" }}>
328
- <div className={`ctx-txt ${context ? "" : "empty-ctx"}`}>{context || "添加上下文 —— 简介、背景与规范,会附加到每个对话前。"}</div>
356
+ <div className={`ctx-txt ${context ? "" : "empty-ctx"}`}>{context || "添加项目背景 —— 简介、范围与规范"}</div>
329
357
  {onEditContext && <div className="ctx-edit">{Icon.edit()} 上下文</div>}
330
358
  </button>
331
359
  )}
package/src/styles.css CHANGED
@@ -124,6 +124,12 @@
124
124
  .cli-ui .cli-fv-csv th{position:sticky;top:0;background:var(--surface-2);font-weight:600;}
125
125
  .cli-ui .cli-fv-note{padding:18px 16px;font-size:13px;color:var(--ink-3);line-height:1.7;}
126
126
  .cli-ui .cli-fv-note.err{color:var(--red);}
127
+ .cli-ui .cli-zip-bar{display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px;}
128
+ .cli-ui .cli-zip-meter{flex:1 1 100%;height:3px;border-radius:2px;background:var(--line-2);overflow:hidden;}
129
+ .cli-ui .cli-zip-meter>span{display:block;height:100%;background:var(--accent);transition:width .2s;}
130
+ .cli-ui .cli-fv-trunc.cli-files-err{color:var(--red);}
131
+ .cli-ui .cli-fv-slow{margin-top:6px;font-size:12px;color:var(--ink-3);}
132
+ .cli-ui .cli-fv-img-err{display:flex;flex-direction:column;align-items:center;gap:8px;padding:32px 16px;color:var(--red);font-size:13px;}
127
133
  .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
134
 
129
135
  /* 对话里弹窗打开的文件 / 浏览器 */
@@ -139,8 +145,20 @@
139
145
  /* 键盘 / 输入法接收框:不放到屏幕外(focus 时会把宿主的滚动容器拖过去),透明盖在左上角,不挡点击。 */
140
146
  .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
147
 
148
+ /* 网页弹出的 alert / confirm / prompt:画面里看不到,盖在画面上处理 */
149
+ .cli-ui .bp-dialog{position:absolute;inset:0;display:grid;place-items:center;background:rgba(0,0,0,.28);z-index:2;}
150
+ .cli-ui .bp-dialog-box{width:min(360px,calc(100% - 24px));background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:var(--r);box-shadow:var(--sh-2);padding:14px 16px;display:flex;flex-direction:column;gap:10px;}
151
+ .cli-ui .bp-dialog-t{font-size:12px;color:var(--ink-3);}
152
+ .cli-ui .bp-dialog-m{font-size:13.5px;line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:40vh;overflow:auto;}
153
+ .cli-ui .bp-dialog-in{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:var(--surface);color:var(--ink);font:inherit;}
154
+ .cli-ui .bp-dialog-hint{font-size:12px;color:var(--amber);}
155
+ .cli-ui .bp-dialog-a{display:flex;justify-content:flex-end;gap:8px;}
156
+
142
157
  /* ── 窄容器(手机、很窄的侧栏):预览工具栏换行,次要信息收起 ── */
143
158
  .cli-ui .cli-fv{container-type:inline-size;}
159
+ .cli-ui .cli-fv{position:relative;}
160
+ .cli-ui .cli-fv-selq{position:absolute;z-index:5;transform:translate(-50%,calc(-100% - 6px));display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:999px;font-size:12px;white-space:nowrap;color:#fff;background:var(--ink);box-shadow:var(--sh-2, 0 4px 14px rgba(0,0,0,.18));}
161
+ .cli-ui .cli-fv-selq svg{width:12px;height:12px;}
144
162
  .cli-ui .cli-fv-h{flex-wrap:wrap;row-gap:4px;}
145
163
  @container (max-width:380px){
146
164
  .cli-ui .cli-fv-meta{display:none;}
@@ -172,3 +190,86 @@
172
190
  .cli-ui .cli-conv-row.awaiting{box-shadow:inset 2px 0 0 var(--blue);}
173
191
  .cli-ui .cli-conv-title{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
174
192
  .cli-ui .cli-conv-act{font-family:var(--mono);font-size:10px;color:var(--ink-3);white-space:nowrap;}
193
+
194
+ /* 组件出错时只影响自己那一块 */
195
+ .cli-ui.cli-error{display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:16px;height:100%;background:var(--panel);color:var(--ink-2);font-size:13px;}
196
+ .cli-ui .cli-error-t{font-weight:600;color:var(--ink);}
197
+ .cli-ui .cli-error-m{color:var(--ink-3);font-family:var(--mono);font-size:11.5px;word-break:break-all;}
198
+
199
+ /* ── 可拖动分栏(CliSplit / <cli-split>,见 layout/split.ts) ── */
200
+ .cli-split{display:flex;align-items:stretch;width:100%;height:100%;min-height:0;overflow:hidden;}
201
+ .cli-split > .cli-split-pane{min-height:0;height:100%;overflow:hidden;}
202
+ .cli-split > .cli-split-pane > *{height:100%;}
203
+ /* 拖动条不占宽度:9px 的手柄压在两栏的分界线上,中间 1px 的线悬停 / 聚焦 / 拖动时变色 */
204
+ .cli-split > .cli-split-gutter{flex:0 0 9px;margin:0 -4px;position:relative;z-index:6;cursor:col-resize;touch-action:none;outline:none;}
205
+ .cli-split > .cli-split-gutter[hidden]{display:none;}
206
+ .cli-split > .cli-split-gutter::after{content:"";position:absolute;top:0;bottom:0;left:4px;width:1px;background:transparent;transition:background .12s,width .12s,left .12s;}
207
+ .cli-split > .cli-split-gutter:hover::after,
208
+ .cli-split > .cli-split-gutter:focus-visible::after,
209
+ .cli-split.cli-split-dragging > .cli-split-gutter:active::after{left:3px;width:3px;background:#b8613f;}
210
+ .cli-split.cli-split-dragging{cursor:col-resize;user-select:none;}
211
+ /* 拖动时 iframe(HTML / PDF 预览)不吃指针事件,否则拖过它就断了 */
212
+ .cli-split.cli-split-dragging iframe{pointer-events:none;}
213
+ cli-split{display:block;}
214
+
215
+ /* 工具卡片的预览:窄栏里按行换行,不出横向滚动条(diff 保持原样对齐) */
216
+ .cli-ui .tpeek-pre:not(.diff){white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:hidden;}
217
+ /* 一次性任务整体选中时,对话和看板正在跟的那个子任务 */
218
+ .cli-ui .cli-conv-row.following{border-color:var(--accent);border-style:dashed;background:var(--surface);}
219
+
220
+ /* 浏览器放大:盖住页面,画面按窗口大小显示(接管后登录、填验证码) */
221
+ .cli-ui.bp-backdrop{position:fixed;inset:0;background:rgba(20,23,28,.45);z-index:999;}
222
+ .cli-ui .bp.bp-big{position:fixed;inset:20px;z-index:1000;display:flex;flex-direction:column;gap:10px;background:var(--panel);border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.3);overflow:auto;--cli-browser-max-h:calc(100vh - 230px);}
223
+ .cli-ui .bp.bp-big .bp-viewer{flex:1 1 auto;min-height:0;}
224
+ .cli-ui .bp-head .bp-zoom{white-space:nowrap;}
225
+
226
+ /* 续等浏览器那边的人:收成一行 */
227
+ .cli-ui .cli-wait-fold{display:flex;align-items:center;gap:8px;width:100%;margin:6px 0;padding:7px 12px;border:1px dashed var(--line-strong);border-radius:var(--r);background:var(--surface-2);color:var(--ink-3);font-size:12.5px;text-align:left;cursor:pointer;}
228
+ .cli-ui .cli-wait-fold:hover{color:var(--ink-2);border-color:var(--accent);}
229
+ .cli-ui .cli-wait-more{margin-left:auto;font-size:11.5px;color:var(--accent);}
230
+
231
+ /* 文件预览放大:盖住页面 */
232
+ .cli-ui.cli-fv-backdrop{position:fixed;inset:0;background:rgba(20,23,28,.45);z-index:999;}
233
+ .cli-ui .cli-fv.cli-fv-big{position:fixed;inset:20px;z-index:1000;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.3);overflow:hidden;}
234
+ .cli-ui .cli-fv-zoom{font-size:14px;line-height:1;}
235
+ /* 图片缩放 */
236
+ .cli-ui .cli-fv-imgwrap{display:flex;flex-direction:column;min-height:100%;}
237
+ .cli-ui .cli-fv-imgbar{display:flex;align-items:center;gap:4px;padding:6px 10px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:1;}
238
+ .cli-ui .cli-fv-imgpct{font-family:var(--mono);font-size:11px;color:var(--ink-3);min-width:44px;text-align:center;}
239
+ .cli-ui .cli-fv-imgwrap .cli-fv-img{flex:1;}
240
+ .cli-ui .cli-fv-img.zoomed{display:block;overflow:auto;}
241
+ .cli-ui .cli-fv-img.zoomed img{display:block;margin:0 auto;}
242
+
243
+ /* 看板引用到对话:输入框上方的小卡片 */
244
+ .cli-ui .cli-refs{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:8px 16px 0;font-size:12px;}
245
+ .cli-ui .cli-refs-t{display:inline-flex;align-items:center;gap:4px;color:var(--ink-3);}
246
+ .cli-ui .cli-refs-t svg{width:13px;height:13px;}
247
+ .cli-ui .cli-ref{display:inline-flex;align-items:center;gap:4px;max-width:260px;padding:3px 4px 3px 9px;border:1px solid var(--line-strong);border-radius:999px;background:var(--surface);}
248
+ .cli-ui .cli-ref-l{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
249
+ .cli-ui .cli-ref-x{display:inline-grid;place-items:center;width:18px;height:18px;border-radius:50%;color:var(--ink-3);}
250
+ .cli-ui .cli-ref-x:hover{background:var(--surface-2);color:var(--ink);}
251
+ .cli-ui .cli-ref-x svg{width:10px;height:10px;}
252
+ .cli-ui .cli-ref.auto{border-style:dashed;background:transparent;}
253
+ .cli-ui .cli-ref-k{color:var(--ink-3);flex:none;}
254
+ .cli-ui .cli-ref.sel .cli-ref-l::before{content:"“";}
255
+
256
+ /* 看板页签:文件可以开多个(可关),页签多了横向滚动,刷新 / 收起按钮固定在右边。 */
257
+ .cli-ui .cli-board .rail-tabs{align-items:stretch;}
258
+ .cli-ui .rail-tabs-scroll{flex:1 1 auto;min-width:0;display:flex;gap:2px;overflow-x:auto;scrollbar-width:none;}
259
+ .cli-ui .rail-tabs-scroll::-webkit-scrollbar{display:none;}
260
+ .cli-ui .rail-tabs-scroll .rail-tab{flex:none;}
261
+ .cli-ui .rail-ftab{display:inline-flex;align-items:center;gap:2px;max-width:180px;padding-right:4px;letter-spacing:0;font-family:inherit;font-size:12px;}
262
+ .cli-ui .rail-ftab-l{display:inline-flex;align-items:center;gap:4px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:inherit;background:none;border:0;padding:0;cursor:pointer;font:inherit;}
263
+ .cli-ui .rail-ftab-l svg{width:12px;height:12px;flex:none;}
264
+ .cli-ui .rail-ftab-x{display:inline-grid;place-items:center;flex:none;width:16px;height:16px;border-radius:4px;border:0;background:none;color:var(--ink-4);cursor:pointer;opacity:.6;}
265
+ .cli-ui .rail-ftab:hover .rail-ftab-x,.cli-ui .rail-ftab.on .rail-ftab-x{opacity:1;}
266
+ .cli-ui .rail-ftab-x:hover{background:var(--surface-2);color:var(--ink);}
267
+ .cli-ui .rail-ftab-x svg{width:9px;height:9px;}
268
+ .cli-ui .bp-toast-btn{margin-left:8px;padding:1px 8px;border-radius:999px;border:1px solid rgba(255,255,255,.5);background:transparent;color:inherit;font-size:12px;cursor:pointer;}
269
+ .cli-ui .bp-toast-btn:hover{background:rgba(255,255,255,.15);}
270
+ .cli-ui .bp-toast{max-width:calc(100% - 24px);white-space:normal;text-align:center;}
271
+ /* 预览里的长链接、长单词(报告里常见的超长搜索地址)换行,不撑出看板。 */
272
+ .cli-ui .cli-fv-md{overflow-wrap:anywhere;}
273
+ .cli-ui .cli-fv-md pre,.cli-ui .cli-fv-md table{overflow-wrap:normal;}
274
+ .cli-ui .cli-refs.cli-ctxs+.cli-refs{padding-top:4px;}
275
+ .cli-ui .cli-ref.ctx{padding-right:9px;background:var(--surface-2);border-color:var(--line-2);}
@@ -0,0 +1,38 @@
1
+ // 组件嵌在业务方的页面里:组件自己渲染出错时只影响它自己那一块(显示「出错了,重试」),不能把宿主整个页面带白屏。
2
+ // 出错时在 window 上发一个 "cli-ui-error" 事件(detail: { component, error }),宿主可以接到自己的监控里。
3
+ import { Component, type ReactNode } from "react";
4
+
5
+ interface Props { component: string; theme?: "light" | "dark"; children: ReactNode }
6
+ interface State { error: Error | null; attempt: number }
7
+
8
+ export class CliErrorBoundary extends Component<Props, State> {
9
+ state: State = { error: null, attempt: 0 };
10
+
11
+ static getDerivedStateFromError(error: unknown): Partial<State> {
12
+ return { error: error instanceof Error ? error : new Error(String(error)) };
13
+ }
14
+
15
+ componentDidCatch(error: unknown) {
16
+ console.error(`[@spzxg/cli-ui] <${this.props.component}> 渲染出错`, error);
17
+ try {
18
+ window.dispatchEvent(new CustomEvent("cli-ui-error", { detail: { component: this.props.component, error } }));
19
+ } catch { /* 没有 window(服务端渲染)就不发 */ }
20
+ }
21
+
22
+ render() {
23
+ const { error, attempt } = this.state;
24
+ if (!error) return <ErrorKey key={attempt}>{this.props.children}</ErrorKey>;
25
+ return (
26
+ <div className="cli-ui cli-error" data-theme={this.props.theme ?? "light"} role="alert">
27
+ <div className="cli-error-t">这一块暂时显示不了</div>
28
+ <div className="cli-error-m">{error.message || "未知错误"}</div>
29
+ <button className="btn btn-line btn-sm" onClick={() => this.setState((s) => ({ error: null, attempt: s.attempt + 1 }))}>重试</button>
30
+ </div>
31
+ );
32
+ }
33
+ }
34
+
35
+ /** 重试时换 key:子树整个重建,不带着出错前的状态。 */
36
+ function ErrorKey({ children }: { children: ReactNode }) {
37
+ return <>{children}</>;
38
+ }
@@ -22,7 +22,7 @@ export const TextBlockCard = memo(function TextBlockCard({ block, onRemove }: {
22
22
  return (
23
23
  <div className={`cli-block ${open ? "open" : ""}`}>
24
24
  <div className="cli-block-h">
25
- <span className="cli-block-ic">{block.kind === "file" ? Icon.doc() : Icon.clip()}</span>
25
+ <span className="cli-block-ic">{block.kind === "file" ? Icon.doc() : block.kind === "context" ? Icon.board() : block.kind === "background" ? Icon.flag() : Icon.clip()}</span>
26
26
  <span className="cli-block-name" title={block.name}>{block.name}</span>
27
27
  <span className="cli-block-meta">{fmtCount(chars)} 字 · {fmtCount(lines)} 行</span>
28
28
  <span className="cli-block-sp" />
@@ -58,37 +58,94 @@ export function hasOpenAsk(messages: Msg[], scan = 400): boolean {
58
58
  }
59
59
 
60
60
  /**
61
- * 把(重连时的)全量转录合进已有的:没变的消息保持对象引用,memo 的行就不会重渲染(不会重新解析 markdown)。
61
+ * 把(重连时的)快照合进已有的:没变的消息保持对象引用,memo 的行就不会重渲染(不会重新解析 markdown)。
62
+ * 快照只是最新一段(older > 0)时,保留这个页面已经往上翻出来的更早的消息(与 Operator 的 mergeSnapshot 一致)。
62
63
  */
63
- export function mergeSnapshot(prev: Msg[] | undefined, committed: Msg[]): Msg[] {
64
+ export function mergeSnapshot(prev: Msg[] | undefined, committed: Msg[], older = 0): { msgs: Msg[]; older: number } {
64
65
  const byId = new Map<string, Msg>();
65
66
  for (const m of prev ?? []) byId.set(m.id, m);
66
- return committed.map((m) => {
67
+ const fresh = committed.map((m) => {
67
68
  const p = byId.get(m.id);
68
69
  if (!p || p.role !== m.role || p.content !== m.content || p.generation !== m.generation) return m;
69
70
  return p.ts === m.ts ? p : { ...p, ts: m.ts };
70
71
  });
72
+ let kept: Msg[] = [];
73
+ if (prev && fresh.length && older > 0) {
74
+ const at = prev.findIndex((m) => m.id === fresh[0].id);
75
+ // 只留落过库的(带时间的):本地补的说明行随旧视图一起作废。
76
+ if (at > 0) kept = prev.slice(0, at).filter((m) => m.role !== "queued" && m.ts != null);
77
+ }
78
+ return { msgs: [...kept, ...fresh], older: Math.max(0, older - kept.length) };
79
+ }
80
+
81
+ /** 往上翻取到的一页更早的消息接在最前面(已经有的跳过)。 */
82
+ export function prependOlder(prev: Msg[], page: Msg[]): Msg[] {
83
+ const have = new Set(prev.map((m) => m.id));
84
+ const add = page.filter((m) => !have.has(m.id));
85
+ return add.length ? [...add, ...prev] : prev;
71
86
  }
72
87
 
73
88
  export type TranscriptItem =
74
89
  | { kind: "label"; key: string; n: number; current: boolean }
75
90
  | { kind: "break"; key: string; summary: string }
76
- | { kind: "msg"; key: string; m: Msg; initial: boolean; hideWho: boolean };
91
+ | { kind: "msg"; key: string; m: Msg; initial: boolean; hideWho: boolean }
92
+ /** 收起的「等你在浏览器里操作」:AI 每 ~50 秒续等一次,每次都是「思考 + 工具 + 一句话」,几分钟就刷满一屏。 */
93
+ | { kind: "fold"; key: string; msgs: Msg[]; waits: number };
94
+
95
+ const WAIT_TOOL = /browser_handoff|browser_ask_user/;
96
+ /** 续等的结果:用户还没做完 / 还没填。 */
97
+ const STILL_WAITING = /hasn'?t (finished|answered|responded)|not (finished|answered|done) yet|still waiting|keep waiting|wait_only|还没(有)?(完成|回答|填)|继续等/i;
77
98
 
78
- /** 转录展开成扁平的行(会话标签、/clear 分隔、消息),界面可以只渲染其中一个窗口。 */
79
- export function buildTranscriptItems(messages: Msg[]): TranscriptItem[] {
99
+ /** 续等浏览器那边的人(browser_handoff / browser_ask_user 带 wait_only,或结果是「还没好」)。第一次请求不算。 */
100
+ export function isWaitCall(m: Msg): boolean {
101
+ if (m.role !== "tool") return false;
102
+ const d = toolDataOf(m);
103
+ if (!WAIT_TOOL.test(d.title)) return false;
104
+ if (d.result === undefined) return /wait_only/.test(d.detail ?? "");
105
+ return STILL_WAITING.test(d.result);
106
+ }
107
+ const isThinking = (m: Msg) => m.role === "tool" && /^\W*thinking\b/i.test(toolDataOf(m).title);
108
+ /** 两次续等之间的消息:智能体的一句话、思考。 */
109
+ const between = (m: Msg) => m.role === "assistant" || isThinking(m);
110
+
111
+ /**
112
+ * 一段连续的续等(中间只夹着思考和一句话):最后一次照常显示(它的状态是最新的),前面的收成一行。
113
+ * 返回收起的那段的结束位置(不含)和收起了几次续等;不到两次续等不收,返回 null。
114
+ */
115
+ function waitRun(msgs: Msg[], start: number): { end: number; waits: number } | null {
116
+ let last = start;
117
+ let waits = 1;
118
+ for (let i = start + 1; i < msgs.length; i++) {
119
+ if (isWaitCall(msgs[i])) { last = i; waits++; continue; }
120
+ if (!between(msgs[i])) break;
121
+ }
122
+ return waits >= 2 ? { end: last, waits: waits - 1 } : null;
123
+ }
124
+
125
+ /**
126
+ * 转录展开成扁平的行(会话标签、/clear 分隔、消息),界面可以只渲染其中一个窗口。
127
+ * hasOlder:更早的消息还没取,第一条就不一定是它那个会话的开场。
128
+ */
129
+ export function buildTranscriptItems(messages: Msg[], hasOlder = false): TranscriptItem[] {
80
130
  const sessions = buildSessions(messages);
81
131
  const items: TranscriptItem[] = [];
82
132
  sessions.forEach((s, si) => {
83
133
  if (si > 0 && s.summaryBefore) items.push({ kind: "break", key: `b-${s.n}`, summary: s.summaryBefore });
84
134
  items.push({ kind: "label", key: `s-${s.n}`, n: s.n, current: si === sessions.length - 1 });
85
- s.messages.forEach((m, mi) => {
135
+ for (let mi = 0; mi < s.messages.length; mi++) {
136
+ const m = s.messages[mi];
137
+ const run = isWaitCall(m) ? waitRun(s.messages, mi) : null;
138
+ if (run) {
139
+ items.push({ kind: "fold", key: `f-${m.id}`, msgs: s.messages.slice(mi, run.end), waits: run.waits });
140
+ mi = run.end - 1;
141
+ continue;
142
+ }
86
143
  const prev = s.messages[mi - 1];
87
144
  // 一段连续的智能体输出(文字 → 工具 → 文字)只显示一次头像行
88
145
  const hideWho = m.role === "assistant" && !!prev && (prev.role === "assistant" || prev.role === "tool");
89
- const initial = mi === 0 && m.role === "user";
146
+ const initial = mi === 0 && m.role === "user" && !(si === 0 && hasOlder);
90
147
  items.push({ kind: "msg", key: m.id, m, initial, hideWho });
91
- });
148
+ }
92
149
  });
93
150
  return items;
94
151
  }