@spzxg/cli-ui 0.4.0 → 0.6.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.
- package/README.md +84 -10
- package/dist/cli-ui-embed-Dk2QGHxn.js +41146 -0
- package/dist/cli-ui-index-BFpRZXn4.js +11209 -0
- package/dist/cli-ui-jszip.min-B9tqMA-e.js +2324 -0
- package/dist/cli-ui-jszip.min-DIavJzUG.js +26 -0
- package/dist/cli-ui-xlsx-BKHhqcoa.js +93 -0
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +3 -1
- package/src/board/BrowserPane.tsx +396 -96
- package/src/board/ChangesPane.tsx +13 -4
- package/src/board/CliBoard.tsx +219 -37
- package/src/board/FileViewer.tsx +387 -73
- package/src/board/FilesPane.tsx +51 -4
- package/src/board/blocks.tsx +3 -2
- package/src/board/fileKind.ts +31 -0
- package/src/board/fileOps.ts +55 -0
- package/src/board/folderDownload.ts +161 -0
- package/src/board/frameQuote.ts +50 -0
- package/src/board/textLoad.ts +99 -0
- package/src/board/workspace.ts +129 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +122 -23
- package/src/chat/Composer.tsx +4 -4
- package/src/core/blocks.ts +69 -11
- package/src/core/client.ts +285 -41
- package/src/core/config.ts +18 -5
- package/src/core/conversation.ts +17 -7
- package/src/core/store.ts +340 -71
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +21 -6
- package/src/core/xlsx.ts +142 -0
- package/src/elements.tsx +106 -11
- package/src/index.ts +7 -2
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +214 -0
- package/src/msglist/CliMsgList.tsx +50 -22
- package/src/styles.css +101 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/TextBlock.tsx +1 -1
- package/src/ui/Transcript.tsx +10 -5
- package/src/ui/transcriptModel.ts +66 -9
- package/dist/cli-ui-embed-D7xtcvGG.js +0 -37967
- package/dist/cli-ui-index-O0krpZBg.js +0 -13516
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 可拖动分栏:把一个容器里横向排的几栏(列表 / 对话 / 看板,或宿主自己的栏)之间加上拖动条,左右拖改宽度。
|
|
3
|
+
* 不依赖 React:React 版 CliSplit 和 Web Component 版 <cli-split> 都用它。
|
|
4
|
+
*
|
|
5
|
+
* attachSplit(container, { panes: [{ size: 300, min: 220, max: 480 }, { min: 360 }, { size: "42%", min: 320 }], storageKey: "strategy" })
|
|
6
|
+
*
|
|
7
|
+
* 规则:
|
|
8
|
+
* · 写了 size 的栏是定宽(px 或百分比,百分比按容器宽度换算成 px);没写 size 的栏占剩下的宽度(只能有一个,没有就是最后一栏)。
|
|
9
|
+
* · 拖动条改它旁边定宽的那一栏;任何一栏都不会小于自己的 min,占剩余宽度的那栏也一样。
|
|
10
|
+
* · 双击拖动条:那一栏回到初始宽度。键盘:拖动条能聚焦,←/→ 每次 16px(按住 Shift 64px)。
|
|
11
|
+
* · 子元素根节点带 collapsed 类(cli-board 收起时):那一栏变成 40px 窄条(--cli-split-collapsed),旁边的拖动条隐藏;展开后回到原来的宽度。
|
|
12
|
+
* · 写了 storageKey:拖好的宽度记在 localStorage(cli-ui:split:<storageKey>),下次打开还是这个宽度。
|
|
13
|
+
* · 窗口变窄时定宽栏按比例让出宽度,保证占剩余宽度的那栏不小于它的 min。
|
|
14
|
+
*/
|
|
15
|
+
export type SplitPane = { size?: number | `${number}%`; min?: number; max?: number };
|
|
16
|
+
export type SplitOptions = {
|
|
17
|
+
panes?: SplitPane[];
|
|
18
|
+
storageKey?: string;
|
|
19
|
+
/** 拖完(松手 / 键盘 / 双击)后的各栏宽度,占剩余宽度的那栏是 null。 */
|
|
20
|
+
onResize?: (sizes: (number | null)[]) => void;
|
|
21
|
+
};
|
|
22
|
+
export type SplitHandle = { update: (options: SplitOptions) => void; reset: () => void; destroy: () => void };
|
|
23
|
+
|
|
24
|
+
const DEFAULT_MIN = 160;
|
|
25
|
+
const STEP = 16;
|
|
26
|
+
|
|
27
|
+
export function attachSplit(container: HTMLElement, initial: SplitOptions = {}): SplitHandle {
|
|
28
|
+
let options = initial;
|
|
29
|
+
let sizes: (number | null)[] = [];
|
|
30
|
+
let gutters: HTMLElement[] = [];
|
|
31
|
+
let panes: HTMLElement[] = [];
|
|
32
|
+
let dragging = false;
|
|
33
|
+
|
|
34
|
+
container.classList.add("cli-split");
|
|
35
|
+
|
|
36
|
+
const key = () => (options.storageKey ? `cli-ui:split:${options.storageKey}` : null);
|
|
37
|
+
const load = (): (number | null)[] | null => {
|
|
38
|
+
const k = key();
|
|
39
|
+
if (!k) return null;
|
|
40
|
+
try { const v = JSON.parse(localStorage.getItem(k) ?? "null"); return Array.isArray(v) ? v : null; } catch { return null; }
|
|
41
|
+
};
|
|
42
|
+
const save = () => {
|
|
43
|
+
const k = key();
|
|
44
|
+
if (k) try { localStorage.setItem(k, JSON.stringify(sizes)); } catch { /* 存不下就不记 */ }
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const spec = (i: number): SplitPane => options.panes?.[i] ?? {};
|
|
48
|
+
const flexIndex = () => {
|
|
49
|
+
const i = panes.findIndex((_, n) => spec(n).size === undefined);
|
|
50
|
+
return i >= 0 ? i : panes.length - 1;
|
|
51
|
+
};
|
|
52
|
+
const minOf = (i: number) => spec(i).min ?? DEFAULT_MIN;
|
|
53
|
+
const initialSize = (i: number): number | null => {
|
|
54
|
+
if (i === flexIndex()) return null;
|
|
55
|
+
const s = spec(i).size;
|
|
56
|
+
if (typeof s === "string") return Math.round((parseFloat(s) / 100) * (container.clientWidth || 1200));
|
|
57
|
+
return s ?? 320;
|
|
58
|
+
};
|
|
59
|
+
const isCollapsed = (pane: HTMLElement) => !!pane.firstElementChild?.classList.contains("collapsed");
|
|
60
|
+
const clamp = (i: number, w: number) => Math.round(Math.min(spec(i).max ?? Infinity, Math.max(minOf(i), w)));
|
|
61
|
+
|
|
62
|
+
/** 定宽栏放进容器:总宽超了就按比例收,给占剩余宽度的那栏留出它的 min。 */
|
|
63
|
+
function fitted(): (number | null)[] {
|
|
64
|
+
const total = container.clientWidth;
|
|
65
|
+
const flex = flexIndex();
|
|
66
|
+
const fixed = sizes.map((s, i) => (i === flex || isCollapsed(panes[i]) ? 0 : s ?? 0));
|
|
67
|
+
const collapsedW = panes.reduce((sum, p, i) => sum + (i !== flex && isCollapsed(p) ? p.getBoundingClientRect().width : 0), 0);
|
|
68
|
+
const room = total - minOf(flex) - collapsedW;
|
|
69
|
+
const sum = fixed.reduce((a, b) => a + b, 0);
|
|
70
|
+
if (!total || sum <= room) return sizes;
|
|
71
|
+
const ratio = Math.max(0, room) / sum;
|
|
72
|
+
return sizes.map((s, i) => (i === flex || s === null ? null : Math.max(minOf(i), Math.floor(s * ratio))));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function apply() {
|
|
76
|
+
const flex = flexIndex();
|
|
77
|
+
const shown = fitted();
|
|
78
|
+
panes.forEach((pane, i) => {
|
|
79
|
+
pane.classList.add("cli-split-pane");
|
|
80
|
+
if (i === flex) { pane.style.flex = "1 1 0"; pane.style.width = ""; pane.style.minWidth = `${minOf(i)}px`; return; }
|
|
81
|
+
// 收起的栏(cli-board 的窄条宽度是 100%,没有自身宽度):固定窄条宽,宿主可用 --cli-split-collapsed 改。
|
|
82
|
+
if (isCollapsed(pane)) { pane.style.flex = "0 0 var(--cli-split-collapsed, 40px)"; pane.style.width = ""; pane.style.minWidth = ""; return; }
|
|
83
|
+
pane.style.flex = `0 0 ${shown[i]}px`;
|
|
84
|
+
pane.style.width = `${shown[i]}px`;
|
|
85
|
+
pane.style.minWidth = "0";
|
|
86
|
+
});
|
|
87
|
+
gutters.forEach((g, i) => {
|
|
88
|
+
const hidden = isCollapsed(panes[i]) || isCollapsed(panes[i + 1]);
|
|
89
|
+
g.hidden = hidden;
|
|
90
|
+
g.setAttribute("aria-valuenow", String(Math.round(panes[sideOf(i)]?.getBoundingClientRect().width ?? 0)));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 拖动条 i(在第 i 栏和第 i+1 栏之间)改的是哪一栏:左边定宽改左边,否则改右边。 */
|
|
95
|
+
const sideOf = (gutter: number) => (gutter === flexIndex() ? gutter + 1 : gutter);
|
|
96
|
+
|
|
97
|
+
function resize(gutter: number, dx: number) {
|
|
98
|
+
const target = sideOf(gutter);
|
|
99
|
+
const sign = target === gutter ? 1 : -1;
|
|
100
|
+
const flex = flexIndex();
|
|
101
|
+
const current = panes[target].getBoundingClientRect().width;
|
|
102
|
+
// 占剩余宽度的那栏不能被挤到 min 以下。
|
|
103
|
+
const flexW = panes[flex].getBoundingClientRect().width;
|
|
104
|
+
const grow = Math.min(sign * dx, flexW - minOf(flex));
|
|
105
|
+
sizes[target] = clamp(target, current + grow);
|
|
106
|
+
apply();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function finish() { save(); options.onResize?.([...sizes]); }
|
|
110
|
+
|
|
111
|
+
function makeGutter(index: number) {
|
|
112
|
+
const g = document.createElement("div");
|
|
113
|
+
g.className = "cli-split-gutter";
|
|
114
|
+
g.tabIndex = 0;
|
|
115
|
+
g.setAttribute("role", "separator");
|
|
116
|
+
g.setAttribute("aria-orientation", "vertical");
|
|
117
|
+
g.setAttribute("aria-label", "拖动调整宽度");
|
|
118
|
+
g.title = "拖动调整宽度,双击恢复";
|
|
119
|
+
g.addEventListener("pointerdown", (e) => {
|
|
120
|
+
if (e.button !== 0) return;
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
g.setPointerCapture(e.pointerId);
|
|
123
|
+
dragging = true;
|
|
124
|
+
container.classList.add("cli-split-dragging");
|
|
125
|
+
let last = e.clientX;
|
|
126
|
+
const move = (ev: PointerEvent) => { const dx = ev.clientX - last; if (!dx) return; last = ev.clientX; resize(index, dx); };
|
|
127
|
+
const up = () => {
|
|
128
|
+
dragging = false;
|
|
129
|
+
container.classList.remove("cli-split-dragging");
|
|
130
|
+
g.removeEventListener("pointermove", move);
|
|
131
|
+
g.removeEventListener("pointerup", up);
|
|
132
|
+
g.removeEventListener("pointercancel", up);
|
|
133
|
+
finish();
|
|
134
|
+
};
|
|
135
|
+
g.addEventListener("pointermove", move);
|
|
136
|
+
g.addEventListener("pointerup", up);
|
|
137
|
+
g.addEventListener("pointercancel", up);
|
|
138
|
+
});
|
|
139
|
+
g.addEventListener("dblclick", () => {
|
|
140
|
+
const target = sideOf(index);
|
|
141
|
+
sizes[target] = initialSize(target);
|
|
142
|
+
apply();
|
|
143
|
+
finish();
|
|
144
|
+
});
|
|
145
|
+
g.addEventListener("keydown", (e) => {
|
|
146
|
+
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
resize(index, (e.key === "ArrowLeft" ? -1 : 1) * (e.shiftKey ? STEP * 4 : STEP));
|
|
149
|
+
finish();
|
|
150
|
+
});
|
|
151
|
+
return g;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 子元素变了(宿主增删栏):重新找栏、重新插拖动条。 */
|
|
155
|
+
function rebuild() {
|
|
156
|
+
for (const g of gutters) g.remove();
|
|
157
|
+
panes = [...container.children].filter((c): c is HTMLElement => c instanceof HTMLElement && !c.classList.contains("cli-split-gutter"));
|
|
158
|
+
const saved = load();
|
|
159
|
+
const flex = flexIndex();
|
|
160
|
+
sizes = panes.map((_, i) => {
|
|
161
|
+
if (i === flex) return null;
|
|
162
|
+
const keep = sizes[i];
|
|
163
|
+
if (typeof keep === "number") return keep;
|
|
164
|
+
const s = saved && saved.length === panes.length ? saved[i] : null;
|
|
165
|
+
return typeof s === "number" ? clamp(i, s) : initialSize(i);
|
|
166
|
+
});
|
|
167
|
+
gutters = panes.slice(0, -1).map((_, i) => makeGutter(i));
|
|
168
|
+
gutters.forEach((g, i) => panes[i].after(g));
|
|
169
|
+
apply();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 只看栏的增删、栏的根节点换了、根节点的 class 变了(收起 / 展开);栏里面的内容(流式输出)不看,不然每个字都要重排。
|
|
173
|
+
const observer = new MutationObserver((records) => {
|
|
174
|
+
if (dragging) return;
|
|
175
|
+
const structural = records.some((r) => r.type === "childList" && r.target === container
|
|
176
|
+
&& [...r.addedNodes, ...r.removedNodes].some((n) => !(n instanceof HTMLElement && n.classList.contains("cli-split-gutter"))));
|
|
177
|
+
observer.disconnect();
|
|
178
|
+
if (structural) rebuild(); else apply();
|
|
179
|
+
observe();
|
|
180
|
+
});
|
|
181
|
+
const observe = () => {
|
|
182
|
+
observer.observe(container, { childList: true });
|
|
183
|
+
for (const p of panes) {
|
|
184
|
+
observer.observe(p, { childList: true });
|
|
185
|
+
if (p.firstElementChild) observer.observe(p.firstElementChild, { attributes: true, attributeFilter: ["class"] });
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => { if (!dragging) apply(); }) : null;
|
|
189
|
+
|
|
190
|
+
rebuild();
|
|
191
|
+
observe();
|
|
192
|
+
ro?.observe(container);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
update(next) {
|
|
196
|
+
const keyChanged = next.storageKey !== options.storageKey;
|
|
197
|
+
options = next;
|
|
198
|
+
if (keyChanged) sizes = [];
|
|
199
|
+
observer.disconnect(); rebuild(); observe();
|
|
200
|
+
},
|
|
201
|
+
reset() {
|
|
202
|
+
sizes = panes.map((_, i) => initialSize(i));
|
|
203
|
+
apply();
|
|
204
|
+
finish();
|
|
205
|
+
},
|
|
206
|
+
destroy() {
|
|
207
|
+
observer.disconnect();
|
|
208
|
+
ro?.disconnect();
|
|
209
|
+
for (const g of gutters) g.remove();
|
|
210
|
+
for (const p of panes) { p.style.flex = ""; p.style.width = ""; p.style.minWidth = ""; p.classList.remove("cli-split-pane"); }
|
|
211
|
+
container.classList.remove("cli-split", "cli-split-dragging");
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
302
|
+
// 首次:拉一遍列表里已有的(宿主给的 jobIds)。
|
|
273
303
|
useEffect(() => {
|
|
274
|
-
void store.refreshAll({ all: true }).
|
|
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
|
-
|
|
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 || "
|
|
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
|
+
}
|
package/src/ui/TextBlock.tsx
CHANGED
|
@@ -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" || block.kind === "viewing" ? 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" />
|
package/src/ui/Transcript.tsx
CHANGED
|
@@ -283,9 +283,10 @@ function AttachmentStrip({ items, attachmentUrl }: { items: MsgAttachment[]; att
|
|
|
283
283
|
}
|
|
284
284
|
|
|
285
285
|
/** 用户消息正文:普通文字按 markdown 渲染,附件 / 长粘贴是可折叠卡片,很长的纯文字也折叠。 */
|
|
286
|
-
function UserBody({ content, attachmentUrl }: { content: string; attachmentUrl?: (a: MsgAttachment) => string | null }) {
|
|
286
|
+
function UserBody({ content, attachmentUrl, viewing = true }: { content: string; attachmentUrl?: (a: MsgAttachment) => string | null; viewing?: boolean }) {
|
|
287
287
|
const { text, attachments } = splitAttachments(content);
|
|
288
|
-
|
|
288
|
+
// 看板上正在看的东西(缺省随消息带上的那块):配置成不显示时转录里也不显示。
|
|
289
|
+
const parts = (text ? splitMessage(text) : []).filter((p) => viewing || p.kind !== "block" || p.block.kind !== "viewing");
|
|
289
290
|
return (
|
|
290
291
|
<>
|
|
291
292
|
{parts.map((part, i) => part.kind === "block"
|
|
@@ -310,7 +311,11 @@ function LongText({ text }: { text: string }) {
|
|
|
310
311
|
}
|
|
311
312
|
|
|
312
313
|
/** 消息行的显示选项(来自组件配置)。要引用稳定,否则 memo 失效。 */
|
|
313
|
-
export interface MessageViewOptions {
|
|
314
|
+
export interface MessageViewOptions {
|
|
315
|
+
timestamps: boolean; initialBadge: boolean; userLabel: string; agentName: string;
|
|
316
|
+
/** 用户消息里「看板正在看」那块显示成卡片;false = 不显示(照样发给了智能体)。缺省显示。 */
|
|
317
|
+
viewingBlock?: boolean;
|
|
318
|
+
}
|
|
314
319
|
const DEFAULT_VIEW: MessageViewOptions = { timestamps: true, initialBadge: true, userLabel: "你", agentName: "智能体" };
|
|
315
320
|
|
|
316
321
|
export interface MessageHandlers {
|
|
@@ -340,7 +345,7 @@ export const MessageView = memo(function MessageView({ m, initial: isInitial, hi
|
|
|
340
345
|
<div className="msg user queued">
|
|
341
346
|
<div className="who"><Avatar who="user" /> {view.userLabel}<span className="badge queued-badge">排队中</span>{time}</div>
|
|
342
347
|
<div className="msg-body">
|
|
343
|
-
<UserBody content={m.content} attachmentUrl={handlers.attachmentUrl} />
|
|
348
|
+
<UserBody content={m.content} attachmentUrl={handlers.attachmentUrl} viewing={view.viewingBlock} />
|
|
344
349
|
{m.note && <div className="cli-queued-note">⏳ {m.note}</div>}
|
|
345
350
|
{handlers.onCancelQueued && <button className="queued-x" title="从队列中移除" aria-label="从队列中移除" onClick={() => handlers.onCancelQueued!(m)}>{Icon.x()}</button>}
|
|
346
351
|
</div>
|
|
@@ -388,7 +393,7 @@ export const MessageView = memo(function MessageView({ m, initial: isInitial, hi
|
|
|
388
393
|
)}
|
|
389
394
|
<div className="msg-body">
|
|
390
395
|
{initial && <div className="initial-tag">{Icon.spark()} 已随项目上下文发送</div>}
|
|
391
|
-
{isUser ? <UserBody content={m.content} attachmentUrl={handlers.attachmentUrl} /> : m.content && <Markdown>{m.content}</Markdown>}
|
|
396
|
+
{isUser ? <UserBody content={m.content} attachmentUrl={handlers.attachmentUrl} viewing={view.viewingBlock} /> : m.content && <Markdown>{m.content}</Markdown>}
|
|
392
397
|
</div>
|
|
393
398
|
</div>
|
|
394
399
|
);
|