@spzxg/cli-ui 0.0.0-stage → 0.3.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.
- package/README.md +163 -2
- package/dist/cli-ui-embed-hutDQhkT.js +37841 -0
- package/dist/cli-ui-index-C8TOk7M3.js +13516 -0
- package/dist/cli-ui.css +1 -0
- package/dist/cli-ui.js +17 -0
- package/package.json +49 -4
- package/src/board/BrowserPane.tsx +418 -0
- package/src/board/ChangesPane.tsx +142 -0
- package/src/board/CliBoard.tsx +246 -0
- package/src/board/FileViewer.tsx +257 -0
- package/src/board/FilesPane.tsx +109 -0
- package/src/board/blocks.tsx +96 -0
- package/src/board/fileKind.ts +81 -0
- package/src/chat/CliChat.tsx +639 -0
- package/src/chat/Composer.tsx +194 -0
- package/src/chat/export.ts +34 -0
- package/src/core/blocks.ts +132 -0
- package/src/core/client.ts +441 -0
- package/src/core/config.ts +222 -0
- package/src/core/conversation.ts +439 -0
- package/src/core/provider.tsx +103 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +575 -0
- package/src/core/types.ts +385 -0
- package/src/elements.tsx +166 -0
- package/src/embed.ts +58 -0
- package/src/index.ts +40 -0
- package/src/msglist/CliMsgList.tsx +342 -0
- package/src/operator.css +1628 -0
- package/src/styles.css +174 -0
- package/src/ui/Markdown.tsx +55 -0
- package/src/ui/TextBlock.tsx +49 -0
- package/src/ui/Transcript.tsx +408 -0
- package/src/ui/fileLinks.ts +46 -0
- package/src/ui/format.ts +101 -0
- package/src/ui/icons.tsx +89 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +94 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// cli-msglist:消息对话列表。界面取自 Operator 的任务栏(TasksColumn 的列表视图,不做看板),
|
|
2
|
+
// 一张卡片是 center 里的一个对话任务(mode=conversation)。对话任务里开了几个对话时,卡片下面按对话展开,
|
|
3
|
+
// 点哪个对话选哪个(选中值是对话键 "job#task");卡片上的「+」在这个对话任务里新开一个对话(同一个工作区)。
|
|
4
|
+
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
|
5
|
+
import type { CliEvent, JobEntry } from "../core/store.js";
|
|
6
|
+
import type { CliStoreOptions, CliStore } from "../core/store.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";
|
|
10
|
+
import { Icon } from "../ui/icons.js";
|
|
11
|
+
import { agentLabel, relTime, toMs } from "../ui/format.js";
|
|
12
|
+
import { AgentBadge, ErrNote, Skel, StatusDot, type DotCls } from "../ui/shared.js";
|
|
13
|
+
import { resolveMsgListConfig, type CliMsgListConfig, type ResolvedMsgListConfig } from "../core/config.js";
|
|
14
|
+
|
|
15
|
+
/** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
|
|
16
|
+
export interface CliMsgListProps extends CliStoreOptions, ResultHandlers {
|
|
17
|
+
/** 列表显示哪些对话(center 的 job_id)。不给就用本地记住的 + (discover 时)从 center 发现的。 */
|
|
18
|
+
jobIds?: string[];
|
|
19
|
+
/** 没给 jobIds 时,挂载后从 center 发现本产品线的对话。缺省 true。 */
|
|
20
|
+
discover?: boolean;
|
|
21
|
+
/** 受控的选中项(对话键:job_id,或 "job_id#task_id");不给就用共享仓库里的选中(和 cli-chat 联动)。 */
|
|
22
|
+
selectedId?: string | null;
|
|
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;
|
|
28
|
+
/** 点「新对话」:缺省清空选中,cli-chat 会显示空白的新对话。 */
|
|
29
|
+
onNew?: () => void;
|
|
30
|
+
/** 顶部名称与色块(如业务 / 项目名)。 */
|
|
31
|
+
title?: string;
|
|
32
|
+
color?: string;
|
|
33
|
+
/** 顶部的说明文字(对应 Operator 的项目上下文)。 */
|
|
34
|
+
context?: string;
|
|
35
|
+
onEditContext?: () => void;
|
|
36
|
+
onCollapse?: () => void;
|
|
37
|
+
/** 刷新列表的间隔,缺省 10 秒;0 = 不轮询(正在看的对话仍然实时)。 */
|
|
38
|
+
pollMs?: number;
|
|
39
|
+
theme?: "light" | "dark";
|
|
40
|
+
className?: string;
|
|
41
|
+
style?: CSSProperties;
|
|
42
|
+
width?: number | string;
|
|
43
|
+
/** 界面配置:顶部栏、搜索、卡片内容、分组标题与空状态文案,见 core/config.ts。 */
|
|
44
|
+
config?: CliMsgListConfig;
|
|
45
|
+
onEvent?: (event: CliEvent) => void;
|
|
46
|
+
|
|
47
|
+
store?: CliStore;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function CliMsgList(props: CliMsgListProps) {
|
|
51
|
+
const ctx = useCliStoreContext();
|
|
52
|
+
return withOwnProvider(props, <MsgListInner {...props} inProvider={!!ctx} />, !!ctx);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SEARCH_MIN = 6;
|
|
56
|
+
|
|
57
|
+
interface CardView {
|
|
58
|
+
id: string;
|
|
59
|
+
title: string;
|
|
60
|
+
desc: string;
|
|
61
|
+
cls: DotCls;
|
|
62
|
+
label: string;
|
|
63
|
+
running: boolean;
|
|
64
|
+
awaiting: boolean;
|
|
65
|
+
group: "needs" | "active" | "done" | "cancelled";
|
|
66
|
+
activity: string;
|
|
67
|
+
agent: string | null;
|
|
68
|
+
turns: number;
|
|
69
|
+
updatedAt: number;
|
|
70
|
+
loading: boolean;
|
|
71
|
+
error: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function cardView(id: string, entry: JobEntry | undefined): CardView {
|
|
75
|
+
const job = entry?.job;
|
|
76
|
+
const conv = job?.conversation;
|
|
77
|
+
const updatedAt = toMs(conv?.last_activity_at) ?? 0;
|
|
78
|
+
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 };
|
|
79
|
+
const status = job?.status ?? "queued";
|
|
80
|
+
if (TERMINAL_JOB.has(status)) {
|
|
81
|
+
const cancelled = status === "cancelled";
|
|
82
|
+
const failed = status === "failed";
|
|
83
|
+
const label = cancelled ? "已取消" : failed ? "已失败" : "已完成";
|
|
84
|
+
return { ...base, cls: cancelled || failed ? "x" : "g", label, running: false, awaiting: false, group: cancelled ? "cancelled" : "done", activity: updatedAt ? `${label} · ${relTime(updatedAt)}` : label };
|
|
85
|
+
}
|
|
86
|
+
// 开了几个对话:任何一个在跑就算在跑,任何一个停在提问上就要你处理(关掉的对话不算)。
|
|
87
|
+
const open = (conv?.conversations ?? []).filter((c) => !c.closed);
|
|
88
|
+
const many = (conv?.conversations?.length ?? 0) > 1;
|
|
89
|
+
const running = many ? open.some((c) => c.running_turn) : !!conv?.running_turn;
|
|
90
|
+
// 上一轮以提问结束、后面没有新消息:等你回答。
|
|
91
|
+
const awaiting = many ? open.some((c) => !c.running_turn && !c.queued_turns && !!c.last_turn?.asked) : !running && !conv?.queued_turns && !!conv?.last_turn?.asked;
|
|
92
|
+
if (awaiting) return { ...base, cls: "a", label: "需要你处理", running, awaiting, group: "needs", activity: updatedAt ? `等你处理 · ${relTime(updatedAt)}` : "等你处理" };
|
|
93
|
+
if (running) return { ...base, cls: "a", label: "进行中", running, awaiting, group: "active", activity: "运行中 · 工作中" };
|
|
94
|
+
const waiting = conv?.queued_turns ? `${conv.queued_turns} 条排队中` : status === "queued" ? "等待执行节点" : updatedAt ? relTime(updatedAt) : "未开始";
|
|
95
|
+
return { ...base, cls: "a", label: "进行中", running, awaiting, group: "active", activity: waiting };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 对话任务里的一个对话(对话任务开了几个对话时,卡片下面的一行)。 */
|
|
99
|
+
interface ConvRowView {
|
|
100
|
+
key: string;
|
|
101
|
+
title: string;
|
|
102
|
+
cls: DotCls;
|
|
103
|
+
label: string;
|
|
104
|
+
running: boolean;
|
|
105
|
+
awaiting: boolean;
|
|
106
|
+
activity: string;
|
|
107
|
+
turns: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 对话任务里的对话:只有一个时返回 [](整张卡片就是它)。 */
|
|
111
|
+
export function convRows(jobId: string, entry: JobEntry | undefined): ConvRowView[] {
|
|
112
|
+
const list = entry?.job?.conversation?.conversations ?? [];
|
|
113
|
+
if (list.length <= 1) return [];
|
|
114
|
+
const jobEnded = TERMINAL_JOB.has(entry?.job?.status ?? "");
|
|
115
|
+
return list.map((c: ConversationInfo) => {
|
|
116
|
+
const base = { key: convKey(jobId, c.task_id), title: c.title, turns: c.turns };
|
|
117
|
+
if (c.closed || jobEnded) return { ...base, cls: "g" as DotCls, label: "已结束", running: false, awaiting: false, activity: "已结束" };
|
|
118
|
+
const running = c.running_turn;
|
|
119
|
+
const awaiting = !running && !c.queued_turns && !!c.last_turn?.asked;
|
|
120
|
+
if (awaiting) return { ...base, cls: "a" as DotCls, label: "需要你处理", running, awaiting, activity: "等你处理" };
|
|
121
|
+
if (running) return { ...base, cls: "a" as DotCls, label: "进行中", running, awaiting, activity: "运行中" };
|
|
122
|
+
return { ...base, cls: "a" as DotCls, label: "进行中", running, awaiting, activity: c.queued_turns ? `${c.queued_turns} 条排队中` : "空闲" };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function MsgCard({ v, selected, onSelect, show, onNewInJob }: { v: CardView; selected: boolean; onSelect: () => void; show: ResolvedMsgListConfig["card"]; onNewInJob?: () => void }) {
|
|
127
|
+
if (v.loading) return <CardSkeleton i={v.id.length} />;
|
|
128
|
+
const foot = show.activity || (show.turns && v.turns > 0);
|
|
129
|
+
const card = (
|
|
130
|
+
<button className={`task ${selected ? "sel" : ""} ${v.awaiting && show.status ? "awaiting" : ""}`} onClick={onSelect} title={v.error ?? undefined}>
|
|
131
|
+
<div className="task-top">
|
|
132
|
+
{show.status && <StatusDot cls={v.cls} running={v.running} awaiting={v.awaiting} title={v.label} />}
|
|
133
|
+
<span className="ttitle">{v.title}</span>
|
|
134
|
+
{show.status && <span className={`slabel ${v.awaiting ? "await" : ""}`}>{v.label}</span>}
|
|
135
|
+
{show.agent && v.agent && <AgentBadge label={agentLabel(v.agent)} />}
|
|
136
|
+
</div>
|
|
137
|
+
{show.description && v.desc && <div className="tdesc">{v.desc}</div>}
|
|
138
|
+
{v.error && <div className="blocked-chip">{Icon.x()} {v.error}</div>}
|
|
139
|
+
{foot && <div className="task-foot">
|
|
140
|
+
{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>}
|
|
141
|
+
<span className="spacer" />
|
|
142
|
+
{show.turns && v.turns > 0 && <span className="activity">{v.turns} 条消息</span>}
|
|
143
|
+
</div>}
|
|
144
|
+
</button>
|
|
145
|
+
);
|
|
146
|
+
if (!onNewInJob) return card;
|
|
147
|
+
// 「+」不能放进卡片按钮里(按钮不能嵌套):叠在卡片右下角。
|
|
148
|
+
return (
|
|
149
|
+
<div className="cli-job-card">
|
|
150
|
+
{card}
|
|
151
|
+
<button className="icon-btn cli-new-in-job" onClick={onNewInJob} title="在这个任务里新开一个对话(共用同一个工作区)" aria-label="在这个任务里新开一个对话">{Icon.plus()}</button>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function ConvRow({ v, selected, onSelect, show }: { v: ConvRowView; selected: boolean; onSelect: () => void; show: ResolvedMsgListConfig["card"] }) {
|
|
157
|
+
return (
|
|
158
|
+
<button className={`cli-conv-row ${selected ? "sel" : ""} ${v.awaiting && show.status ? "awaiting" : ""}`} onClick={onSelect}>
|
|
159
|
+
{show.status && <StatusDot cls={v.cls} running={v.running} awaiting={v.awaiting} title={v.label} />}
|
|
160
|
+
<span className="cli-conv-title">{v.title}</span>
|
|
161
|
+
{show.activity && <span className="cli-conv-act">{v.activity}</span>}
|
|
162
|
+
{show.turns && v.turns > 0 && <span className="cli-conv-act">{v.turns} 条</span>}
|
|
163
|
+
</button>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function CardSkeleton({ i = 0 }: { i?: number }) {
|
|
168
|
+
const widths = [58, 42, 66, 38];
|
|
169
|
+
return (
|
|
170
|
+
<div className="task" style={{ cursor: "default" }} aria-hidden>
|
|
171
|
+
<div className="task-top"><Skel w={9} h={9} r="50%" /><Skel w={`${widths[i % widths.length]}%`} h={12} /><span style={{ flex: 1 }} /><Skel w={34} h={10} /></div>
|
|
172
|
+
<div className="task-foot"><Skel w={120} h={9} /></div>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
type GroupProps = {
|
|
178
|
+
label: string | false; cards: CardView[]; selected: string | null; onSelect: (id: string) => void; show: ResolvedMsgListConfig["card"];
|
|
179
|
+
rows: Record<string, ConvRowView[]>; firstTask: Record<string, string | undefined>; onNewInJob?: (jobId: string) => void;
|
|
180
|
+
accent?: boolean; collapsible?: boolean; collapsed?: boolean; onToggle?: () => void;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
function Group({ label, cards, selected, onSelect, show, rows, firstTask, onNewInJob, accent, collapsible, collapsed, onToggle }: GroupProps) {
|
|
184
|
+
if (!cards.length || label === false) return null;
|
|
185
|
+
const sel = selected ? parseConvKey(selected) : null;
|
|
186
|
+
const list = cards.map((v) => {
|
|
187
|
+
const convs = rows[v.id] ?? [];
|
|
188
|
+
// 选中的是这个对话任务:只有 job_id 指它的第一个对话。
|
|
189
|
+
const selectedKey = sel?.jobId === v.id ? convKey(v.id, sel.taskId ?? firstTask[v.id]) : null;
|
|
190
|
+
const newing = sel?.jobId === v.id && sel.taskId === NEW_TASK;
|
|
191
|
+
const card = (
|
|
192
|
+
<MsgCard
|
|
193
|
+
key={v.id} v={v} show={show}
|
|
194
|
+
// 只有一个对话:整张卡片就是它;有几个对话:卡片是标题,选中落在下面的行上。
|
|
195
|
+
selected={!convs.length && !newing && sel?.jobId === v.id}
|
|
196
|
+
onSelect={() => onSelect(convs.length ? convs.find((c) => c.key === selectedKey)?.key ?? convs[0].key : v.id)}
|
|
197
|
+
onNewInJob={onNewInJob && v.group !== "done" && v.group !== "cancelled" ? () => onNewInJob(v.id) : undefined}
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
if (!convs.length && !newing) return card;
|
|
201
|
+
return (
|
|
202
|
+
<div key={v.id} className="cli-job-group">
|
|
203
|
+
{card}
|
|
204
|
+
<div className="cli-conv-rows">
|
|
205
|
+
{convs.map((c) => <ConvRow key={c.key} v={c} selected={c.key === selectedKey} onSelect={() => onSelect(c.key)} show={show} />)}
|
|
206
|
+
{newing && <div className="cli-conv-row sel"><span className="cli-conv-title">新对话 · 发第一条消息后开始</span></div>}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
if (collapsible) {
|
|
212
|
+
return (
|
|
213
|
+
<>
|
|
214
|
+
<button className={`task-group-h tgh-btn ${collapsed ? "is-collapsed" : ""}`} onClick={onToggle} title={`${collapsed ? "展开" : "折叠"}「${label}」`}>
|
|
215
|
+
{Icon.chevDown({ className: "tgh-chev" })}
|
|
216
|
+
{label} <span className="gcount">{cards.length}</span><span className="gline" />
|
|
217
|
+
</button>
|
|
218
|
+
{!collapsed && list}
|
|
219
|
+
</>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return (
|
|
223
|
+
<>
|
|
224
|
+
<div className={`task-group-h ${accent ? "needs-you" : ""}`}>{label} <span className="gcount">{cards.length}</span><span className="gline" /></div>
|
|
225
|
+
{list}
|
|
226
|
+
</>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
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 }) {
|
|
231
|
+
useComponentEvents(onEvent, { onTurnEnd, onConversationEnd, onCallback }, inProvider);
|
|
232
|
+
const configKey = JSON.stringify(config ?? {});
|
|
233
|
+
const cfg = useMemo(() => resolveMsgListConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
234
|
+
const h = cfg.header;
|
|
235
|
+
const store = useCliStore();
|
|
236
|
+
const ids = useCliState((s) => s.jobIds);
|
|
237
|
+
const jobs = useCliState((s) => s.jobs);
|
|
238
|
+
const shared = useCliState((s) => s.selected);
|
|
239
|
+
const listLoading = useCliState((s) => s.listLoading);
|
|
240
|
+
const listError = useCliState((s) => s.listError);
|
|
241
|
+
const selected = selectedId !== undefined ? selectedId : shared;
|
|
242
|
+
const [query, setQuery] = useState("");
|
|
243
|
+
const [doneCollapsed, setDoneCollapsed] = useState(false);
|
|
244
|
+
const [cancelledCollapsed, setCancelledCollapsed] = useState(true);
|
|
245
|
+
const [discovered, setDiscovered] = useState(false);
|
|
246
|
+
|
|
247
|
+
const idsKey = jobIds?.join("\n");
|
|
248
|
+
useEffect(() => { if (jobIds) store.setJobIds(jobIds); }, [store, idsKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
249
|
+
// 宿主选中了列表里还没有的对话(例如旁边独立的 cli-chat 刚新建出来的):加进列表,不用两个组件共享任何东西。
|
|
250
|
+
// 放在 setJobIds 之后,宿主的 jobIds 更新后也会再补一次。
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
const jobId = selected ? parseConvKey(selected).jobId : null;
|
|
253
|
+
if (jobId && !ids.includes(jobId)) store.addJob(jobId);
|
|
254
|
+
}, [selected, ids, store]);
|
|
255
|
+
|
|
256
|
+
// 首次:刷新已知的;没给 jobIds 时再去 center 发现。
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
void store.refreshAll({ all: true }).then(() => {
|
|
259
|
+
if (!jobIds && discover) return store.discover().finally(() => setDiscovered(true));
|
|
260
|
+
setDiscovered(true);
|
|
261
|
+
});
|
|
262
|
+
}, [store]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
263
|
+
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (!pollMs) return;
|
|
266
|
+
const timer = setInterval(() => { if (document.visibilityState !== "hidden") void store.refreshAll(); }, pollMs);
|
|
267
|
+
return () => clearInterval(timer);
|
|
268
|
+
}, [store, pollMs]);
|
|
269
|
+
|
|
270
|
+
// 新加进来的对话(别的组件新建的 / 接续出来的)还没数据:补拉一次。
|
|
271
|
+
useEffect(() => { for (const id of ids) if (!jobs[id]) void store.refreshJob(id); }, [ids, jobs, store]);
|
|
272
|
+
|
|
273
|
+
const cards = useMemo(() => ids.map((id) => cardView(id, jobs[id])), [ids, jobs]);
|
|
274
|
+
const q = query.trim().toLowerCase();
|
|
275
|
+
const shown = q ? cards.filter((c) => c.title.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)
|
|
276
|
+
|| (jobs[c.id]?.job?.conversation?.conversations ?? []).some((conv) => conv.title.toLowerCase().includes(q))) : cards;
|
|
277
|
+
const byRecent = (a: CardView, b: CardView) => b.updatedAt - a.updatedAt;
|
|
278
|
+
const groups = {
|
|
279
|
+
needs: shown.filter((c) => c.group === "needs").sort(byRecent),
|
|
280
|
+
active: shown.filter((c) => c.group === "active").sort(byRecent),
|
|
281
|
+
done: shown.filter((c) => c.group === "done").sort(byRecent),
|
|
282
|
+
cancelled: shown.filter((c) => c.group === "cancelled").sort(byRecent),
|
|
283
|
+
};
|
|
284
|
+
const select = (id: string) => { if (selectedId === undefined) store.select(id); onSelect?.(id, parseConvKey(id)); };
|
|
285
|
+
const newInJob = cfg.card.newInJob ? (jobId: string) => select(convKey(jobId, NEW_TASK)) : undefined;
|
|
286
|
+
const rows = useMemo(() => Object.fromEntries(ids.map((id) => [id, convRows(id, jobs[id])])), [ids, jobs]);
|
|
287
|
+
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]);
|
|
288
|
+
const groupProps = { selected, onSelect: select, show: cfg.card, rows, firstTask, onNewInJob: newInJob };
|
|
289
|
+
const startNew = () => { if (onNew) onNew(); else store.select(null); };
|
|
290
|
+
const firstLoad = !discovered && !cards.length;
|
|
291
|
+
const showRow = h.show && (h.title || h.newButton || h.refresh || !!onCollapse);
|
|
292
|
+
const showContext = h.show && h.context && (context !== undefined || !!onEditContext);
|
|
293
|
+
const showBanner = showRow || showContext;
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<div className={`cli-ui cli-msglist ${className ?? ""}`} data-theme={theme} style={{ ...style, ...(width !== undefined ? { width } : null) }}>
|
|
297
|
+
<div className="col col-tasks">
|
|
298
|
+
{showBanner && <div className="proj-banner">
|
|
299
|
+
{showRow && <div className="pb-row">
|
|
300
|
+
{h.title ? (
|
|
301
|
+
<span className="pb-home" style={{ cursor: "default" }}>
|
|
302
|
+
<span className="pb-pic" style={{ background: color }}>{title[0]}</span>
|
|
303
|
+
<span className="pb-name">{title}</span>
|
|
304
|
+
</span>
|
|
305
|
+
) : <span style={{ flex: 1 }} />}
|
|
306
|
+
{h.newButton && <button className="btn btn-line btn-sm" onClick={startNew}>{Icon.plus()} {h.newButtonText}</button>}
|
|
307
|
+
{h.refresh && <button className="icon-btn" onClick={() => void store.refreshAll({ all: true })} title="刷新列表" aria-label="刷新列表" disabled={listLoading}>{Icon.restore()}</button>}
|
|
308
|
+
{onCollapse && <button className="icon-btn" onClick={onCollapse} title="隐藏对话列表">{Icon.chevRight({ style: { transform: "rotate(180deg)" } })}</button>}
|
|
309
|
+
</div>}
|
|
310
|
+
{showContext && (
|
|
311
|
+
<button className="pb-ctx" onClick={onEditContext} style={onEditContext ? undefined : { cursor: "default" }}>
|
|
312
|
+
<div className={`ctx-txt ${context ? "" : "empty-ctx"}`}>{context || "添加上下文 —— 简介、背景与规范,会附加到每个对话前。"}</div>
|
|
313
|
+
{onEditContext && <div className="ctx-edit">{Icon.edit()} 上下文</div>}
|
|
314
|
+
</button>
|
|
315
|
+
)}
|
|
316
|
+
</div>}
|
|
317
|
+
{(cfg.search === true || (cfg.search === "auto" && cards.length >= SEARCH_MIN)) && (
|
|
318
|
+
<div className="search-bar">
|
|
319
|
+
<span className="search-ic">{Icon.search()}</span>
|
|
320
|
+
<input className="search-input" value={query} placeholder={cfg.searchPlaceholder} spellCheck={false} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") setQuery(""); }} />
|
|
321
|
+
{query && <button className="search-clear" title="清除搜索" onClick={() => setQuery("")}>{Icon.x()}</button>}
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
<div className="scroll">
|
|
325
|
+
<div className="task-scroll">
|
|
326
|
+
{listError && <ErrNote onRetry={() => void store.refreshAll({ all: true })}>{listError}</ErrNote>}
|
|
327
|
+
{firstLoad ? [0, 1, 2].map((i) => <CardSkeleton key={i} i={i} />) : (
|
|
328
|
+
<>
|
|
329
|
+
{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>}
|
|
330
|
+
{q && shown.length === 0 && <div className="search-empty">没有匹配“{query.trim()}”的对话。</div>}
|
|
331
|
+
<Group label={cfg.groups.needs} cards={groups.needs} {...groupProps} accent />
|
|
332
|
+
<Group label={cfg.groups.active} cards={groups.active} {...groupProps} />
|
|
333
|
+
<Group label={cfg.groups.done} cards={groups.done} {...groupProps} collapsible collapsed={doneCollapsed && !q} onToggle={() => setDoneCollapsed((c) => !c)} />
|
|
334
|
+
<Group label={cfg.groups.cancelled} cards={groups.cancelled} {...groupProps} collapsible collapsed={cancelledCollapsed && !q} onToggle={() => setCancelledCollapsed((c) => !c)} />
|
|
335
|
+
</>
|
|
336
|
+
)}
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
);
|
|
342
|
+
}
|