@spzxg/cli-ui 0.0.0-stage → 0.1.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.
@@ -0,0 +1,248 @@
1
+ // cli-msglist:消息对话列表。界面取自 Operator 的任务栏(TasksColumn 的列表视图,不做看板),
2
+ // 一张卡片是 center 里的一个对话任务(mode=conversation)。
3
+ import { useEffect, useMemo, useState, type CSSProperties } from "react";
4
+ import type { CliEvent, JobEntry } from "../core/store.js";
5
+ import type { ConversationCallback } from "../core/types.js";
6
+ import type { CliStoreOptions, CliStore } from "../core/store.js";
7
+ import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider.js";
8
+ import { TERMINAL_JOB } from "../core/conversation.js";
9
+ import { Icon } from "../ui/icons.js";
10
+ import { agentLabel, relTime, toMs } from "../ui/format.js";
11
+ import { AgentBadge, ErrNote, Skel, StatusDot, type DotCls } from "../ui/shared.js";
12
+ import { resolveMsgListConfig, type CliMsgListConfig, type ResolvedMsgListConfig } from "../core/config.js";
13
+
14
+ export interface CliMsgListProps extends CliStoreOptions {
15
+ /** 列表显示哪些对话(center 的 job_id)。不给就用本地记住的 + (discover 时)从 center 发现的。 */
16
+ jobIds?: string[];
17
+ /** 没给 jobIds 时,挂载后从 center 发现本产品线的对话。缺省 true。 */
18
+ discover?: boolean;
19
+ /** 受控的选中项;不给就用共享仓库里的选中(和 cli-chat 联动)。 */
20
+ selectedId?: string | null;
21
+ onSelect?: (jobId: string) => void;
22
+ /** 点「新对话」:缺省清空选中,cli-chat 会显示空白的新对话。 */
23
+ onNew?: () => void;
24
+ /** 顶部名称与色块(如业务 / 项目名)。 */
25
+ title?: string;
26
+ color?: string;
27
+ /** 顶部的说明文字(对应 Operator 的项目上下文)。 */
28
+ context?: string;
29
+ onEditContext?: () => void;
30
+ onCollapse?: () => void;
31
+ /** 刷新列表的间隔,缺省 10 秒;0 = 不轮询(正在看的对话仍然实时)。 */
32
+ pollMs?: number;
33
+ theme?: "light" | "dark";
34
+ className?: string;
35
+ style?: CSSProperties;
36
+ width?: number | string;
37
+ /** 界面配置:顶部栏、搜索、卡片内容、分组标题与空状态文案,见 core/config.ts。 */
38
+ config?: CliMsgListConfig;
39
+ onEvent?: (event: CliEvent) => void;
40
+ /** 前端回调:载荷与 center 回调 conversation_turn / conversation_closed 的请求体同名同义。 */
41
+ onCallback?: (payload: ConversationCallback) => void;
42
+ store?: CliStore;
43
+ }
44
+
45
+ export function CliMsgList(props: CliMsgListProps) {
46
+ const ctx = useCliStoreContext();
47
+ return withOwnProvider(props, <MsgListInner {...props} inProvider={!!ctx} />, !!ctx);
48
+ }
49
+
50
+ const SEARCH_MIN = 6;
51
+
52
+ interface CardView {
53
+ id: string;
54
+ title: string;
55
+ desc: string;
56
+ cls: DotCls;
57
+ label: string;
58
+ running: boolean;
59
+ awaiting: boolean;
60
+ group: "needs" | "active" | "done" | "cancelled";
61
+ activity: string;
62
+ agent: string | null;
63
+ turns: number;
64
+ updatedAt: number;
65
+ loading: boolean;
66
+ error: string | null;
67
+ }
68
+
69
+ export function cardView(id: string, entry: JobEntry | undefined): CardView {
70
+ const job = entry?.job;
71
+ const conv = job?.conversation;
72
+ const updatedAt = toMs(conv?.last_activity_at) ?? 0;
73
+ const base = { id, title: job?.title ?? id, desc: entry?.firstInput ?? "", agent: job?.tasks?.[0]?.agent ?? conv?.last_turn?.agent ?? null, turns: conv?.turns ?? 0, updatedAt, loading: !job && !entry?.error, error: entry?.error ?? null };
74
+ const status = job?.status ?? "queued";
75
+ if (TERMINAL_JOB.has(status)) {
76
+ const cancelled = status === "cancelled";
77
+ const failed = status === "failed";
78
+ const label = cancelled ? "已取消" : failed ? "已失败" : "已完成";
79
+ return { ...base, cls: cancelled || failed ? "x" : "g", label, running: false, awaiting: false, group: cancelled ? "cancelled" : "done", activity: updatedAt ? `${label} · ${relTime(updatedAt)}` : label };
80
+ }
81
+ const running = !!conv?.running_turn;
82
+ // 上一轮以提问结束、后面没有新消息:等你回答。
83
+ const awaiting = !running && !conv?.queued_turns && !!conv?.last_turn?.asked;
84
+ if (awaiting) return { ...base, cls: "a", label: "需要你处理", running, awaiting, group: "needs", activity: updatedAt ? `等你处理 · ${relTime(updatedAt)}` : "等你处理" };
85
+ if (running) return { ...base, cls: "a", label: "进行中", running, awaiting, group: "active", activity: "运行中 · 工作中" };
86
+ const waiting = conv?.queued_turns ? `${conv.queued_turns} 条排队中` : status === "queued" ? "等待执行节点" : updatedAt ? relTime(updatedAt) : "未开始";
87
+ return { ...base, cls: "a", label: "进行中", running, awaiting, group: "active", activity: waiting };
88
+ }
89
+
90
+ function MsgCard({ v, selected, onSelect, show }: { v: CardView; selected: boolean; onSelect: () => void; show: ResolvedMsgListConfig["card"] }) {
91
+ if (v.loading) return <CardSkeleton i={v.id.length} />;
92
+ const foot = show.activity || (show.turns && v.turns > 0);
93
+ return (
94
+ <button className={`task ${selected ? "sel" : ""} ${v.awaiting && show.status ? "awaiting" : ""}`} onClick={onSelect} title={v.error ?? undefined}>
95
+ <div className="task-top">
96
+ {show.status && <StatusDot cls={v.cls} running={v.running} awaiting={v.awaiting} title={v.label} />}
97
+ <span className="ttitle">{v.title}</span>
98
+ {show.status && <span className={`slabel ${v.awaiting ? "await" : ""}`}>{v.label}</span>}
99
+ {show.agent && v.agent && <AgentBadge label={agentLabel(v.agent)} />}
100
+ </div>
101
+ {show.description && v.desc && <div className="tdesc">{v.desc}</div>}
102
+ {v.error && <div className="blocked-chip">{Icon.x()} {v.error}</div>}
103
+ {foot && <div className="task-foot">
104
+ {show.activity && <span className="activity">{v.awaiting ? <span style={{ color: "var(--blue)" }}>●</span> : v.running ? <span style={{ color: "var(--amber)" }}>●</span> : null}{v.activity}</span>}
105
+ <span className="spacer" />
106
+ {show.turns && v.turns > 0 && <span className="activity">{v.turns} 轮对话</span>}
107
+ </div>}
108
+ </button>
109
+ );
110
+ }
111
+
112
+ function CardSkeleton({ i = 0 }: { i?: number }) {
113
+ const widths = [58, 42, 66, 38];
114
+ return (
115
+ <div className="task" style={{ cursor: "default" }} aria-hidden>
116
+ <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>
117
+ <div className="task-foot"><Skel w={120} h={9} /></div>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ function Group({ label, cards, selected, onSelect, show, accent, collapsible, collapsed, onToggle }: { label: string | false; cards: CardView[]; selected: string | null; onSelect: (id: string) => void; show: ResolvedMsgListConfig["card"]; accent?: boolean; collapsible?: boolean; collapsed?: boolean; onToggle?: () => void }) {
123
+ if (!cards.length || label === false) return null;
124
+ const list = cards.map((v) => <MsgCard key={v.id} v={v} selected={v.id === selected} onSelect={() => onSelect(v.id)} show={show} />);
125
+ if (collapsible) {
126
+ return (
127
+ <>
128
+ <button className={`task-group-h tgh-btn ${collapsed ? "is-collapsed" : ""}`} onClick={onToggle} title={`${collapsed ? "展开" : "折叠"}「${label}」`}>
129
+ {Icon.chevDown({ className: "tgh-chev" })}
130
+ {label} <span className="gcount">{cards.length}</span><span className="gline" />
131
+ </button>
132
+ {!collapsed && list}
133
+ </>
134
+ );
135
+ }
136
+ return (
137
+ <>
138
+ <div className={`task-group-h ${accent ? "needs-you" : ""}`}>{label} <span className="gcount">{cards.length}</span><span className="gline" /></div>
139
+ {list}
140
+ </>
141
+ );
142
+ }
143
+
144
+ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, title = "对话", color = "#b8613f", context, onEditContext, onCollapse, pollMs = 10_000, config, theme = "light", className, style, width, onEvent, onCallback, inProvider }: CliMsgListProps & { inProvider: boolean }) {
145
+ useComponentEvents(onEvent, onCallback, inProvider);
146
+ const configKey = JSON.stringify(config ?? {});
147
+ const cfg = useMemo(() => resolveMsgListConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
148
+ const h = cfg.header;
149
+ const store = useCliStore();
150
+ const ids = useCliState((s) => s.jobIds);
151
+ const jobs = useCliState((s) => s.jobs);
152
+ const shared = useCliState((s) => s.selected);
153
+ const listLoading = useCliState((s) => s.listLoading);
154
+ const listError = useCliState((s) => s.listError);
155
+ const selected = selectedId !== undefined ? selectedId : shared;
156
+ const [query, setQuery] = useState("");
157
+ const [doneCollapsed, setDoneCollapsed] = useState(false);
158
+ const [cancelledCollapsed, setCancelledCollapsed] = useState(true);
159
+ const [discovered, setDiscovered] = useState(false);
160
+
161
+ const idsKey = jobIds?.join("\n");
162
+ useEffect(() => { if (jobIds) store.setJobIds(jobIds); }, [store, idsKey]); // eslint-disable-line react-hooks/exhaustive-deps
163
+ // 宿主选中了列表里还没有的对话(例如旁边独立的 cli-chat 刚新建出来的):加进列表,不用两个组件共享任何东西。
164
+ // 放在 setJobIds 之后,宿主的 jobIds 更新后也会再补一次。
165
+ useEffect(() => { if (selected && !ids.includes(selected)) store.addJob(selected); }, [selected, ids, store]);
166
+
167
+ // 首次:刷新已知的;没给 jobIds 时再去 center 发现。
168
+ useEffect(() => {
169
+ void store.refreshAll({ all: true }).then(() => {
170
+ if (!jobIds && discover) return store.discover().finally(() => setDiscovered(true));
171
+ setDiscovered(true);
172
+ });
173
+ }, [store]); // eslint-disable-line react-hooks/exhaustive-deps
174
+
175
+ useEffect(() => {
176
+ if (!pollMs) return;
177
+ const timer = setInterval(() => { if (document.visibilityState !== "hidden") void store.refreshAll(); }, pollMs);
178
+ return () => clearInterval(timer);
179
+ }, [store, pollMs]);
180
+
181
+ // 新加进来的对话(别的组件新建的 / 接续出来的)还没数据:补拉一次。
182
+ useEffect(() => { for (const id of ids) if (!jobs[id]) void store.refreshJob(id); }, [ids, jobs, store]);
183
+
184
+ const cards = useMemo(() => ids.map((id) => cardView(id, jobs[id])), [ids, jobs]);
185
+ const q = query.trim().toLowerCase();
186
+ const shown = q ? cards.filter((c) => c.title.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)) : cards;
187
+ const byRecent = (a: CardView, b: CardView) => b.updatedAt - a.updatedAt;
188
+ const groups = {
189
+ needs: shown.filter((c) => c.group === "needs").sort(byRecent),
190
+ active: shown.filter((c) => c.group === "active").sort(byRecent),
191
+ done: shown.filter((c) => c.group === "done").sort(byRecent),
192
+ cancelled: shown.filter((c) => c.group === "cancelled").sort(byRecent),
193
+ };
194
+ const select = (id: string) => { if (selectedId === undefined) store.select(id); onSelect?.(id); };
195
+ const startNew = () => { if (onNew) onNew(); else store.select(null); };
196
+ const firstLoad = !discovered && !cards.length;
197
+ const showRow = h.show && (h.title || h.newButton || h.refresh || !!onCollapse);
198
+ const showContext = h.show && h.context && (context !== undefined || !!onEditContext);
199
+ const showBanner = showRow || showContext;
200
+
201
+ return (
202
+ <div className={`cli-ui cli-msglist ${className ?? ""}`} data-theme={theme} style={{ ...style, ...(width !== undefined ? { width } : null) }}>
203
+ <div className="col col-tasks">
204
+ {showBanner && <div className="proj-banner">
205
+ {showRow && <div className="pb-row">
206
+ {h.title ? (
207
+ <span className="pb-home" style={{ cursor: "default" }}>
208
+ <span className="pb-pic" style={{ background: color }}>{title[0]}</span>
209
+ <span className="pb-name">{title}</span>
210
+ </span>
211
+ ) : <span style={{ flex: 1 }} />}
212
+ {h.newButton && <button className="btn btn-line btn-sm" onClick={startNew}>{Icon.plus()} {h.newButtonText}</button>}
213
+ {h.refresh && <button className="icon-btn" onClick={() => void store.refreshAll({ all: true })} title="刷新列表" aria-label="刷新列表" disabled={listLoading}>{Icon.restore()}</button>}
214
+ {onCollapse && <button className="icon-btn" onClick={onCollapse} title="隐藏对话列表">{Icon.chevRight({ style: { transform: "rotate(180deg)" } })}</button>}
215
+ </div>}
216
+ {showContext && (
217
+ <button className="pb-ctx" onClick={onEditContext} style={onEditContext ? undefined : { cursor: "default" }}>
218
+ <div className={`ctx-txt ${context ? "" : "empty-ctx"}`}>{context || "添加上下文 —— 简介、背景与规范,会附加到每个对话前。"}</div>
219
+ {onEditContext && <div className="ctx-edit">{Icon.edit()} 上下文</div>}
220
+ </button>
221
+ )}
222
+ </div>}
223
+ {(cfg.search === true || (cfg.search === "auto" && cards.length >= SEARCH_MIN)) && (
224
+ <div className="search-bar">
225
+ <span className="search-ic">{Icon.search()}</span>
226
+ <input className="search-input" value={query} placeholder={cfg.searchPlaceholder} spellCheck={false} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") setQuery(""); }} />
227
+ {query && <button className="search-clear" title="清除搜索" onClick={() => setQuery("")}>{Icon.x()}</button>}
228
+ </div>
229
+ )}
230
+ <div className="scroll">
231
+ <div className="task-scroll">
232
+ {listError && <ErrNote onRetry={() => void store.refreshAll({ all: true })}>{listError}</ErrNote>}
233
+ {firstLoad ? [0, 1, 2].map((i) => <CardSkeleton key={i} i={i} />) : (
234
+ <>
235
+ {cards.length === 0 && <div className="empty" style={{ padding: "30px 16px" }}><div className="e-t">{cfg.emptyTitle}</div>{cfg.emptyDescription && <div className="e-s">{cfg.emptyDescription}</div>}</div>}
236
+ {q && shown.length === 0 && <div className="search-empty">没有匹配“{query.trim()}”的对话。</div>}
237
+ <Group label={cfg.groups.needs} cards={groups.needs} selected={selected} onSelect={select} show={cfg.card} accent />
238
+ <Group label={cfg.groups.active} cards={groups.active} selected={selected} onSelect={select} show={cfg.card} />
239
+ <Group label={cfg.groups.done} cards={groups.done} selected={selected} onSelect={select} show={cfg.card} collapsible collapsed={doneCollapsed && !q} onToggle={() => setDoneCollapsed((c) => !c)} />
240
+ <Group label={cfg.groups.cancelled} cards={groups.cancelled} selected={selected} onSelect={select} show={cfg.card} collapsible collapsed={cancelledCollapsed && !q} onToggle={() => setCancelledCollapsed((c) => !c)} />
241
+ </>
242
+ )}
243
+ </div>
244
+ </div>
245
+ </div>
246
+ </div>
247
+ );
248
+ }