@chenacc/agentbridge 0.0.0-stage → 0.1.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.
@@ -0,0 +1,368 @@
1
+ import { renderMessageMarkdown } from "./markdown.js";
2
+
3
+ // Pure transcript logic, kept out of app.js so it can be tested directly with
4
+ // Node and the real Markdown renderer instead of only in a browser. Everything
5
+ // here is about one promise: the phone shows the whole conversation, no matter
6
+ // how many tokens the agent streamed.
7
+
8
+ // Real conversation keeps its place: token deltas are collapsed to one entry
9
+ // per turn, so completed messages and status changes are never squeezed out.
10
+ export const MAX_EVENT_HISTORY = 4000;
11
+ export const HISTORY_TRIM_CHUNK = 750;
12
+
13
+ function escapeHtml(value) {
14
+ return String(value ?? "").replace(/[&<>'"]/g, (character) => ({
15
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;",
16
+ })[character]);
17
+ }
18
+
19
+ function deltaKeyOf(event) {
20
+ return `${event.sessionId ?? ""}\u0000${event.turnId ?? ""}`;
21
+ }
22
+
23
+ export function indexDeltas(events, index = new Map()) {
24
+ index.clear();
25
+ for (const event of events) {
26
+ if (event.kind === "message.delta") index.set(deltaKeyOf(event), event);
27
+ }
28
+ return index;
29
+ }
30
+
31
+ // Token deltas are folded into a single cumulative entry per turn, matching the
32
+ // bridge. Without this a long turn's tokens fill the whole buffer and push the
33
+ // actual conversation out of it, which is why replies used to arrive missing or
34
+ // half-rendered.
35
+ export function absorbEvent(events, deltaIndex, event) {
36
+ if (event.kind !== "message.delta") {
37
+ events.push(event);
38
+ return event;
39
+ }
40
+ const text = String(event.payload?.text ?? "");
41
+ const existing = deltaIndex.get(deltaKeyOf(event));
42
+ if (existing) {
43
+ const current = String(existing.payload?.text ?? "");
44
+ const offset = Number.isInteger(event.payload?.offset) ? event.payload.offset : current.length;
45
+ // `offset` is the character position this chunk starts at, so a merged or
46
+ // replayed chunk rewrites the same text instead of duplicating it.
47
+ const merged = offset >= current.length ? current + text : current.slice(0, offset) + text;
48
+ existing.payload = { ...existing.payload, text: merged, offset: 0 };
49
+ existing.time = event.time;
50
+ return existing;
51
+ }
52
+ const entry = { ...event, payload: { ...event.payload, offset: 0 } };
53
+ deltaIndex.set(deltaKeyOf(event), entry);
54
+ events.push(entry);
55
+ return entry;
56
+ }
57
+
58
+ // Trimming normally drops the oldest entries, which is exactly what a freshly
59
+ // fetched history page is. `keepSessionId` drops other conversations' oldest
60
+ // entries first, so switching to a quiet conversation cannot be undone a moment
61
+ // later by a busy one that is still streaming.
62
+ export function trimEvents(events, deltaIndex, { maxEvents = MAX_EVENT_HISTORY, trimChunk = HISTORY_TRIM_CHUNK, keepSessionId = null } = {}) {
63
+ if (events.length <= maxEvents) return false;
64
+ const target = events.length - maxEvents + trimChunk;
65
+ let removed = 0;
66
+ if (keepSessionId) {
67
+ for (let index = 0; index < events.length && removed < target; index += 1) {
68
+ if (events[index].sessionId === keepSessionId) continue;
69
+ events.splice(index, 1);
70
+ index -= 1;
71
+ removed += 1;
72
+ }
73
+ }
74
+ if (removed < target) events.splice(0, target - removed);
75
+ indexDeltas(events, deltaIndex);
76
+ return true;
77
+ }
78
+
79
+ export function foldDeltas(relevant) {
80
+ const completedTurns = new Set();
81
+ const textByTurn = new Map();
82
+ for (const event of relevant) {
83
+ if (event.kind === "message.completed" && event.payload.role === "assistant") completedTurns.add(event.turnId);
84
+ }
85
+ for (const event of relevant) {
86
+ if (event.kind !== "message.delta") continue;
87
+ const text = String(event.payload?.text ?? "");
88
+ const current = textByTurn.get(event.turnId) ?? "";
89
+ const offset = Number.isInteger(event.payload?.offset) ? event.payload.offset : current.length;
90
+ textByTurn.set(event.turnId, offset >= current.length ? current + text : current.slice(0, offset) + text);
91
+ }
92
+ return { textByTurn, completedTurns };
93
+ }
94
+
95
+ export function entryKey(event) {
96
+ return event.kind === "message.delta" ? `delta:${event.sessionId}:${event.turnId}` : `${event.kind}:${event.eventId}`;
97
+ }
98
+
99
+ // Live events and per-session history describe the same seq space, and a switch
100
+ // can overlap a stream that is still arriving. De-duplicating by eventId and
101
+ // re-sorting by seq keeps both sources honest: no entry is rendered twice, and
102
+ // a fetched page slots in ahead of whatever already arrived live.
103
+ export function mergeHistory(events, deltaIndex, incoming, knownIds = null) {
104
+ if (!Array.isArray(incoming) || !incoming.length) return 0;
105
+ // `knownIds` is the caller's live set of every id in the buffer; without it the
106
+ // set is derived from `events`, which is enough for a one-shot merge but not for
107
+ // a buffer that keeps growing from the live stream.
108
+ const known = knownIds instanceof Set ? knownIds : new Set(events.map((event) => event.eventId).filter(Boolean));
109
+ let added = 0;
110
+ for (const event of incoming) {
111
+ if (!event || !Number.isSafeInteger(event.seq)) continue;
112
+ if (event.eventId && known.has(event.eventId)) continue;
113
+ if (event.eventId) known.add(event.eventId);
114
+ events.push(event);
115
+ added += 1;
116
+ }
117
+ if (!added) return 0;
118
+ events.sort((a, b) => a.seq - b.seq);
119
+ indexDeltas(events, deltaIndex);
120
+ return added;
121
+ }
122
+
123
+ const DEFAULT_STATUS_LABELS = {
124
+ "session.created": "会话已创建",
125
+ "session.ready": "Agent 已就绪",
126
+ "turn.started": "本轮已开始",
127
+ "turn.completed": "本轮已完成",
128
+ "turn.cancelled": "本轮已取消",
129
+ "control.granted": "输入控制权已更新",
130
+ "turn.stop_requested": "已请求停止本轮",
131
+ "model.changed": "模型已切换",
132
+ };
133
+
134
+ const STATUS_KINDS = [
135
+ "session.created", "session.ready", "turn.started", "turn.completed",
136
+ "turn.cancelled", "control.granted", "turn.stop_requested", "model.changed",
137
+ ];
138
+
139
+ // A question answered on the desktop, or skipped on the phone, still has to
140
+ // leave a readable trace: the card above it can never be answered again.
141
+ const RESOLVED_LABELS = {
142
+ answered: "已回答",
143
+ cancelled: "已跳过",
144
+ desktop: "已在电脑上回答",
145
+ expired: "超时未答,已自动拒绝",
146
+ };
147
+
148
+ const APPROVAL_RESOLVED_LABELS = {
149
+ approve: "已批准(仅本次)",
150
+ deny: "已拒绝",
151
+ };
152
+
153
+ const entryCache = new WeakMap();
154
+
155
+ // Math rendering arrives one tick after the first paint (KaTeX is fetched only
156
+ // when the transcript needs it). Bumping the generation invalidates the memoised
157
+ // HTML so the formulas re-render instead of staying as raw TeX.
158
+ let renderGeneration = 0;
159
+
160
+ export function bumpRenderGeneration() {
161
+ renderGeneration += 1;
162
+ }
163
+
164
+ // `key` lets one event render two ways (a question card before and after it is
165
+ // answered): the memoised HTML is only reused when the key still matches.
166
+ function cachedEntry(cache, event, key) {
167
+ const cached = cache.get(event);
168
+ if (!cached || cached.generation !== renderGeneration) return null;
169
+ if (key !== undefined && cached.key !== key) return null;
170
+ return cached.entry;
171
+ }
172
+
173
+ function rememberEntry(cache, event, entry, key) {
174
+ cache.set(event, { generation: renderGeneration, entry, key });
175
+ return entry;
176
+ }
177
+
178
+ function formatTime(value, formatter) {
179
+ return formatter ? formatter(value) : new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
180
+ }
181
+
182
+ /**
183
+ * Attachments the phone sent with a message.
184
+ *
185
+ * Images render as a thumbnail that opens full size; anything else renders as a
186
+ * named chip. Both point at the bridge's own preview URL, so the picture is the
187
+ * real file the agent received, not a local copy.
188
+ */
189
+ function attachmentsHtml(list) {
190
+ if (!Array.isArray(list) || !list.length) return "";
191
+ const items = list.map((attachment) => {
192
+ const id = escapeHtml(String(attachment?.id ?? ""));
193
+ const name = escapeHtml(String(attachment?.name ?? "附件"));
194
+ const size = Number(attachment?.bytes) > 0 ? `${Math.max(1, Math.round(Number(attachment.bytes) / 1024))} KB` : "";
195
+ const url = `/api/attachments/${encodeURIComponent(String(attachment?.id ?? ""))}`;
196
+ if (attachment?.isImage) {
197
+ return `<a class="attachment-thumb" href="${url}" target="_blank" rel="noreferrer" data-attachment="${id}">
198
+ <img src="${url}" alt="${name}" loading="lazy" decoding="async">
199
+ <span class="attachment-caption">${name}${size ? ` · ${size}` : ""}</span>
200
+ </a>`;
201
+ }
202
+ return `<a class="attachment-file" href="${url}" target="_blank" rel="noreferrer" data-attachment="${id}">📎 ${name}${size ? ` · ${size}` : ""}</a>`;
203
+ }).join("");
204
+ return `<div class="message-attachments">${items}</div>`;
205
+ }
206
+ function entryFor(event, label, text, className, supportsMarkdown, { formatMessageTime, cache, math }) {
207
+ const stable = event.kind !== "message.delta";
208
+ const cached = stable ? cachedEntry(cache, event) : null;
209
+ if (cached) return cached;
210
+ const content = supportsMarkdown
211
+ ? renderMessageMarkdown(text, math ? { math } : undefined)
212
+ : `<p>${escapeHtml(String(text ?? ""))}</p>`;
213
+ const attachments = attachmentsHtml(event.payload?.attachments);
214
+ const entry = {
215
+ key: entryKey(event),
216
+ className,
217
+ html: `<time class="event-time">${formatTime(event.time, formatMessageTime)}</time><div class="event-body"><strong>${escapeHtml(label)}</strong><div class="message-markdown">${content}</div>${attachments}</div>`,
218
+ };
219
+ if (stable) rememberEntry(cache, event, entry);
220
+ return entry;
221
+ }
222
+
223
+ /**
224
+ * Tool calls get the two affordances a person actually needs: the command is
225
+ * copyable, and a long output collapses instead of pushing the conversation off the
226
+ * screen. Both work without JavaScript state — `<details>` is the disclosure.
227
+ */
228
+ function toolEntry(event, label, text, options) {
229
+ const cached = cachedEntry(options.cache, event);
230
+ if (cached) return cached;
231
+ const body = String(text ?? "");
232
+ const long = body.length > 240;
233
+ const command = typeof event.payload?.command === "string" ? event.payload.command : "";
234
+ const copy = command
235
+ ? `<button class="tool-copy" type="button" data-copy-tool="${escapeHtml(body)}">复制命令</button>`
236
+ : "";
237
+ const shown = long
238
+ ? `<details class="tool-output"><summary>展开完整输出(${body.length} 字符)</summary><pre>${escapeHtml(body)}</pre></details>`
239
+ : `<div class="message-markdown"><p>${escapeHtml(body)}</p></div>`;
240
+ const entry = {
241
+ key: entryKey(event),
242
+ className: "tool",
243
+ html: `<time class="event-time">${formatTime(event.time, options.formatMessageTime)}</time><div class="event-body"><strong>${escapeHtml(label)}</strong>${copy}${shown}</div>`,
244
+ };
245
+ return rememberEntry(options.cache, event, entry);
246
+ }
247
+ function approvalEntry(event, options) {
248
+ const cached = cachedEntry(options.cache, event);
249
+ if (cached) return cached;
250
+ const request = JSON.stringify(event.payload.request ?? {}, null, 2).slice(0, 1600);
251
+ const entry = {
252
+ key: entryKey(event),
253
+ className: "",
254
+ html: `<time class="event-time">${formatTime(event.time, options.formatMessageTime)}</time><div class="event-body approval-entry"><strong>需要你的批准</strong><p>${escapeHtml(event.payload.method)}\n${escapeHtml(request)}</p><div class="approval-actions"><button class="approve" data-approval="${event.payload.approvalId}" data-decision="approve" type="button">仅本次批准</button><button class="deny" data-approval="${event.payload.approvalId}" data-decision="deny" type="button">拒绝</button></div></div>`,
255
+ };
256
+ return rememberEntry(options.cache, event, entry);
257
+ }
258
+
259
+ // An agent that stops to ask a question is blocked until a human answers, so a
260
+ // card of dead text would strand the turn. Options render as real inputs and the
261
+ // submit button posts one batch, in the order the questions were asked.
262
+ //
263
+ // Once the question is resolved the card must stop looking answerable: it used to keep
264
+ // its buttons forever, so a reload offered a card whose submit could only 409. The card
265
+ // is folded into a settled form (options as static text, no buttons, outcome named).
266
+ function inputEntry(event, options, settledOutcome) {
267
+ const cacheKey = settledOutcome ? `${settledOutcome}:${entryKey(event)}` : entryKey(event);
268
+ const cached = cachedEntry(options.cache, event, cacheKey);
269
+ if (cached) return cached;
270
+ const settled = Boolean(settledOutcome);
271
+ const requestId = escapeHtml(event.payload.requestId);
272
+ const questions = Array.isArray(event.payload.questions) ? event.payload.questions : [];
273
+ const blocks = questions.map((question, index) => {
274
+ const questionId = escapeHtml(question.id);
275
+ const multi = question.multiSelect === true;
276
+ const options = Array.isArray(question.options) ? question.options : [];
277
+ const choices = options.map((option) => {
278
+ const label = String(option.label ?? "");
279
+ const description = option.description
280
+ ? `<small class="input-option-note">${escapeHtml(option.description)}</small>`
281
+ : "";
282
+ if (settled) {
283
+ return `<span class="input-option settled"><span>${escapeHtml(label)}${description}</span></span>`;
284
+ }
285
+ return `<label class="input-option"><input type="${multi ? "checkbox" : "radio"}" name="q-${requestId}-${index}" value="${escapeHtml(label)}" data-input-option> <span>${escapeHtml(label)}${description}</span></label>`;
286
+ }).join("");
287
+ return `<div class="input-question" data-input-question="${questionId}"${multi ? ' data-input-multi="1"' : ""}>`
288
+ + (question.header ? `<p class="input-header">${escapeHtml(question.header)}</p>` : "")
289
+ + `<p class="input-prompt">${escapeHtml(question.question)}</p>`
290
+ + (choices ? `<div class="input-options">${choices}</div>` : "")
291
+ + (settled
292
+ ? ""
293
+ : `<input class="input-custom" type="${question.secret === true ? "password" : "text"}" data-input-custom placeholder="${options.length ? "或者直接输入回答" : "输入你的回答"}">`)
294
+ + "</div>";
295
+ }).join("");
296
+ const footnote = settled ? `<p class="input-settled">${escapeHtml(RESOLVED_LABELS[settledOutcome] ?? "已处理")}</p>` : "";
297
+ const entry = {
298
+ // The key changes with the settled state: the transcript diff only rewrites an
299
+ // entry when its key changes, so a card that resolves in place would otherwise
300
+ // keep offering its buttons until the whole list re-rendered.
301
+ key: settled ? `settled:${entryKey(event)}` : entryKey(event),
302
+ className: settled ? "settled" : "",
303
+ html: `<time class="event-time">${formatTime(event.time, options.formatMessageTime)}</time><div class="event-body input-entry${settled ? " is-settled" : ""}"><strong>${settled ? "Agent 的提问" : "Agent 在等你回答"}</strong>${blocks}${footnote}`
304
+ + (settled ? "" : `<div class="approval-actions"><button class="approve" data-input-submit="${requestId}" type="button">提交回答</button><button class="deny" data-input-cancel="${requestId}" type="button">跳过</button></div>`)
305
+ + "</div>",
306
+ };
307
+ return rememberEntry(options.cache, event, entry, cacheKey);
308
+ }
309
+
310
+ /** Turn a session's events into the list of transcript entries to display. */
311
+ export function buildEntries(relevant, options = {}) {
312
+ const statusLabel = options.statusLabel ?? ((kind) => DEFAULT_STATUS_LABELS[kind] ?? kind);
313
+ const settings = { cache: entryCache, formatMessageTime: options.formatMessageTime, math: options.math };
314
+ const { textByTurn, completedTurns } = foldDeltas(relevant);
315
+ // A question's outcome arrives as a later event, so it has to be known before the
316
+ // card that asked it is rendered — otherwise the card keeps offering buttons that
317
+ // can only fail once the desktop has already answered.
318
+ const settledInputs = new Map();
319
+ for (const event of relevant) {
320
+ if (event.kind === "input.resolved" && event.payload?.requestId) {
321
+ settledInputs.set(event.payload.requestId, event.payload.outcome ?? "answered");
322
+ }
323
+ }
324
+
325
+ const renderedDeltas = new Set();
326
+ const entries = [];
327
+ for (const event of relevant) {
328
+ if (event.kind === "message.delta") {
329
+ if (completedTurns.has(event.turnId) || renderedDeltas.has(event.turnId)) continue;
330
+ renderedDeltas.add(event.turnId);
331
+ entries.push(entryFor(event, "Agent 正在回复", textByTurn.get(event.turnId) ?? "", "assistant", true, settings));
332
+ } else if (event.kind === "message.completed") {
333
+ // An assistant message that carried only tool calls has no text. Rendering it makes
334
+ // an empty "Agent" bubble between the tool rows is pure noise, so skip it — unless it
335
+ // brought attachments, which are worth a row on their own.
336
+ const messageText = String(event.payload.text ?? "");
337
+ const messageAttachments = Array.isArray(event.payload.attachments) ? event.payload.attachments : [];
338
+ if (!messageText.trim() && messageAttachments.length === 0) continue;
339
+ entries.push(entryFor(event, event.payload.role === "user" ? "你" : "Agent", messageText, event.payload.role, true, settings));
340
+ } else if (event.kind === "tool.started" || event.kind === "tool.completed") {
341
+ const label = event.kind.endsWith("started") ? "工具开始" : "工具完成";
342
+ const detail = event.payload.command ? String(event.payload.command) : `${event.payload.type ?? "tool"}${event.payload.status ? ` · ${event.payload.status}` : ""}`;
343
+ entries.push(toolEntry(event, label, detail, settings));
344
+ } else if (event.kind === "approval.requested") {
345
+ entries.push(approvalEntry(event, settings));
346
+ } else if (event.kind === "input.requested") {
347
+ entries.push(inputEntry(event, settings, settledInputs.get(event.payload?.requestId)));
348
+ } else if (event.kind === "input.resolved") {
349
+ entries.push(entryFor(event, "提问", RESOLVED_LABELS[event.payload?.outcome] ?? "已处理", "tool", false, settings));
350
+ } else if (event.kind === "approval.resolved") {
351
+ entries.push(entryFor(
352
+ event,
353
+ "审批",
354
+ event.payload?.auto
355
+ ? `已自动放行(限时策略)`
356
+ : APPROVAL_RESOLVED_LABELS[event.payload?.decision] ?? "已处理",
357
+ "tool",
358
+ false,
359
+ settings,
360
+ ));
361
+ } else if (event.kind === "turn.failed" || event.kind === "session.failed" || event.kind === "adapter.exited") {
362
+ entries.push(entryFor(event, "执行异常", event.payload.error ?? event.payload.stderr ?? event.kind, "error", false, settings));
363
+ } else if (STATUS_KINDS.includes(event.kind)) {
364
+ entries.push(entryFor(event, "状态", statusLabel(event.kind), "tool", false, settings));
365
+ }
366
+ }
367
+ return entries;
368
+ }
@@ -0,0 +1,241 @@
1
+ function workspaceIdForSession(session, workspaces) {
2
+ if (!session) return null;
3
+ if (session.workspaceId && workspaces.some((workspace) => workspace.id === session.workspaceId)) return session.workspaceId;
4
+ return workspaces.find((workspace) => workspace.alias === session.projectAlias)?.id ?? null;
5
+ }
6
+
7
+ export function sessionsForWorkspace(sessions, workspaces, workspaceId) {
8
+ if (!workspaceId) return [];
9
+ return sessions.filter((session) => workspaceIdForSession(session, workspaces) === workspaceId);
10
+ }
11
+
12
+ export function resolveWorkspaceNavigation({
13
+ workspaces = [],
14
+ sessions = [],
15
+ selectedWorkspaceId = null,
16
+ selectedSessionId = null,
17
+ } = {}) {
18
+ const enabledWorkspaces = workspaces.filter((workspace) => workspace.enabled !== false);
19
+ const selectedSession = sessions.find((session) => session.id === selectedSessionId) ?? null;
20
+ const selectedSessionWorkspaceId = workspaceIdForSession(selectedSession, enabledWorkspaces);
21
+ const requestedWorkspace = enabledWorkspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? null;
22
+ const workspaceId = selectedSessionWorkspaceId ?? requestedWorkspace?.id ?? enabledWorkspaces[0]?.id ?? null;
23
+ const workspaceSessions = sessionsForWorkspace(sessions, enabledWorkspaces, workspaceId);
24
+ const sessionId = selectedSessionWorkspaceId === workspaceId
25
+ ? selectedSession.id
26
+ : workspaceSessions[0]?.id ?? null;
27
+ return { workspaceId, sessionId };
28
+ }
29
+
30
+ export function discoverableAdapters(adapters = []) {
31
+ return adapters.filter((adapter) => (
32
+ adapter?.capabilities?.discover === true
33
+ && adapter?.availability?.available === true
34
+ && adapter?.availability?.supported !== false
35
+ ));
36
+ }
37
+
38
+ // How an Agent's row is labelled and ranked in the drawer. "可以新建" is the
39
+ // strongest claim a row can make, so those sort first; an Agent that can only be
40
+ // attached from the desktop is useful but cannot open a conversation by itself,
41
+ // and an uninstalled Agent must say so instead of offering a dead end.
42
+ const CAPABILITY_RANK = { create: 0, attach: 1, unavailable: 2 };
43
+
44
+ function drawerCapability(adapter) {
45
+ const availability = adapter?.availability ?? {};
46
+ const capabilities = adapter?.capabilities ?? {};
47
+ const usable = availability.available === true && availability.supported !== false;
48
+ if (!usable) return "unavailable";
49
+ return capabilities.create === true ? "create" : capabilities.attach === true ? "attach" : "unavailable";
50
+ }
51
+
52
+ const CAPABILITY_LABEL = { create: "可新建", attach: "仅桌面附着", unavailable: "未安装" };
53
+
54
+ function lastActiveOf(session) {
55
+ const value = Date.parse(session?.lastActiveAt ?? session?.updatedAt ?? "");
56
+ return Number.isFinite(value) ? value : 0;
57
+ }
58
+
59
+ function workspaceIdOf(session, workspaces) {
60
+ if (!session) return null;
61
+ if (session.workspaceId && workspaces.some((workspace) => workspace.id === session.workspaceId)) return session.workspaceId;
62
+ return workspaces.find((workspace) => workspace.alias === session.projectAlias)?.id ?? null;
63
+ }
64
+
65
+ function matchesQuery(query, ...fields) {
66
+ if (!query) return true;
67
+ return fields.some((field) => String(field ?? "").toLowerCase().includes(query));
68
+ }
69
+
70
+ /**
71
+ * Build the Agent → project → conversation tree the drawer renders.
72
+ *
73
+ * Structure follows how people actually choose: which tool first, then which
74
+ * project, then which conversation. Two rules keep that from becoming three taps
75
+ * on every visit: an Agent that only has one project skips the project row
76
+ * (`skipProjectRows`), and a search expands every path it matched. Everything is
77
+ * derived here so the drawer stays a renderer and this stays testable.
78
+ */
79
+ export function buildAgentDrawer({
80
+ sessions = [],
81
+ workspaces = [],
82
+ adapters = [],
83
+ selectedSessionId = null,
84
+ query = "",
85
+ // `null` means "nothing remembered yet"; an empty array is a real state the
86
+ // user produced by collapsing everything.
87
+ expandedAgents = null,
88
+ // Projects default to open once their Agent is open, so a project only needs
89
+ // remembering when the user closed it. Tracking the exceptions instead of the
90
+ // open set is what keeps "☰ → Agent → conversation" two taps.
91
+ collapsedProjects = [],
92
+ } = {}) {
93
+ const normalizedQuery = String(query ?? "").trim().toLowerCase();
94
+ const enabledWorkspaces = workspaces.filter((workspace) => workspace.enabled !== false);
95
+ const expandedAgentSet = new Set(Array.isArray(expandedAgents) ? expandedAgents : []);
96
+ const collapsedProjectSet = new Set(Array.isArray(collapsedProjects) ? collapsedProjects : []);
97
+
98
+ const groups = adapters.flatMap((adapter) => {
99
+ const capability = drawerCapability(adapter);
100
+ const agentSessions = sessions
101
+ .filter((session) => session.adapterId === adapter.id)
102
+ .sort((a, b) => lastActiveOf(b) - lastActiveOf(a));
103
+ if (!agentSessions.length && capability === "unavailable") {
104
+ // An unavailable Agent with nothing in it is a footnote, not a section.
105
+ return [{
106
+ id: adapter.id,
107
+ label: adapter.label ?? adapter.id,
108
+ capability,
109
+ capabilityLabel: CAPABILITY_LABEL[capability],
110
+ note: adapter.availability?.note ?? null,
111
+ sessionCount: 0,
112
+ expanded: false,
113
+ workspaces: [],
114
+ empty: true,
115
+ }];
116
+ }
117
+
118
+ const projects = enabledWorkspaces.flatMap((workspace) => {
119
+ const projectSessions = agentSessions.filter((session) => workspaceIdOf(session, enabledWorkspaces) === workspace.id);
120
+ if (!projectSessions.length) return [];
121
+ return [{
122
+ id: workspace.id,
123
+ name: workspace.name ?? workspace.alias,
124
+ alias: workspace.alias ?? workspace.name,
125
+ sessionCount: projectSessions.length,
126
+ sessions: projectSessions.map((session) => ({
127
+ id: session.id,
128
+ title: session.title ?? "未命名会话",
129
+ status: session.status ?? "offline",
130
+ adapterId: session.adapterId,
131
+ workspaceName: workspace.name ?? workspace.alias,
132
+ lastActiveAt: session.lastActiveAt ?? session.updatedAt ?? null,
133
+ selected: session.id === selectedSessionId,
134
+ })),
135
+ }];
136
+ });
137
+ // Sessions whose workspace is gone still belong to the Agent; dropping them
138
+ // would hide real conversations behind a stale catalog entry.
139
+ const orphaned = agentSessions.filter((session) => !projects.some((project) => project.sessions.some((entry) => entry.id === session.id)));
140
+ if (orphaned.length) {
141
+ projects.push({
142
+ id: `unknown-${adapter.id}`,
143
+ name: "未归属项目",
144
+ alias: "unknown",
145
+ sessionCount: orphaned.length,
146
+ sessions: orphaned.map((session) => ({
147
+ id: session.id,
148
+ title: session.title ?? "未命名会话",
149
+ status: session.status ?? "offline",
150
+ adapterId: session.adapterId,
151
+ workspaceName: session.projectAlias ?? "未归属项目",
152
+ lastActiveAt: session.lastActiveAt ?? session.updatedAt ?? null,
153
+ selected: session.id === selectedSessionId,
154
+ })),
155
+ });
156
+ }
157
+
158
+ const sessionCount = projects.reduce((total, project) => total + project.sessionCount, 0);
159
+ const agentMatches = matchesQuery(normalizedQuery, adapter.label, adapter.id);
160
+ const visibleProjects = projects.flatMap((project) => {
161
+ if (!normalizedQuery) return [project];
162
+ const projectMatches = matchesQuery(normalizedQuery, project.name, project.alias);
163
+ const matchingSessions = project.sessions.filter((session) => matchesQuery(normalizedQuery, session.title));
164
+ if (!agentMatches && !projectMatches && !matchingSessions.length) return [];
165
+ return [{
166
+ ...project,
167
+ sessions: agentMatches || projectMatches ? project.sessions : matchingSessions,
168
+ sessionCount: agentMatches || projectMatches ? project.sessionCount : matchingSessions.length,
169
+ }];
170
+ });
171
+ if (normalizedQuery && !visibleProjects.length) return [];
172
+
173
+ const visibleSessionCount = visibleProjects.reduce((total, project) => total + project.sessionCount, 0);
174
+ // A search always reveals what it matched. Otherwise the remembered expansion
175
+ // decides — and when nothing is remembered yet, the Agent holding the selected
176
+ // conversation opens by itself, so returning to the app lands on a path the
177
+ // user can see rather than on a collapsed tree.
178
+ const remembered = expandedAgents !== null;
179
+ const holdsSelection = visibleProjects.some((project) => project.sessions.some((session) => session.selected));
180
+ const expanded = normalizedQuery
181
+ ? true
182
+ : remembered
183
+ ? expandedAgentSet.has(adapter.id)
184
+ : holdsSelection;
185
+
186
+ return [{
187
+ id: adapter.id,
188
+ label: adapter.label ?? adapter.id,
189
+ capability,
190
+ capabilityLabel: CAPABILITY_LABEL[capability],
191
+ note: adapter.availability?.note ?? null,
192
+ sessionCount: normalizedQuery ? visibleSessionCount : sessionCount,
193
+ expanded,
194
+ empty: false,
195
+ workspaces: visibleProjects.map((project) => ({
196
+ ...project,
197
+ // The project row is only worth a tap when it actually splits something.
198
+ skipProjectRow: visibleProjects.length === 1,
199
+ expanded: normalizedQuery ? true : !collapsedProjectSet.has(project.id),
200
+ })),
201
+ }];
202
+ });
203
+
204
+ const visible = groups.filter((group) => !group.empty || !normalizedQuery);
205
+ // Ordering follows what people are actually doing: the tool holding the most
206
+ // recent work comes first, then usable-but-empty tools, and uninstalled ones
207
+ // last. Ranking purely by "can create" would bury DSH Desktop — the one Agent
208
+ // that can only be attached — under tools nobody has used.
209
+ const recentOf = (group) => group.workspaces
210
+ .flatMap((project) => project.sessions)
211
+ .reduce((latest, session) => Math.max(latest, Date.parse(session.lastActiveAt ?? "") || 0), 0);
212
+ return visible.sort((a, b) => {
213
+ const groupRank = (group) => {
214
+ if (group.sessionCount > 0) return 0;
215
+ return group.capability === "unavailable" ? 2 : 1;
216
+ };
217
+ const rank = groupRank(a) - groupRank(b);
218
+ if (rank !== 0) return rank;
219
+ const recency = recentOf(b) - recentOf(a);
220
+ if (Number.isFinite(recency) && recency !== 0) return recency;
221
+ const capability = CAPABILITY_RANK[a.capability] - CAPABILITY_RANK[b.capability];
222
+ if (capability !== 0) return capability;
223
+ return String(a.label).localeCompare(String(b.label));
224
+ });
225
+ }
226
+
227
+ /** "3 分钟前" style label for a drawer row; older entries fall back to a date. */
228
+ export function relativeTimeLabel(value, now = Date.now()) {
229
+ const time = Date.parse(value ?? "");
230
+ if (!Number.isFinite(time)) return "";
231
+ const minutes = Math.floor((now - time) / 60_000);
232
+ if (minutes < 1) return "刚刚";
233
+ if (minutes < 60) return `${minutes} 分钟前`;
234
+ const hours = Math.floor(minutes / 60);
235
+ if (hours < 24) return `${hours} 小时前`;
236
+ const days = Math.floor(hours / 24);
237
+ if (days === 1) return "昨天";
238
+ if (days < 7) return `${days} 天前`;
239
+ return new Date(time).toLocaleDateString();
240
+ }
241
+
@@ -0,0 +1,23 @@
1
+ import { describeAdapterCapabilities } from "./adapters/capabilities.js";
2
+
3
+ export class AdapterRegistry {
4
+ #adapters = new Map();
5
+
6
+ register(adapter) {
7
+ if (!adapter?.id || this.#adapters.has(adapter.id)) throw new Error(`Invalid or duplicate adapter: ${adapter?.id}`);
8
+ this.#adapters.set(adapter.id, adapter);
9
+ }
10
+
11
+ get(id) {
12
+ return this.#adapters.get(id);
13
+ }
14
+
15
+ async describe() {
16
+ return Promise.all([...this.#adapters.values()].map(async (adapter) => ({
17
+ id: adapter.id,
18
+ label: adapter.label,
19
+ capabilities: describeAdapterCapabilities(adapter),
20
+ availability: await adapter.detect(),
21
+ })));
22
+ }
23
+ }
@@ -0,0 +1,36 @@
1
+ const methodCapabilities = {
2
+ create: "start",
3
+ attach: "attach",
4
+ discover: "discoverConversations",
5
+ cancel: "stop",
6
+ approve: "resolveApproval",
7
+ // Answering an agent's own question ("which option?") is a different seam
8
+ // from approving a risky operation, so it gets its own capability instead of
9
+ // riding on `approve`.
10
+ answer: "resolveInput",
11
+ selectModel: "selectModel",
12
+ };
13
+
14
+ export function describeAdapterCapabilities(adapter) {
15
+ const declared = adapter?.capabilities ?? {};
16
+ const described = { ...declared };
17
+
18
+ for (const [capability, method] of Object.entries(methodCapabilities)) {
19
+ const legacy = capability === "cancel"
20
+ ? declared.stop
21
+ : capability === "approve"
22
+ ? declared.approvals
23
+ : undefined;
24
+ described[capability] = Boolean(
25
+ declared[capability]
26
+ ?? legacy
27
+ ?? (typeof adapter?.[method] === "function"),
28
+ );
29
+ }
30
+ described.resume = Boolean(declared.resume);
31
+ return described;
32
+ }
33
+
34
+ export function adapterSupports(adapter, capability) {
35
+ return describeAdapterCapabilities(adapter)[capability] === true;
36
+ }