dsh-a2a-groups 0.0.0-stage → 0.1.2

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/client.js ADDED
@@ -0,0 +1,3782 @@
1
+ /**
2
+ * dsh-a2a — browser half (DSH Web client plugin).
3
+ *
4
+ * HOW THIS FILE IS LOADED (mirrors every shipped plugin bundle):
5
+ * window.__ModuleLoader__.load({ id: "dsh-a2a-groups", factory: (require) => exports })
6
+ * The shell only *registers* the factory; the factory body runs later, on first
7
+ * materialization, and the `require` handed to it is the shell's synchronous
8
+ * lazy CJS table (react / react/jsx-runtime / react-dom / @deepseek-ai/cordis /
9
+ * zod / @tanstack/react-virtual / @deepseek-ai/dsh-client-store /
10
+ * @deepseek-ai/dsh-client-ui-slots / @deepseek-ai/dsh-client-ui-primitives /
11
+ * @deepseek-ai/dsh-client-ui-dockkit). Therefore:
12
+ * - no `import` / `export` statements anywhere in this file (it is a script,
13
+ * not a module);
14
+ * - only the modules above may be required.
15
+ *
16
+ * WHAT IT RENDERS: a read-only 「协作群」 left-sidebar entry plus its central
17
+ * panel page. Data is pull-only, from a Host-injected source:
18
+ * 1. exports.setDataSource({ read, subscribe }) — preferred, this is the slot
19
+ * the Lead wires to `ctx.remote` from lib/index.js;
20
+ * 2. window.__DSH_A2A_GROUPS__ — zero-integration fallback (plain snapshot,
21
+ * or an object exposing subscribe(listener)).
22
+ * Missing / empty / malformed data always renders 「还没有加入任何群」; nothing
23
+ * in this file may throw out of a render.
24
+ *
25
+ * See README-ui.md for the full data contract and integration steps.
26
+ */
27
+ window.__ModuleLoader__.load({
28
+ id: "dsh-a2a-groups",
29
+ factory: (require) => {
30
+ var module = { exports: {} };
31
+ var exports = module.exports;
32
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
33
+
34
+ /** Seed-table module: real React 18.3.1 handed down by the web shell. */
35
+ const react = require("react");
36
+ /**
37
+ * Optional shared design kit. Required defensively: every primitive used
38
+ * below is feature-detected, so a version skew degrades to plain markup
39
+ * instead of breaking the panel.
40
+ */
41
+ let primitives = {};
42
+ try {
43
+ primitives = require("@deepseek-ai/dsh-client-ui-primitives") || {};
44
+ } catch (error) {
45
+ primitives = {};
46
+ }
47
+
48
+ const h = react.createElement;
49
+
50
+ //#region contract constants
51
+ /** Global snapshot key the Host may inject without any code change. */
52
+ const GLOBAL_KEY = "__DSH_A2A_GROUPS__";
53
+ /** Stable slot identity: the sidebar row's `id` and the `main` panel's `key`. */
54
+ const PANEL_ID = "a2a-groups";
55
+ /** DOM event a Host may dispatch to push a fresh snapshot without polling. */
56
+ const CHANGE_EVENT = "dsh-a2a:groups-changed";
57
+ /** How many feed lines one expanded group shows. */
58
+ const FEED_LIMIT = 20;
59
+ /** Polling fallback while the panel is mounted (ms). Cheap: signature diff. */
60
+ const POLL_MS = 2000;
61
+ /** Sidebar row order; schedule uses 10 and the workspace picker sits above. */
62
+ const PANEL_ORDER = 40;
63
+
64
+ /**
65
+ * Key of the right-column members tab.
66
+ *
67
+ * One key for both the body and the title: the owner dispatches them through two
68
+ * slots, and a body registered under a key with no title renders as blank chrome.
69
+ */
70
+ const MEMBERS_TAB_KEY = "a2a-members";
71
+
72
+ /** Kind of the right-column members page. Also its tab key. */
73
+ const MEMBERS_TAB_KIND = "a2a-members-page";
74
+
75
+ /**
76
+ * Services the components reach for, captured in `apply(ctx)`.
77
+ *
78
+ * Components are rendered by the host rather than called by us, so they cannot take
79
+ * the context as an argument. One module-level handle is the smallest way to let a
80
+ * click handler ask the right column to open.
81
+ */
82
+ const services = { sidebarRight: undefined, sidebarRightTabs: undefined };
83
+
84
+ /**
85
+ * The panel's tabs, in the order a person scans them.
86
+ *
87
+ * `icon` names are resolved through {@link icon}, which prefers the product's own
88
+ * primitives export and falls back to geometry extracted from the shipped shell.
89
+ * Each tab keeps its text label: an icon-only tab bar in a 280px column makes the
90
+ * reader hover to find out what 「我参与」 means, and these four words are short
91
+ * enough that the glyph is a scanning aid rather than a replacement.
92
+ */
93
+ const PANEL_TABS = [
94
+ { id: "all", label: "全部", icon: "list" },
95
+ { id: "mine", label: "我创建", icon: "plus" },
96
+ { id: "joined", label: "我参与", icon: "users" },
97
+ { id: "cards", label: "同事名片", icon: "user" }
98
+ ];
99
+
100
+ //#endregion
101
+
102
+ //#region host bridge
103
+ /** The object that may carry the injected snapshot (browser window). */
104
+ function hostObject() {
105
+ if (typeof window !== "undefined" && window !== null) return window;
106
+ if (typeof globalThis !== "undefined" && globalThis !== null) return globalThis;
107
+ return undefined;
108
+ }
109
+
110
+ /**
111
+ * Host-controlled data source. Either a function returning a snapshot, or a
112
+ * `{ read, subscribe }` object. Set through `exports.setDataSource`, which is
113
+ * the seam lib/index.js uses to forward `ctx.remote`.
114
+ */
115
+ let dataSource;
116
+
117
+ /**
118
+ * Install the Host data source (Lead integration seam).
119
+ * @param source - `{ read: () => snapshot, subscribe?: (fn) => () => void }`
120
+ * or a bare snapshot reader function; `null`/anything else clears it.
121
+ */
122
+ function setDataSource(source) {
123
+ if (typeof source === "function") {
124
+ dataSource = { read: source };
125
+ return;
126
+ }
127
+ if (source && typeof source === "object" && typeof source.read === "function") {
128
+ dataSource = source;
129
+ return;
130
+ }
131
+ dataSource = undefined;
132
+ }
133
+
134
+ /** Read the raw, still-unvalidated snapshot from the best available source. */
135
+ function readRaw() {
136
+ if (dataSource !== undefined) {
137
+ try {
138
+ const value = dataSource.read();
139
+ if (value !== undefined) return value;
140
+ } catch (error) {
141
+ // A Host read failure must never surface as a render error.
142
+ warn("data source read failed", error);
143
+ }
144
+ }
145
+ const host = hostObject();
146
+ if (host === undefined) return undefined;
147
+ try {
148
+ return host[GLOBAL_KEY];
149
+ } catch (error) {
150
+ return undefined;
151
+ }
152
+ }
153
+
154
+ /** Subscribe to Host pushes. Always returns a disposer, never throws. */
155
+ function subscribeGroups(listener) {
156
+ const disposers = [];
157
+ if (dataSource !== undefined && typeof dataSource.subscribe === "function") {
158
+ try {
159
+ const off = dataSource.subscribe(listener);
160
+ if (typeof off === "function") disposers.push(off);
161
+ } catch (error) {
162
+ warn("data source subscribe failed", error);
163
+ }
164
+ }
165
+ const host = hostObject();
166
+ const raw = readRaw();
167
+ if (raw !== null && typeof raw === "object" && typeof raw.subscribe === "function") {
168
+ try {
169
+ const off = raw.subscribe(listener);
170
+ if (typeof off === "function") disposers.push(off);
171
+ } catch (error) {
172
+ warn("snapshot subscribe failed", error);
173
+ }
174
+ }
175
+ if (host !== undefined && typeof host.addEventListener === "function") {
176
+ const handler = () => {
177
+ listener();
178
+ };
179
+ try {
180
+ host.addEventListener(CHANGE_EVENT, handler);
181
+ disposers.push(() => {
182
+ host.removeEventListener(CHANGE_EVENT, handler);
183
+ });
184
+ } catch (error) {
185
+ /* non-DOM host: polling still covers it */
186
+ }
187
+ }
188
+ return () => {
189
+ for (const dispose of disposers) {
190
+ try {
191
+ dispose();
192
+ } catch (error) {
193
+ /* disposers are best-effort */
194
+ }
195
+ }
196
+ };
197
+ }
198
+
199
+ /** Console warning that cannot itself throw (no console, or a frozen one). */
200
+ function warn(message, detail) {
201
+ try {
202
+ if (typeof console !== "undefined" && console !== null && typeof console.warn === "function") {
203
+ console.warn("[dsh-a2a] " + message, detail === undefined ? "" : detail);
204
+ }
205
+ } catch (error) {
206
+ /* logging is never load-bearing */
207
+ }
208
+ }
209
+ //#endregion
210
+
211
+ //#region normalizers (total functions: garbage in, empty snapshot out)
212
+ function pickString(value) {
213
+ if (typeof value === "string") return value.trim();
214
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
215
+ return "";
216
+ }
217
+
218
+ function pickNumber(value) {
219
+ if (typeof value === "number") return Number.isFinite(value) ? value : 0;
220
+ if (typeof value === "string" && value.trim() !== "") {
221
+ const parsed = Number(value);
222
+ return Number.isFinite(parsed) ? parsed : 0;
223
+ }
224
+ return 0;
225
+ }
226
+
227
+ /** Accept epoch ms, epoch seconds, Date, or an ISO/parsable string. */
228
+ function pickTime(value) {
229
+ if (value instanceof Date) {
230
+ const ms = value.getTime();
231
+ return Number.isFinite(ms) ? ms : 0;
232
+ }
233
+ if (typeof value === "number" && Number.isFinite(value)) {
234
+ if (value <= 0) return 0;
235
+ return value < 1e12 ? Math.round(value * 1000) : value;
236
+ }
237
+ if (typeof value === "string" && value.trim() !== "") {
238
+ const parsed = Date.parse(value);
239
+ if (Number.isFinite(parsed)) return parsed;
240
+ return pickTime(Number(value));
241
+ }
242
+ return 0;
243
+ }
244
+
245
+ function firstDefined(bag, keys) {
246
+ for (const key of keys) {
247
+ if (bag[key] !== undefined && bag[key] !== null) return bag[key];
248
+ }
249
+ return undefined;
250
+ }
251
+
252
+ /**
253
+ * One roster member. `personId`/`displayName` are lib/groups.js `publicMember`.
254
+ *
255
+ * `about` is that member's `roleAbout` — what they do IN THIS GROUP. It is read
256
+ * here rather than from the person table because it is per-group by design: the
257
+ * same person is "研发" in one group and "产品" in another, and the members pane
258
+ * shows one group, so it must show that group's answer.
259
+ */
260
+ function normalizeMember(raw, index) {
261
+ if (!raw || typeof raw !== "object") return undefined;
262
+ const name = pickString(firstDefined(raw, ["displayName", "name", "title", "label"]));
263
+ // Both names are accepted: this bundle is cached by the page and may talk to a Host
264
+ // that still sends the older field. Do not drop the old one — a cached bundle and a
265
+ // fresh Host are a normal pairing for a few navigations after an upgrade.
266
+ const id = pickString(firstDefined(raw, ["personId", "memberId", "id", "sessionId", "key"])) || "member-" + String(index);
267
+ // "present but empty" and "absent" mean different things: the tool leaves a field
268
+ // alone when it is omitted, so empty == cleared. Losing that distinction would let
269
+ // a cleared description be silently re-filled from the person table.
270
+ const aboutRaw = firstDefined(raw, ["roleAbout", "about", "headline"]);
271
+ return {
272
+ id: id,
273
+ name: name !== "" ? name : id,
274
+ role: pickString(firstDefined(raw, ["role", "kind"])),
275
+ roleAbout: pickString(aboutRaw),
276
+ roleAboutKnown: aboutRaw !== undefined,
277
+ tags: (Array.isArray(raw.tags) ? raw.tags : []).filter(function (tag) {
278
+ return typeof tag === "string" && tag !== "";
279
+ }),
280
+ online: raw.online === true,
281
+ self: raw.self === true || raw.isSelf === true
282
+ };
283
+ }
284
+
285
+ /**
286
+ * One feed / last-message entry.
287
+ *
288
+ * `seq`/`msgId`/`at`/`from:{personId,displayName}`/`kind`/`text` is exactly
289
+ * `lib/groups.js` `publicEntry()`; the rest are conveniences for other Hosts.
290
+ */
291
+ function normalizeEntry(raw, index) {
292
+ if (!raw || typeof raw !== "object") return undefined;
293
+ const text = pickString(firstDefined(raw, ["text", "body", "summary", "message", "content", "preview"]));
294
+ // `from` is an object in groups.js (`{personId, displayName}`), a string elsewhere.
295
+ const from = raw.from;
296
+ const fromName = from !== null && typeof from === "object" ? pickString(firstDefined(from, ["displayName", "name", "personId", "memberId"])) : pickString(from);
297
+ const senderName = fromName !== "" ? fromName : pickString(firstDefined(raw, ["senderName", "sender", "author", "name"]));
298
+ const time = pickTime(firstDefined(raw, ["at", "time", "ts", "createdAt", "sentAt"]));
299
+ const seq = pickNumber(firstDefined(raw, ["seq", "sequence"]));
300
+ const id = pickString(firstDefined(raw, ["msgId", "id", "messageId"]));
301
+ return {
302
+ id: id !== "" ? id : seq > 0 ? "seq-" + String(seq) : "entry-" + String(index),
303
+ seq: seq,
304
+ text: text,
305
+ senderName: senderName !== "" ? senderName : "未知成员",
306
+ time: time,
307
+ kind: pickString(firstDefined(raw, ["kind", "type"]))
308
+ };
309
+ }
310
+
311
+ /** One collaboration group plus its roster and feed. */
312
+ function normalizeGroup(raw, index) {
313
+ if (!raw || typeof raw !== "object") return undefined;
314
+ const id = pickString(firstDefined(raw, ["id", "groupId", "group"])) || "group-" + String(index);
315
+ const name = pickString(firstDefined(raw, ["name", "title", "label"])) || "未命名群";
316
+
317
+ // `roster` is the list a Session cached after calling with a member credential;
318
+ // `members` is groups.js `members()` for a group hosted right here. Either is
319
+ // authoritative — both came from a caller that held a credential.
320
+ const membersRaw = Array.isArray(raw.roster)
321
+ ? raw.roster
322
+ : Array.isArray(raw.members)
323
+ ? raw.members
324
+ : Array.isArray(raw.memberList)
325
+ ? raw.memberList
326
+ : [];
327
+ const members = [];
328
+ for (let i = 0; i < membersRaw.length; i += 1) {
329
+ const member = normalizeMember(membersRaw[i], i);
330
+ if (member !== undefined) members.push(member);
331
+ }
332
+
333
+ // `feedTail` is what lib/groups.js `snapshot()` calls it; `feed`/`messages` are conveniences.
334
+ const feedRaw = Array.isArray(raw.feed) ? raw.feed : Array.isArray(raw.messages) ? raw.messages : Array.isArray(raw.feedTail) ? raw.feedTail : [];
335
+ const feed = [];
336
+ for (let i = 0; i < feedRaw.length; i += 1) {
337
+ const entry = normalizeEntry(feedRaw[i], i);
338
+ if (entry !== undefined) feed.push(entry);
339
+ }
340
+ feed.sort((left, right) => left.time - right.time || left.seq - right.seq);
341
+ const recentFeed = feed.length > FEED_LIMIT ? feed.slice(feed.length - FEED_LIMIT) : feed;
342
+
343
+ // The Host no longer sends message bodies — deliberately: the snapshot is written into the page
344
+ // HTML, and a group member reading the group is not the same as the body appearing in every
345
+ // page render. So the summary is assembled from routing facts.
346
+ //
347
+ // `lastFromName` names who spoke without quoting them. A system line has no author, which is
348
+ // why it is carried as its own flag rather than inferred from an empty name — "nobody said
349
+ // anything" and "the system said something" are different statements.
350
+ const lastFrom = pickString(firstDefined(raw, ["lastFromName", "lastFrom"]));
351
+ const lastIsSystem = raw.lastIsSystem === true;
352
+ const lastAt = pickTime(firstDefined(raw, ["lastTime", "lastPostAt", "lastMessageAt"]));
353
+
354
+ // An older Host (or a fixture written against one) sends a whole `lastPost` object including
355
+ // `text`. Its author and time are useful; its body must be dropped here rather than carried
356
+ // into the summary, or the leak returns through the compatibility path where nobody looks.
357
+ // The oldest explicit form: a whole `lastMessage` object. Its `from`/`at` are useful and its
358
+ // `body`/`text` are dropped on the way in.
359
+ const legacyExplicit = raw.lastMessage !== null && typeof raw.lastMessage === "object"
360
+ ? raw.lastMessage
361
+ : undefined;
362
+ const legacyLast = raw.lastPost !== null && typeof raw.lastPost === "object" ? raw.lastPost : undefined;
363
+ const legacyFrom = legacyLast === undefined
364
+ ? ""
365
+ : pickString(firstDefined(legacyLast, ["from", "senderName", "displayName"]))
366
+ || (legacyLast.from !== null && typeof legacyLast.from === "object"
367
+ ? pickString(legacyLast.from.displayName)
368
+ : "");
369
+ const legacyAt = legacyLast === undefined ? 0 : pickTime(firstDefined(legacyLast, ["at", "time"]));
370
+
371
+ // The oldest shape: a feed array. Its last entry names an author and a time, and its `text` is
372
+ // dropped — a compatibility path that forwards the body is the same leak in a place nobody
373
+ // audits.
374
+ const feedTailAuthor = (() => {
375
+ if (lastFrom !== "" && lastAt > 0) return { from: "", at: 0 };
376
+ if (!Array.isArray(raw.feed) || raw.feed.length === 0) return { from: "", at: 0 };
377
+ const last = raw.feed[raw.feed.length - 1];
378
+ if (last === null || typeof last !== "object") return { from: "", at: 0 };
379
+ const name = pickString(firstDefined(last, ["senderName", "fromName"]))
380
+ || (last.from !== null && typeof last.from === "object" ? pickString(last.from.displayName) : "")
381
+ || (typeof last.from === "string" ? pickString(last.from) : "");
382
+ return { from: name, at: pickTime(firstDefined(last, ["at", "time"])) };
383
+ })();
384
+
385
+ const explicitFrom = legacyExplicit === undefined
386
+ ? ""
387
+ : pickString(firstDefined(legacyExplicit, ["senderName", "fromName"]))
388
+ || (legacyExplicit.from !== null && typeof legacyExplicit.from === "object"
389
+ ? pickString(legacyExplicit.from.displayName)
390
+ : pickString(legacyExplicit.from));
391
+ const explicitAt = legacyExplicit === undefined ? 0 : pickTime(firstDefined(legacyExplicit, ["at", "time"]));
392
+
393
+ const effectiveFrom = lastFrom !== "" ? lastFrom
394
+ : (legacyFrom !== "" ? legacyFrom
395
+ : (explicitFrom !== "" ? explicitFrom : feedTailAuthor.from));
396
+ const effectiveAt = lastAt > 0 ? lastAt
397
+ : (legacyAt > 0 ? legacyAt
398
+ : (explicitAt > 0 ? explicitAt : feedTailAuthor.at));
399
+
400
+ // Kept as an object so every existing renderer keeps working; `text` is intentionally
401
+ // absent, and `from` carries the author instead.
402
+ // An author or a system marker is required. A timestamp on its own is not evidence of a message:
403
+ // it may be a settings change, and rendering "有新消息" for it would invent a line the group never
404
+ // had. Ordering reads `lastTime` separately, so nothing is lost.
405
+ const hasSummary = effectiveFrom !== "" || lastIsSystem === true;
406
+ let lastMessage = hasSummary === false
407
+ ? undefined
408
+ : {
409
+ id: "",
410
+ seq: 0,
411
+ time: effectiveAt,
412
+ from: effectiveFrom,
413
+ // `senderName` is the name other renderers read; kept because the card, the feed rows and
414
+ // the tests all address an author by it.
415
+ senderName: effectiveFrom,
416
+ // Never the message body. This string is the whole summary the card shows.
417
+ text: lastIsSystem === true
418
+ ? "群里有新消息"
419
+ : (effectiveFrom === "" ? "有新消息" : effectiveFrom + " 发来一条消息"),
420
+ system: lastIsSystem
421
+ };
422
+
423
+ const declaredMemberCount = pickNumber(firstDefined(raw, ["memberCount", "membersCount", "memberTotal"]));
424
+ const unreadCount = Math.max(0, Math.round(pickNumber(firstDefined(raw, ["unreadCount", "unread", "unreadTotal"]))));
425
+
426
+ return {
427
+ id: id,
428
+ name: name,
429
+ // `topic` is the secondary line in groups.js; shown as the card tooltip.
430
+ topic: pickString(firstDefined(raw, ["topic", "description"])),
431
+ memberCount: declaredMemberCount > 0 ? Math.round(declaredMemberCount) : members.length,
432
+ unreadCount: unreadCount,
433
+ lastMessage: lastMessage,
434
+ lastTime: lastMessage === undefined ? 0 : lastMessage.time,
435
+ members: members,
436
+ // No feed array: the Host does not send one any more, and keeping an always-empty field invites
437
+ // a future renderer to depend on it.
438
+ feed: recentFeed,
439
+ // `role` is the permission bit (owner | member). It decides which section
440
+ // the group belongs to, because the ACTIONS differ: an owner approves and
441
+ // invites, a member only reads and posts.
442
+ role: pickString(firstDefined(raw, ["role", "permission"])) || "member",
443
+ // A group that exists only as a directory row: not joined, so it belongs in
444
+ // "discover" and its only action is "ask to join".
445
+ discovered: raw.discovered === true || raw.joined === false,
446
+ // Which Session this membership belongs to, when the Host says.
447
+ sessionId: pickString(firstDefined(raw, ["sessionId", "session"])),
448
+ // My own person id IN THIS GROUP, when the Host puts it on the membership row
449
+ // (`lib/index.js` does). It is what lets the pane say "这是你" before any
450
+ // roster has been fetched, which is the normal state on first open.
451
+ personId: pickString(firstDefined(raw, ["personId", "memberId"])),
452
+ muted: raw.muted === true || raw.mute === true
453
+ };
454
+ }
455
+
456
+ /**
457
+ * Coerce anything the Host (or a future `ctx.remote` read) hands over into
458
+ * the shape the components consume.
459
+ * @param raw - array of groups, `{ groups: [...] }`, `{ items: [...] }`, or junk.
460
+ * @returns `{ groups, updatedAt, unreadTotal, ok }` — always safe to spread.
461
+ */
462
+ function readSnapshot(raw) {
463
+ const empty = {
464
+ groups: [],
465
+ cards: [],
466
+ updatedAt: 0,
467
+ unreadTotal: 0,
468
+ ok: false
469
+ };
470
+ try {
471
+ const container = Array.isArray(raw) ? raw : raw !== null && typeof raw === "object" ? (Array.isArray(raw.groups) ? raw.groups : Array.isArray(raw.items) ? raw.items : []) : [];
472
+ const groups = [];
473
+ for (let i = 0; i < container.length; i += 1) {
474
+ const group = normalizeGroup(container[i], i);
475
+ if (group !== undefined) groups.push(group);
476
+ }
477
+ let unreadTotal = 0;
478
+ for (const group of groups) unreadTotal += group.unreadCount;
479
+ const updatedAt = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? pickTime(firstDefined(raw, ["updatedAt", "time", "ts"])) : 0;
480
+ return {
481
+ groups: groups,
482
+ cards: readCards(raw),
483
+ updatedAt: updatedAt,
484
+ unreadTotal: unreadTotal,
485
+ ok: true
486
+ };
487
+ } catch (error) {
488
+ return empty;
489
+ }
490
+ }
491
+
492
+ /** Current snapshot straight from the Host, normalized. */
493
+ function currentSnapshot() {
494
+ return readSnapshot(readRaw());
495
+ }
496
+
497
+ /** Cheap change signature so polling only re-renders on real movement. */
498
+ function signatureOf(snapshot) {
499
+ let signature = String(snapshot.groups.length) + "|" + String(snapshot.unreadTotal);
500
+ for (const group of snapshot.groups) {
501
+ signature += "|" + group.id + ":" + String(group.memberCount) + ":" + String(group.unreadCount) + ":" + String(group.lastTime);
502
+ const last = group.lastMessage;
503
+ if (last !== undefined) signature += ":" + last.id + ":" + String(last.text.length);
504
+ }
505
+ return signature;
506
+ }
507
+ //#endregion
508
+
509
+ //#region time formatting (no dependency on primitive time helpers)
510
+ function pad2(value) {
511
+ return value < 10 ? "0" + String(value) : String(value);
512
+ }
513
+
514
+ /** Compact Chinese relative time, e.g. 「刚刚」「3 分钟前」「昨天 14:05」. */
515
+ function formatTime(time) {
516
+ if (!Number.isFinite(time) || time <= 0) return "";
517
+ const now = Date.now();
518
+ const delta = now - time;
519
+ if (delta >= 0 && delta < 60 * 1000) return "刚刚";
520
+ if (delta >= 60 * 1000 && delta < 60 * 60 * 1000) return String(Math.floor(delta / (60 * 1000))) + " 分钟前";
521
+ if (delta >= 60 * 60 * 1000 && delta < 24 * 60 * 60 * 1000) return String(Math.floor(delta / (60 * 60 * 1000))) + " 小时前";
522
+ const then = new Date(time);
523
+ const today = new Date(now);
524
+ const clock = pad2(then.getHours()) + ":" + pad2(then.getMinutes());
525
+ const sameDay = then.getFullYear() === today.getFullYear() && then.getMonth() === today.getMonth() && then.getDate() === today.getDate();
526
+ if (sameDay) return clock;
527
+ const yesterday = new Date(now - 24 * 60 * 60 * 1000);
528
+ const isYesterday = then.getFullYear() === yesterday.getFullYear() && then.getMonth() === yesterday.getMonth() && then.getDate() === yesterday.getDate();
529
+ if (isYesterday) return "昨天 " + clock;
530
+ if (then.getFullYear() === today.getFullYear()) return String(then.getMonth() + 1) + "月" + String(then.getDate()) + "日 " + clock;
531
+ return String(then.getFullYear()) + "-" + pad2(then.getMonth() + 1) + "-" + pad2(then.getDate());
532
+ }
533
+
534
+ /** One-line preview: collapse newlines, clamp length. */
535
+ function previewText(text, limit) {
536
+ const flat = String(text === undefined || text === null ? "" : text).replace(/\s+/g, " ").trim();
537
+ if (flat === "") return "(暂无内容)";
538
+ const max = typeof limit === "number" && limit > 0 ? limit : 60;
539
+ return flat.length > max ? flat.slice(0, max) + "…" : flat;
540
+ }
541
+ //#endregion
542
+
543
+ //#region styles (inline only: no CSS file, no build artifacts)
544
+ const S = {
545
+ root: {
546
+ display: "flex",
547
+ flexDirection: "column",
548
+ gap: "10px",
549
+ padding: "16px",
550
+ height: "100%",
551
+ minHeight: "0",
552
+ overflowY: "auto",
553
+ boxSizing: "border-box",
554
+ color: "inherit",
555
+ font: "13px/1.5 inherit"
556
+ },
557
+ header: {
558
+ display: "flex",
559
+ alignItems: "baseline",
560
+ gap: "8px",
561
+ flexWrap: "wrap"
562
+ },
563
+ title: {
564
+ fontSize: "15px",
565
+ fontWeight: "600",
566
+ margin: 0
567
+ },
568
+ hint: {
569
+ fontSize: "12px",
570
+ opacity: 0.55
571
+ },
572
+ empty: {
573
+ display: "flex",
574
+ flexDirection: "column",
575
+ gap: "6px",
576
+ alignItems: "flex-start",
577
+ padding: "20px 16px",
578
+ borderRadius: "10px",
579
+ border: "1px dashed rgba(127,127,127,0.35)",
580
+ opacity: 0.85
581
+ },
582
+ emptyTitle: {
583
+ fontSize: "14px",
584
+ fontWeight: "600",
585
+ margin: 0
586
+ },
587
+ /* Dimmed illustration for the empty state: present enough to soften the panel,
588
+ faint enough that it never competes with the sentence under it. */
589
+ emptyIcon: {
590
+ display: "inline-flex",
591
+ alignItems: "center",
592
+ justifyContent: "center",
593
+ marginBottom: "2px",
594
+ opacity: 0.3,
595
+ color: "currentColor"
596
+ },
597
+ emptyBody: {
598
+ fontSize: "12px",
599
+ opacity: 0.6,
600
+ margin: 0
601
+ },
602
+ card: {
603
+ borderRadius: "10px",
604
+ border: "1px solid rgba(127,127,127,0.25)",
605
+ overflow: "hidden",
606
+ background: "transparent"
607
+ },
608
+ cardHead: {
609
+ display: "flex",
610
+ alignItems: "center",
611
+ gap: "8px",
612
+ width: "100%",
613
+ padding: "9px 11px",
614
+ border: "none",
615
+ background: "transparent",
616
+ color: "inherit",
617
+ cursor: "pointer",
618
+ textAlign: "left",
619
+ font: "inherit"
620
+ },
621
+ chevron: {
622
+ flex: "none",
623
+ display: "inline-flex",
624
+ alignItems: "center",
625
+ opacity: 0.55,
626
+ color: "inherit"
627
+ },
628
+ groupName: {
629
+ flex: "1 1 auto",
630
+ minWidth: 0,
631
+ fontWeight: "600",
632
+ whiteSpace: "nowrap",
633
+ overflow: "hidden",
634
+ textOverflow: "ellipsis"
635
+ },
636
+ badge: {
637
+ flex: "none",
638
+ minWidth: "17px",
639
+ padding: "0 5px",
640
+ borderRadius: "9px",
641
+ textAlign: "center",
642
+ fontSize: "11px",
643
+ lineHeight: "17px",
644
+ background: "rgba(240,80,80,0.18)",
645
+ color: "inherit",
646
+ fontWeight: "600"
647
+ },
648
+ /* A count with a glyph instead of a word: 「3 人」 became an icon plus a number,
649
+ which is how every IM client shows a roster size and costs one third the width. */
650
+ metaChip: {
651
+ flex: "none",
652
+ display: "inline-flex",
653
+ alignItems: "center",
654
+ gap: "3px",
655
+ fontSize: "11px",
656
+ opacity: 0.55,
657
+ lineHeight: "1"
658
+ },
659
+ memberCount: {
660
+ flex: "none",
661
+ display: "inline-flex",
662
+ alignItems: "center",
663
+ gap: "3px",
664
+ fontSize: "11px",
665
+ opacity: 0.55
666
+ },
667
+ cardBody: {
668
+ display: "flex",
669
+ flexDirection: "column",
670
+ gap: "10px",
671
+ padding: "0 11px 11px"
672
+ },
673
+ last: {
674
+ display: "flex",
675
+ gap: "6px",
676
+ fontSize: "12px",
677
+ opacity: 0.72,
678
+ minWidth: 0
679
+ },
680
+ lastWho: {
681
+ flex: "none",
682
+ fontWeight: "600"
683
+ },
684
+ lastText: {
685
+ flex: "1 1 auto",
686
+ minWidth: 0,
687
+ whiteSpace: "nowrap",
688
+ overflow: "hidden",
689
+ textOverflow: "ellipsis"
690
+ },
691
+ lastTime: {
692
+ flex: "none",
693
+ fontSize: "11px",
694
+ opacity: 0.7
695
+ },
696
+ sectionLabel: {
697
+ fontSize: "11px",
698
+ letterSpacing: "0.04em",
699
+ textTransform: "uppercase",
700
+ opacity: 0.5,
701
+ margin: 0
702
+ },
703
+ memberRow: {
704
+ display: "flex",
705
+ flexWrap: "wrap",
706
+ gap: "4px"
707
+ },
708
+ chip: {
709
+ padding: "1px 7px",
710
+ borderRadius: "9px",
711
+ border: "1px solid rgba(127,127,127,0.3)",
712
+ fontSize: "11px",
713
+ opacity: 0.85
714
+ },
715
+ feed: {
716
+ display: "flex",
717
+ flexDirection: "column",
718
+ gap: "5px",
719
+ margin: 0,
720
+ padding: 0,
721
+ listStyle: "none"
722
+ },
723
+ feedItem: {
724
+ display: "flex",
725
+ gap: "6px",
726
+ fontSize: "12px",
727
+ minWidth: 0
728
+ },
729
+ feedWho: {
730
+ flex: "none",
731
+ fontWeight: "600",
732
+ opacity: 0.85
733
+ },
734
+ feedText: {
735
+ flex: "1 1 auto",
736
+ minWidth: 0,
737
+ wordBreak: "break-word",
738
+ opacity: 0.78
739
+ },
740
+ sidebarGlyph: {
741
+ position: "relative",
742
+ display: "inline-flex",
743
+ alignItems: "center",
744
+ justifyContent: "center",
745
+ lineHeight: 0
746
+ },
747
+ sidebarBadge: {
748
+ position: "absolute",
749
+ top: "-4px",
750
+ right: "-6px",
751
+ minWidth: "12px",
752
+ height: "12px",
753
+ padding: "0 3px",
754
+ borderRadius: "6px",
755
+ fontSize: "9px",
756
+ lineHeight: "12px",
757
+ textAlign: "center",
758
+ background: "rgba(240,80,80,0.9)",
759
+ color: "#fff",
760
+ fontWeight: "700"
761
+ }
762
+ };
763
+ //#endregion
764
+
765
+ //#region hooks
766
+ /** Live snapshot: initial read + Host subscription + cheap polling. */
767
+ function useSnapshot() {
768
+ const [snapshot, setSnapshot] = react.useState(currentSnapshot);
769
+ react.useEffect(() => {
770
+ let signature = signatureOf(snapshot);
771
+ const refresh = () => {
772
+ const next = currentSnapshot();
773
+ const nextSignature = signatureOf(next);
774
+ if (nextSignature === signature) return;
775
+ signature = nextSignature;
776
+ setSnapshot(next);
777
+ };
778
+ const dispose = subscribeGroups(refresh);
779
+ let timer;
780
+ if (typeof window !== "undefined" && window !== null && typeof window.setInterval === "function") {
781
+ timer = window.setInterval(refresh, POLL_MS);
782
+ }
783
+ return () => {
784
+ if (typeof dispose === "function") dispose();
785
+ if (timer !== undefined && typeof window.clearInterval === "function") window.clearInterval(timer);
786
+ };
787
+ }, []);
788
+ return snapshot;
789
+ }
790
+
791
+ /** Expand/collapse memory for the group cards. */
792
+ function useExpanded() {
793
+ const [expanded, setExpanded] = react.useState({});
794
+ const toggle = (id) => {
795
+ setExpanded((previous) => {
796
+ const next = {};
797
+ for (const key of Object.keys(previous)) if (key !== id) next[key] = previous[key];
798
+ if (previous[id] !== true) next[id] = true;
799
+ return next;
800
+ });
801
+ };
802
+ return [expanded, toggle];
803
+ }
804
+
805
+ /**
806
+ * A single boolean that survives re-renders.
807
+ *
808
+ * Not persisted on purpose: a strip that remembered being open would surprise
809
+ * someone who collapsed it in a different Session, and collapsed is the quiet
810
+ * default for something that sits above every composer.
811
+ */
812
+
813
+ /**
814
+ * Insert \`@name \` into the composer, through the owner's two-step API.
815
+ *
816
+ * \`captureInsertion()\` first, then \`insertText(text, span)\`: the span carries the
817
+ * selection and the draft revision, so an insert into a draft that changed since the
818
+ * click is REFUSED rather than applied in the wrong place. Passing text alone does
819
+ * not throw — it silently returns false — which is why the one-argument version
820
+ * looked like a dead button.
821
+ *
822
+ * Returns true when something was actually written.
823
+ */
824
+ function insertMention(inputActions, name) {
825
+ const text = "@" + name + " ";
826
+ try {
827
+ if (inputActions === null || inputActions === undefined) return false;
828
+ if (typeof inputActions.captureInsertion === "function"
829
+ && typeof inputActions.insertText === "function") {
830
+ const span = inputActions.captureInsertion();
831
+ return inputActions.insertText(text, span) === true;
832
+ }
833
+ // Last resort for a Host that only offers a whole-draft setter. It replaces
834
+ // the draft, which is why it is the fallback and not the first choice.
835
+ if (typeof inputActions.setDraft === "function") {
836
+ inputActions.setDraft(text);
837
+ return true;
838
+ }
839
+ return false;
840
+ } catch (error) {
841
+ reportUnavailable("inputActions.insertText", error);
842
+ return false;
843
+ }
844
+ }
845
+
846
+ /**
847
+ * Component-local state that survives re-renders.
848
+ *
849
+ * Deliberately unpersisted: the panel keeps its active tab and its tag filter here,
850
+ * and neither should outlive the page. A remembered tag filter would silently hide
851
+ * colleagues after a reload, which is worse than losing the filter.
852
+ */
853
+ function useLocalState(initial) {
854
+ const [value, setValue] = react.useState(initial === undefined ? false : initial);
855
+ return {
856
+ value: value,
857
+ set: setValue,
858
+ /** The active tag filter, when this state holds a {tab, tag} pair. */
859
+ get tag() {
860
+ return value !== null && typeof value === "object" ? value.tag : "";
861
+ }
862
+ };
863
+ }
864
+ //#endregion
865
+
866
+ //#region icons
867
+ /**
868
+ * Icon layer.
869
+ *
870
+ * ## Where these come from
871
+ *
872
+ * `@dsh-client-ui-primitives` is a BUILD-TIME VENDORED module in the web shell: it
873
+ * is not resolvable from disk (there is no `lib/index.js` anywhere under the `dsh`
874
+ * install), but the shell's seed table binds it to the real package, so
875
+ * `require("@dsh-client-ui-primitives")` is the only correct way in. It is required
876
+ * once at the top of the factory and read through `primitives.IconXxxYyy`.
877
+ *
878
+ * The bundle's icon components share one contract, verified in the shipped shell:
879
+ *
880
+ * ({ size = 16, className, strokeWidth }) => svg{ width: size, height: size,
881
+ * viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", strokeWidth, ... }
882
+ *
883
+ * Two consequences for the code below:
884
+ * - every icon is already 16x16 and `aria-hidden`, so we pass `size` and nothing else
885
+ * decorative;
886
+ * - the variant suffix is weight, not shape: `...Regular` is 1px and `...Medium` is
887
+ * 1.3px. The geometry is identical, so a fallback drawn at 1px matches Regular.
888
+ *
889
+ * ## Why every icon has an inline-SVG fallback
890
+ *
891
+ * The primitives package is versioned with the shell. A profile whose shell predates
892
+ * an icon, or a future rename, would otherwise render a blank square where an
893
+ * affordance used to be — the worst failure mode for a clickable row. Each fallback
894
+ * below is not invented: its geometry was extracted from the shipped shell bundle
895
+ * with `scripts/extract-icon-paths.mjs`, so a fallback is pixel-identical to the
896
+ * product icon it stands in for. Every icon in this table is on the 16x16 grid, but
897
+ * each fallback still carries its own `viewBox` because the product is not uniformly
898
+ * 16x16 in general (`IconCheckCircleFillRegular` is 36x36).
899
+ *
900
+ * ## Icons used, and what each one means here
901
+ *
902
+ * | export | drawn as | used for |
903
+ * |---------------------------------|----------|---------------------------------------|
904
+ * | IconFlatListOutlineRegular | list | tab 「全部」 |
905
+ * | IconPlusOutlineRegular | plus | tab 「我创建」 |
906
+ * | IconUsersOutlineRegular | 2 people | tab 「我参与」/ members header / count |
907
+ * | IconUserOutlineRegular | 1 person | tab 「同事名片」 |
908
+ * | IconChevronDownOutlineRegular | ⌄ | expanded group card |
909
+ * | IconChevronRightOutlineRegular | › | collapsed group card |
910
+ * | IconNewChatOutlineRegular | speech | empty state 「还没有加入任何群」 |
911
+ *
912
+ * `IconEditOutlineRegular` is mapped for the 「改我的名片」 / edit affordances; the
913
+ * members rows use a text chip (`编辑介绍`) instead, because a 12px pencil next to a
914
+ * name reads as "rename this person" rather than "describe yourself".
915
+ *
916
+ * `@所有人` deliberately has **no** icon: it renders a literal `@`, which is the
917
+ * universal broadcast marker and is exactly the character the click inserts.
918
+ */
919
+ const ICONS = {
920
+ list: "IconFlatListOutlineRegular",
921
+ plus: "IconPlusOutlineRegular",
922
+ users: "IconUsersOutlineRegular",
923
+ user: "IconUserOutlineRegular",
924
+ chevronDown: "IconChevronDownOutlineRegular",
925
+ chevronRight: "IconChevronRightOutlineRegular",
926
+ edit: "IconEditOutlineRegular",
927
+ chat: "IconNewChatOutlineRegular"
928
+ };
929
+
930
+ /**
931
+ * Fallback geometry, copied out of the shipped shell bundle.
932
+ *
933
+ * `paths` are attribute maps; `attrs` are the values the shell's shared base svg
934
+ * puts on the <svg> element and its children inherit (`stroke`, `strokeLinecap`).
935
+ * `viewBox` is per icon because not all of them use the 16x16 grid.
936
+ */
937
+ const ICON_FALLBACKS = {
938
+ IconFlatListOutlineRegular: {
939
+ viewBox: "0 0 16 16",
940
+ attrs: { fill: "none", stroke: "currentColor", strokeLinecap: "round" },
941
+ paths: [{ d: "M6 3.5h7.5M6 8h7.5M6 12.5h7.5" }, { d: "M2.6 3.5h.01M2.6 8h.01M2.6 12.5h.01" }]
942
+ },
943
+ IconPlusOutlineRegular: {
944
+ viewBox: "0 0 16 16",
945
+ attrs: { fill: "none", stroke: "currentColor" },
946
+ paths: [{ d: "M8 2V14", stroke: "currentColor" }, { d: "M2 8H14", stroke: "currentColor" }]
947
+ },
948
+ IconUsersOutlineRegular: {
949
+ viewBox: "0 0 16 16",
950
+ attrs: { fill: "none", stroke: "currentColor" },
951
+ paths: [
952
+ { d: "M6 8.25C7.51878 8.25 8.75 7.01878 8.75 5.5C8.75 3.98122 7.51878 2.75 6 2.75C4.48122 2.75 3.25 3.98122 3.25 5.5C3.25 7.01878 4.48122 8.25 6 8.25Z", stroke: "currentColor" },
953
+ { d: "M1 14.5C1 11.5 3.5 10.25 6 10.25C8.5 10.25 11 11.5 11 14.5", stroke: "currentColor" },
954
+ { d: "M10.5 2.9C11.65 3.35 12.45 4.35 12.45 5.5C12.45 6.65 11.65 7.65 10.5 8.1", stroke: "currentColor" },
955
+ { d: "M12.4 10.6C13.9 11.3 15 12.6 15 14.5", stroke: "currentColor" }
956
+ ]
957
+ },
958
+ IconUserOutlineRegular: {
959
+ viewBox: "0 0 16 16",
960
+ attrs: { fill: "none", stroke: "currentColor" },
961
+ paths: [
962
+ { d: "M8 8.25C9.51878 8.25 10.75 7.01878 10.75 5.5C10.75 3.98122 9.51878 2.75 8 2.75C6.48122 2.75 5.25 3.98122 5.25 5.5C5.25 7.01878 6.48122 8.25 8 8.25Z", stroke: "currentColor" },
963
+ { d: "M2.5 14.5C2.5 11.5 5.25 10.25 8 10.25C10.75 10.25 13.5 11.5 13.5 14.5", stroke: "currentColor" }
964
+ ]
965
+ },
966
+ IconChevronDownOutlineRegular: {
967
+ viewBox: "0 0 16 16",
968
+ attrs: { fill: "none", stroke: "currentColor" },
969
+ paths: [{ d: "M4 6L7.29289 9.29289C7.68342 9.68342 8.31658 9.68342 8.70711 9.29289L12 6", stroke: "currentColor" }]
970
+ },
971
+ IconChevronRightOutlineRegular: {
972
+ viewBox: "0 0 16 16",
973
+ attrs: { fill: "none", stroke: "currentColor" },
974
+ paths: [{ d: "M6 12L9.29289 8.70711C9.68342 8.31658 9.68342 7.68342 9.29289 7.29289L6 4", stroke: "currentColor" }]
975
+ },
976
+ IconEditOutlineRegular: {
977
+ viewBox: "0 0 16 16",
978
+ attrs: { fill: "none", stroke: "currentColor" },
979
+ paths: [
980
+ { d: "M8.85596 2.69971H4.19971C3.37141 2.69971 2.69992 3.37146 2.69971 4.19971V11.8003C2.69992 12.6285 3.37141 13.3003 4.19971 13.3003H11.8003C12.6283 13.2999 13.3001 12.6283 13.3003 11.8003V7.89893H14.3003V11.8003C14.3001 13.1806 13.1806 14.2999 11.8003 14.3003H4.19971C2.81913 14.3003 1.69992 13.1808 1.69971 11.8003V4.19971C1.69992 2.81918 2.81913 1.69971 4.19971 1.69971H8.85596V2.69971Z", fill: "currentColor" },
981
+ { d: "M7.7849 8.23878L13.888 2.13574", stroke: "currentColor" }
982
+ ]
983
+ },
984
+ IconNewChatOutlineRegular: {
985
+ viewBox: "0 0 16 16",
986
+ attrs: { fill: "none", stroke: "currentColor" },
987
+ paths: [
988
+ { d: "M2.37091 11.2501C1.58745 9.89288 1.32067 8.29835 1.61969 6.76006C1.91872 5.22177 2.76342 3.8433 3.99826 2.87846C5.2331 1.91362 6.77494 1.42737 8.33988 1.50925C9.90482 1.59113 11.3875 2.23562 12.5149 3.32406C13.6425 4.41269 14.3387 5.87206 14.4754 7.4334C14.612 8.99474 14.18 10.5529 13.2587 11.8209C12.3375 13.0888 10.9891 13.9813 9.46194 14.3337C8.18691 14.628 6.85895 14.5294 5.64989 14.0605C5.1712 13.8748 4.76962 13.4932 4.26534 13.3967C3.67413 13.2835 2.95257 13.5598 2.03794 14.3337", stroke: "currentColor" },
989
+ { d: "M8 5V11", stroke: "currentColor" },
990
+ { d: "M5 8H11", stroke: "currentColor" }
991
+ ]
992
+ },
993
+ };
994
+
995
+ /** Narrow a primitives export to something callable as a component. */
996
+ function isRenderable(value) {
997
+ return value !== null && value !== undefined && (typeof value === "function" || typeof value === "object");
998
+ }
999
+
1000
+ /**
1001
+ * Draw one icon.
1002
+ *
1003
+ * Resolves, in order: the primitives export for `name`, then the extracted
1004
+ * fallback geometry, then nothing (`null`). It never throws: an icon is decoration,
1005
+ * and a missing one must cost the glyph, not the row that carries the action.
1006
+ *
1007
+ * @param {string} name - key into {@link ICONS}, or a raw `Icon*` export name.
1008
+ * @param {number} [size] - pixel size; the product default is 16.
1009
+ * @returns {object|null} a vnode, or null when neither source has the icon.
1010
+ */
1011
+ function icon(name, size) {
1012
+ const exportName = ICONS[name] === undefined ? name : ICONS[name];
1013
+ const px = typeof size === "number" && size > 0 ? size : 14;
1014
+ try {
1015
+ const component = primitives[exportName];
1016
+ if (isRenderable(component)) return h(component, { size: px });
1017
+ } catch (error) {
1018
+ /* a throwing getter must fall through to the drawn fallback */
1019
+ }
1020
+ const shape = ICON_FALLBACKS[exportName];
1021
+ if (shape === undefined) return null;
1022
+ const attrs = Object.assign(
1023
+ {
1024
+ width: px,
1025
+ height: px,
1026
+ viewBox: shape.viewBox,
1027
+ strokeWidth: 1,
1028
+ strokeLinecap: shape.attrs.strokeLinecap === undefined ? "round" : shape.attrs.strokeLinecap,
1029
+ strokeLinejoin: "round",
1030
+ // Per the product contract: icons are decoration. The accessible label lives on
1031
+ // the button's `title`, never on the glyph.
1032
+ "aria-hidden": "true",
1033
+ focusable: "false",
1034
+ style: { flex: "0 0 auto", display: "block", color: "currentColor" }
1035
+ },
1036
+ shape.attrs
1037
+ );
1038
+ return h(
1039
+ "svg",
1040
+ attrs,
1041
+ shape.paths.map(function (shapeSpec, index) {
1042
+ const tag = shapeSpec.circle === undefined ? "path" : "circle";
1043
+ const spec = shapeSpec.circle === undefined ? shapeSpec : shapeSpec.circle;
1044
+ return h(tag, Object.assign({ key: "p" + String(index) }, spec));
1045
+ })
1046
+ );
1047
+ }
1048
+
1049
+ /** `icon()` sized and styled for a text-sized inline slot. */
1050
+ function inlineIcon(name, size) {
1051
+ const drawn = icon(name, typeof size === "number" && size > 0 ? size : 12);
1052
+ return drawn;
1053
+ }
1054
+ //#endregion
1055
+
1056
+ //#region components
1057
+ /**
1058
+ * Empty state required by the contract: never an error, never a spinner.
1059
+ *
1060
+ * The illustration is a single dimmed speech-bubble glyph, not a paragraph of
1061
+ * explanation: a reader who has joined nothing needs one glanceable signal plus one
1062
+ * sentence, and an empty panel is where a wall of text is least likely to be read.
1063
+ * Both the glyph and the copy still say the same thing in words, so nothing is
1064
+ * carried by the picture alone.
1065
+ */
1066
+ function EmptyState() {
1067
+ return h(
1068
+ "div",
1069
+ { style: S.empty },
1070
+ h("span", { style: S.emptyIcon, "aria-hidden": "true" }, icon("chat", 28)),
1071
+ h("p", { style: S.emptyTitle }, "还没有加入任何群"),
1072
+ h("p", { style: S.emptyBody }, "在会话里让助手用 a2a_group_join 加入一个协作群,或接受别人发来的邀请链接。")
1073
+ );
1074
+ }
1075
+
1076
+ /** Feed line. */
1077
+ function FeedRow(props) {
1078
+ const entry = props.entry;
1079
+ return h(
1080
+ "li",
1081
+ { style: S.feedItem },
1082
+ h("span", { style: S.feedWho }, entry.senderName + ":"),
1083
+ h("span", { style: S.feedText }, previewText(entry.text, 200)),
1084
+ entry.time > 0 ? h("span", { style: S.lastTime }, formatTime(entry.time)) : null
1085
+ );
1086
+ }
1087
+
1088
+ /** One group: collapsed summary row, expanded roster + feed. */
1089
+ function GroupCard(props) {
1090
+ const group = props.group;
1091
+ const open = props.open === true;
1092
+ const onToggle = props.onToggle;
1093
+ const last = group.lastMessage;
1094
+
1095
+ const head = h(
1096
+ "button",
1097
+ {
1098
+ type: "button",
1099
+ style: S.cardHead,
1100
+ onClick: onToggle,
1101
+ "aria-expanded": open,
1102
+ title: group.topic !== "" ? group.name + " · " + group.topic : group.name
1103
+ },
1104
+ h("span", { style: S.chevron }, icon(open ? "chevronDown" : "chevronRight", 12)),
1105
+ h("span", { style: S.groupName }, group.name),
1106
+ group.unreadCount > 0
1107
+ ? h(
1108
+ "span",
1109
+ {
1110
+ style: S.badge,
1111
+ // The dot is the count; the word 「未读」 lives in the tooltip, so the
1112
+ // row stays readable at a glance without losing the accessible label.
1113
+ title: String(group.unreadCount) + " 条未读"
1114
+ },
1115
+ group.unreadCount > 99 ? "99+" : String(group.unreadCount)
1116
+ )
1117
+ : null,
1118
+ h(
1119
+ "span",
1120
+ { style: S.memberCount, title: String(group.memberCount) + " 个成员" },
1121
+ icon("users", 12),
1122
+ String(group.memberCount)
1123
+ )
1124
+ );
1125
+
1126
+ const summary =
1127
+ last === undefined
1128
+ ? h("div", { style: S.last }, h("span", { style: S.lastText }, "(还没有消息)"))
1129
+ : h(
1130
+ "div",
1131
+ { style: S.last },
1132
+ h("span", { style: S.lastWho }, last.senderName + ":"),
1133
+ h("span", { style: S.lastText }, previewText(last.text, 60)),
1134
+ last.time > 0 ? h("span", { style: S.lastTime }, formatTime(last.time)) : null
1135
+ );
1136
+
1137
+ const detail = open
1138
+ ? h(
1139
+ "div",
1140
+ { style: S.cardBody },
1141
+ h(
1142
+ "div",
1143
+ { style: S.memberRow },
1144
+ h("p", { style: S.sectionLabel }, "成员"),
1145
+ group.members.length === 0
1146
+ // Not an error: naming the members needs a member credential, and
1147
+ // this page is rendered before any Session exists. Say where the
1148
+ // answer lives instead of leaving a gap that reads as "nobody here".
1149
+ ? h("span", { style: S.chip }, "名单需在会话里查看(a2a_group_role)")
1150
+ : group.members.map((member) =>
1151
+ h(
1152
+ "span",
1153
+ { key: member.id, style: S.chip, title: member.role === "" ? member.name : member.name + " · " + member.role },
1154
+ member.self ? member.name + "(我)" : member.name
1155
+ )
1156
+ )
1157
+ ),
1158
+ h(
1159
+ "div",
1160
+ { style: S.memberRow },
1161
+ h("p", { style: S.sectionLabel }, "最近消息"),
1162
+ group.feed.length === 0
1163
+ ? h("span", { style: S.chip }, "(还没有消息)")
1164
+ : h("ul", { style: S.feed }, group.feed.map((entry) => h(FeedRow, { key: entry.id, entry: entry })))
1165
+ ),
1166
+ h("p", { style: S.emptyBody }, "只读视图:发消息请在会话里让助手用 a2a_group_post。")
1167
+ )
1168
+ : null;
1169
+
1170
+ return h("div", { style: S.card }, head, h("div", { style: S.cardBody }, summary), detail);
1171
+ }
1172
+
1173
+ /**
1174
+ * Central panel page (registered into the `main` slot under key PANEL_ID).
1175
+ * Receives no props from the shell; all data comes from the Host source.
1176
+ */
1177
+
1178
+ /**
1179
+ * Split groups into the sections the panel draws.
1180
+ *
1181
+ * Order is deliberate and not "by recency": ownership first (it needs action), then
1182
+ * membership (where the work happens), then discovery (a browse), and colleagues last
1183
+ * because a card is reference material rather than a place to be.
1184
+ *
1185
+ * A group with no explicit role is treated as `joined`: that is the safe default,
1186
+ * because showing an owner control for a group somebody merely belongs to would offer
1187
+ * an action the server will refuse.
1188
+ */
1189
+
1190
+ /**
1191
+ * Colleague cards from the snapshot.
1192
+ *
1193
+ * A card is a person's own description aggregated across their groups: name,
1194
+ * headline, tags, and which groups they share with this instance. The Host
1195
+ * assembles it from what members declared, so nothing here invents identity — an
1196
+ * absent card means "not fetched" and the tab says so.
1197
+ */
1198
+ function readCards(raw) {
1199
+ const list = raw !== null && typeof raw === "object" && !Array.isArray(raw) && Array.isArray(raw.cards)
1200
+ ? raw.cards
1201
+ : [];
1202
+ const cards = [];
1203
+ for (let i = 0; i < list.length; i += 1) {
1204
+ const entry = list[i];
1205
+ if (entry === null || typeof entry !== "object") continue;
1206
+ const name = pickString(firstDefined(entry, [ "name", "displayName", "title" ]));
1207
+ if (name === "") continue;
1208
+ cards.push({
1209
+ id: pickString(firstDefined(entry, [ "personId", "memberId", "id", "name" ])) || name,
1210
+ name: name,
1211
+ headline: pickString(firstDefined(entry, [ "headline", "roleAbout", "role", "about" ])),
1212
+ tags: (Array.isArray(entry.tags) ? entry.tags : []).filter(function (tag) {
1213
+ return typeof tag === "string" && tag !== "";
1214
+ }),
1215
+ groups: Array.isArray(entry.groups) ? entry.groups : [],
1216
+ // Every role they declared, in group order. The card shows the group names
1217
+ // beside the roles, so a person who is "研发" in one group and "产品" in
1218
+ // another reads as one colleague with two contexts rather than two people.
1219
+ roles: (Array.isArray(entry.roles) ? entry.roles : []).filter(function (role) {
1220
+ return typeof role === "string" && role !== "";
1221
+ }),
1222
+ // My own card: the only one the reader can change, so the only one styled as
1223
+ // editable. The Host decides this by member id, not by name.
1224
+ isSelf: entry.isSelf === true,
1225
+ online: entry.online === true,
1226
+ // Their A2A address, when the host knows one. Editable from the card, and the
1227
+ // basis for a future "reach them directly" action.
1228
+ endpoint: pickString(firstDefined(entry, [ "endpoint", "baseUrl", "url" ]))
1229
+ });
1230
+ }
1231
+ return cards;
1232
+ }
1233
+
1234
+ /**
1235
+ * The Self view the members pane needs: who "I" am, and what I wrote in this group.
1236
+ *
1237
+ * THREE SOURCES, deliberately ranked, because no single one is complete:
1238
+ *
1239
+ * 1. the roster row the Host marked `isSelf` — authoritative for the display name
1240
+ * and for `roleAbout` IN THIS GROUP;
1241
+ * 2. the person table (`snapshot.cards`) entry with `isSelf` — carries the default
1242
+ * `headline` and the tags, so a member whose roster row predates the
1243
+ * `roleAbout` field still shows something rather than an empty line;
1244
+ * 3. the membership row's own display name / personId, when neither list arrived.
1245
+ *
1246
+ * The fallback ORDER matters for exactly one case: after I clear my own about, the
1247
+ * roster carries nothing but the person table still remembers the last headline I
1248
+ * ever declared. Showing it would tell me I still have a description I just deleted.
1249
+ * So a roster that arrived but says nothing about me is taken at its word —
1250
+ * `roleAbout === ""` with `hasSelf === true` means "cleared", not "unknown".
1251
+ *
1252
+ * @param {object|undefined} group - normalized group.
1253
+ * @param {Array} cards - normalized person table.
1254
+ * @returns {{hasSelf: boolean, personId: string, name: string, about: string, aboutFrom: string, online: boolean, tags: string[]}}
1255
+ * `aboutFrom` is "group" | "person" | "none", which the pane uses to tell a
1256
+ * cleared description from an unset one.
1257
+ */
1258
+ function pickSelf(group, cards) {
1259
+ const result = {
1260
+ hasSelf: false,
1261
+ personId: "",
1262
+ name: "",
1263
+ about: "",
1264
+ aboutFrom: "none",
1265
+ online: false,
1266
+ tags: []
1267
+ };
1268
+
1269
+ const roster = group !== undefined && Array.isArray(group.members) ? group.members : [];
1270
+ // Identity is matched by person id, never by display name: two members of one
1271
+ // group may both be called 我 (or a display name may be edited to match someone
1272
+ // else's), and the wrong match would offer an edit of the wrong profile.
1273
+ const ownId = group !== undefined ? pickString(group.personId) : "";
1274
+ let own = roster.filter(function (member) {
1275
+ return member.self === true;
1276
+ })[0];
1277
+ if (own === undefined && ownId !== "") {
1278
+ own = roster.filter(function (member) {
1279
+ return member.id === ownId;
1280
+ })[0];
1281
+ }
1282
+
1283
+ const selfCard = (Array.isArray(cards) ? cards : []).filter(function (card) {
1284
+ return card.isSelf === true;
1285
+ })[0];
1286
+
1287
+ // Last identity resort, for a Host that ships neither `group.personId` nor a
1288
+ // `self` marker on the roster but does mark its own card: match the roster row
1289
+ // against the card's person id. Still id-based, so it cannot pick the wrong
1290
+ // person the way a name match would.
1291
+ if (own === undefined && selfCard !== undefined && selfCard.id !== "") {
1292
+ own = roster.filter(function (member) {
1293
+ return member.id === selfCard.id;
1294
+ })[0];
1295
+ }
1296
+
1297
+ if (own !== undefined) {
1298
+ result.hasSelf = true;
1299
+ result.personId = own.id;
1300
+ result.name = own.name;
1301
+ result.online = own.online;
1302
+ result.tags = own.tags;
1303
+ if (own.roleAbout !== "") {
1304
+ result.about = own.roleAbout;
1305
+ result.aboutFrom = "group";
1306
+ } else if (own.roleAboutKnown !== true && selfCard !== undefined && selfCard.headline !== "") {
1307
+ // The roster row has no about field at all — it predates `roleAbout`, or
1308
+ // this Host sends it only in the table. The table is the documented default.
1309
+ result.about = selfCard.headline;
1310
+ result.aboutFrom = "person";
1311
+ }
1312
+ // `roleAboutKnown === true` with an empty value means I cleared it. Leaving
1313
+ // `about` empty is the point: backfilling the old headline would tell me the
1314
+ // clear had failed.
1315
+ if (result.name === "" && selfCard !== undefined) result.name = selfCard.name;
1316
+ return result;
1317
+ }
1318
+
1319
+ if (selfCard !== undefined) {
1320
+ result.hasSelf = true;
1321
+ result.personId = selfCard.id;
1322
+ result.name = selfCard.name;
1323
+ result.about = selfCard.headline;
1324
+ result.aboutFrom = selfCard.headline === "" ? "none" : "person";
1325
+ result.online = selfCard.online;
1326
+ result.tags = selfCard.tags;
1327
+ return result;
1328
+ }
1329
+
1330
+ // No roster and no table: the membership row still knows who I am, which is
1331
+ // enough to draw the row and to name the group in the edit instruction.
1332
+ if (group !== undefined && ownId !== "") {
1333
+ result.hasSelf = true;
1334
+ result.personId = ownId;
1335
+ result.name = ownId;
1336
+ }
1337
+ return result;
1338
+ }
1339
+
1340
+ /**
1341
+ * Split a roster into the row order the pane renders.
1342
+ *
1343
+ * ## The order, and why
1344
+ *
1345
+ * 1. 「所有人」 — added by the pane, not part of this function: it is an address, not
1346
+ * a person, and it is always first regardless of who is in the group.
1347
+ * 2. 群主 — the person who can approve and invite. In a collaboration group the
1348
+ * owner is the one you address when something is stuck, so they outrank everyone.
1349
+ * 3. 我 — where am I, and the only row with a second action (edit).
1350
+ * 4. everyone else — online first, then by name.
1351
+ *
1352
+ * Alphabetical order is right only for strangers. In a group you share with eight
1353
+ * people, "who runs this", "where am I" and "who is around" are the questions that
1354
+ * actually get asked.
1355
+ *
1356
+ * ## When I am the owner
1357
+ *
1358
+ * `mine` and `owner` are then the SAME person, and rendering both would show me twice
1359
+ * in a list whose whole purpose is telling me who is here. The row is returned once,
1360
+ * in `owner`, flagged `isSelf` so the pane can label it 「群主 · 我」 and keep the edit
1361
+ * action. When the owner is somebody else, `mine` is me and `owner` is them.
1362
+ *
1363
+ * A group whose owner is not in the roster yields `owner: undefined` — an empty slot
1364
+ * at position 2 would read as a missing person rather than as absent data.
1365
+ *
1366
+ * @param {Array} members - normalized roster.
1367
+ * @param {object} self - output of {@link pickSelf}.
1368
+ * @returns {{owner: object|undefined, mine: object|undefined, others: Array}}
1369
+ * `owner` carries `isSelf` when I run this group. Never returns the same person twice.
1370
+ */
1371
+
1372
+ /**
1373
+ * The instruction that invites one colleague into THIS group.
1374
+ *
1375
+ * Both facts are known here — the group from the pane, the person from the pick — so the
1376
+ * sentence needs no choice from the reader. That is the whole reason the action lives on the
1377
+ * group rather than on the card: an invitation whose subject is ambiguous must not be offered.
1378
+ *
1379
+ * Named by id, not by name: two members of one group can share a display name.
1380
+ *
1381
+ * @param {object} group - the group whose pane this is.
1382
+ * @param {object} person - the colleague to invite.
1383
+ * @returns {string} the text to insert.
1384
+ */
1385
+ function draftGroupInvite(group, person) {
1386
+ const who = (person.name ?? "") === "" ? person.id : person.name;
1387
+ return "用 a2a_person_send 邀请 " + who + "(personId: " + person.id + ")加入「"
1388
+ + (group.name ?? group.id) + "」(groupId: " + group.id + ")。"
1389
+ + "\n(发送后会告诉你对方是否在线;此时在线则立刻送到。)";
1390
+ }
1391
+ function splitRoster(members, self) {
1392
+ const list = Array.isArray(members) ? members : [];
1393
+ const mineId = self !== undefined && self.hasSelf === true ? self.personId : "";
1394
+ const byDefault = function (left, right) {
1395
+ if (left.online !== right.online) return left.online ? -1 : 1;
1396
+ return left.name.localeCompare(right.name, "zh-Hans-CN");
1397
+ };
1398
+
1399
+ let owner;
1400
+ let mine;
1401
+ const others = [];
1402
+
1403
+ for (const member of list) {
1404
+ const isSelf = member.self === true || (mineId !== "" && member.id === mineId);
1405
+ const isOwner = member.role === "owner";
1406
+ if (isOwner && owner === undefined) {
1407
+ // One row only, even when the owner is me. `isSelf` here is what makes the
1408
+ // pane label it 「群主 · 我」 instead of showing a second row below.
1409
+ owner = Object.assign({}, member, { isSelf: isSelf || member.self === true });
1410
+ continue;
1411
+ }
1412
+ if (isSelf && mine === undefined) {
1413
+ // `isSelf` is the flag the row builder keys its behaviour and badges off.
1414
+ // Without it the row would render as an ordinary member and I would lose the
1415
+ // only edit affordance in the pane.
1416
+ mine = Object.assign({}, member, { isSelf: true });
1417
+ continue;
1418
+ }
1419
+ others.push(member);
1420
+ }
1421
+
1422
+ others.sort(byDefault);
1423
+ return { owner: owner, mine: mine, others: others };
1424
+ }
1425
+
1426
+ function groupByRelationship(groups) {
1427
+ const mine = [];
1428
+ const joined = [];
1429
+ const discover = [];
1430
+ for (const group of groups) {
1431
+ if (group.discovered === true) {
1432
+ discover.push(group);
1433
+ } else if (group.role === "owner") {
1434
+ mine.push(group);
1435
+ } else {
1436
+ joined.push(group);
1437
+ }
1438
+ }
1439
+ // Most recently active first inside each section: a directory sorted by name is
1440
+ // useless when what you want is "what is happening".
1441
+ const byActivity = (left, right) => (right.lastTime || 0) - (left.lastTime || 0);
1442
+ mine.sort(byActivity);
1443
+ joined.sort(byActivity);
1444
+ discover.sort(byActivity);
1445
+ return { mine: mine, joined: joined, discover: discover };
1446
+ }
1447
+
1448
+
1449
+ /**
1450
+ * The panel's horizontal tab bar.
1451
+ *
1452
+ * Counts are shown on each tab because "我创建 (0)" is information: it says the
1453
+ * question is answerable, and the answer is none. A tab that vanished when empty
1454
+ * would leave a person wondering whether the feature existed.
1455
+ */
1456
+ function TabBar(props) {
1457
+ return h(
1458
+ "div",
1459
+ {
1460
+ style: {
1461
+ display: "flex",
1462
+ gap: "2px",
1463
+ borderBottom: "1px solid rgba(128, 128, 128, 0.35)",
1464
+ paddingBottom: "0"
1465
+ }
1466
+ },
1467
+ props.tabs.map(function (tab) {
1468
+ const active = props.active === tab.id;
1469
+ return h(
1470
+ "button",
1471
+ {
1472
+ key: tab.id,
1473
+ type: "button",
1474
+ title: tab.label,
1475
+ style: {
1476
+ display: "inline-flex",
1477
+ alignItems: "center",
1478
+ gap: "4px",
1479
+ padding: "6px 8px",
1480
+ border: "none",
1481
+ borderBottom: active
1482
+ ? "2px solid currentColor"
1483
+ : "2px solid transparent",
1484
+ background: "transparent",
1485
+ color: "inherit",
1486
+ font: "inherit",
1487
+ fontSize: "12px",
1488
+ fontWeight: active ? 600 : 400,
1489
+ opacity: active ? 1 : 0.65,
1490
+ cursor: "pointer",
1491
+ whiteSpace: "nowrap"
1492
+ },
1493
+ onClick: function () {
1494
+ props.onSelect(tab.id);
1495
+ }
1496
+ },
1497
+ // Decorative glyph, then the word: the icon speeds up scanning, the
1498
+ // label keeps the tab answerable without hovering.
1499
+ tab.icon === undefined ? null : icon(tab.icon, 12),
1500
+ tab.label,
1501
+ tab.count === undefined
1502
+ ? null
1503
+ : h("span", { style: { opacity: active ? 0.75 : 0.55, marginLeft: "1px" } }, String(tab.count))
1504
+ );
1505
+ })
1506
+ );
1507
+ }
1508
+
1509
+ /**
1510
+ * One colleague's card, in the roster style rather than the group-card style.
1511
+ *
1512
+ * A card answers "who is this and what do they do", so it leads with the name and
1513
+ * the tags rather than with an unread count. Tags are clickable: filtering the
1514
+ * roster by "测试" is the whole point of having tags at all.
1515
+ */
1516
+
1517
+ /**
1518
+ * The instruction that edits a profile, prefilled with the current values.
1519
+ *
1520
+ * Written as a sentence a person would say, because that is what the Session turn will
1521
+ * read: a JSON blob would work but would be something to get wrong rather than something
1522
+ * to edit.
1523
+ *
1524
+ * @param {object} card - the person's card.
1525
+ * @param {string} groupName - which group to act in (the tool needs one for the credential).
1526
+ * @returns {string} the text to insert.
1527
+ */
1528
+ function draftProfileEdit(card, groupName) {
1529
+ const tags = (card.tags ?? []).join("、");
1530
+ const lines = [
1531
+ "帮我更新我在「" + (groupName === "" ? "这个群" : groupName) + "」的名片信息:"
1532
+ ];
1533
+ if ((card.headline ?? "") !== "") lines.push("简介:" + card.headline);
1534
+ if (tags !== "") lines.push("标签:" + tags);
1535
+ if ((card.endpoint ?? "") !== "") lines.push("A2A 地址:" + card.endpoint);
1536
+ // The last line is the actual ask: the reader edits the values above it and sends.
1537
+ lines.push("");
1538
+ lines.push("(改上面的内容后直接发送即可;留空的项会被清空。)");
1539
+ return lines.join("\n") + " ";
1540
+ }
1541
+
1542
+ /**
1543
+ * The instruction that edits MY role in ONE group — the members pane's edit action.
1544
+ *
1545
+ * ## Why an instruction and not a save button
1546
+ *
1547
+ * The browser bundle holds no membership credential. `a2a_group_role` authenticates
1548
+ * with `{personId, personKey}`, and that key lives in `memberships.json` on the Host
1549
+ * (0600) and is never published into the page — `lib/index.js` publishes only the
1550
+ * read-only snapshot. A form that POSTed from here would have to either carry the
1551
+ * key into the DOM or invent a second write channel to the Host; both are worse than
1552
+ * the thing they replace. One write path (the Session turn, which already holds the
1553
+ * credential) is also why the panel and the assistant cannot disagree about what
1554
+ * was written.
1555
+ *
1556
+ * So the click writes a prefilled instruction into the composer. The reader edits
1557
+ * the sentence and presses Enter; the assistant calls `a2a_group_role` with the
1558
+ * values that are now visible in the draft. History, validation and error reporting
1559
+ * are the turn's, which means the failure message ("about is too long") appears in
1560
+ * the conversation next to the thing that caused it rather than in a toast that
1561
+ * cannot say what to do about it.
1562
+ *
1563
+ * `groupId` is included because the tool requires it and the name alone is ambiguous
1564
+ * — two groups may share a display name, and the assistant must not have to guess.
1565
+ * Both are stated as facts the reader can check, not as a JSON payload: an editable
1566
+ * sentence is something a person can correct, and a payload is something to get wrong.
1567
+ *
1568
+ * @param {object} input
1569
+ * @param {string} input.groupName - display name, for the sentence.
1570
+ * @param {string} input.groupId - the tool's required key.
1571
+ * @param {string} [input.about] - current text, prefilled so a small edit stays small.
1572
+ * @param {string[]} [input.tags] - current tags.
1573
+ * @returns {string} text to insert into the composer.
1574
+ */
1575
+ /**
1576
+ * Write a profile through the Host, over DSH's Connection RPC.
1577
+ *
1578
+ * Lazy and cached: the module is resolved on first save rather than at render, so a host without
1579
+ * the Connection plugin shows "saving is unavailable" instead of breaking the pane.
1580
+ *
1581
+ * @returns {object|undefined} the RPC client, or undefined when unavailable.
1582
+ */
1583
+ /**
1584
+ * Caller into the Host, installed once by apply().
1585
+ *
1586
+ * The browser half of Connection publishes `ctx.connection.rpc`, and that is the same service object
1587
+ * the Host half registers channels on. Handing the call function down as a prop keeps this file free of
1588
+ * any service lookup at render time, which is where a pane must not throw.
1589
+ *
1590
+ * @type {((channel: string, endpoint: string, payload: unknown) => Promise<object>)|undefined}
1591
+ */
1592
+ let panelRpcCaller;
1593
+
1594
+ /** Why the caller is unavailable, for the self-check to show instead of a silent failure. */
1595
+ let panelRpcError = "";
1596
+
1597
+ /**
1598
+ * Install the Connection caller from the plugin's context.
1599
+ *
1600
+ * Called from apply(), where the service exists. Records the reason when it does not, because a panel
1601
+ * that says "unavailable" without saying why costs a debugging session per occurrence.
1602
+ *
1603
+ * @param {object} ctx - plugin context.
1604
+ */
1605
+ function installPanelRpc(ctx) {
1606
+ try {
1607
+ const connection = ctx !== null && ctx !== undefined && typeof ctx.get === "function"
1608
+ ? ctx.get("connection")
1609
+ : undefined;
1610
+ if (connection !== undefined && connection !== null && connection.rpc !== undefined
1611
+ && typeof connection.rpc.call === "function") {
1612
+ panelRpcCaller = function (channel, endpoint, payload) {
1613
+ return connection.rpc.call(channel, endpoint, payload);
1614
+ };
1615
+ panelRpcError = "";
1616
+ return;
1617
+ }
1618
+ panelRpcError = "connection service has no rpc.call";
1619
+ } catch (error) {
1620
+ panelRpcError = String((error && error.message) || error);
1621
+ }
1622
+ }
1623
+
1624
+ /**
1625
+ * Save one profile field through the Host.
1626
+ *
1627
+ * Resolves to `{ ok, message }` rather than throwing: the caller is a button handler, and a
1628
+ * rejected promise there would surface as silence.
1629
+ *
1630
+ * @param {object} input - `{ groupId, sessionId, about?, tags?, headline? }`.
1631
+ * @returns {Promise<{ok: boolean, message: string}>} the outcome, phrased for a person.
1632
+ */
1633
+ async function saveProfile(input) {
1634
+ if (panelRpcCaller === undefined) {
1635
+ return { ok: false, message: "面板写入通道不可用(" + (panelRpcError === "" ? "未安装" : panelRpcError) + ")" };
1636
+ }
1637
+ try {
1638
+ const result = await panelRpcCaller("/a2a-panel", "profile", input);
1639
+ if (result !== null && result !== undefined && result.ok === true) {
1640
+ return { ok: true, message: "已保存" };
1641
+ }
1642
+ const code = result && result.error ? result.error.code : "unknown";
1643
+ return { ok: false, message: "保存失败:" + code };
1644
+ } catch (error) {
1645
+ return { ok: false, message: "保存失败:" + String((error && error.message) || error) };
1646
+ }
1647
+ }
1648
+
1649
+ /**
1650
+ * Call the Host's `ping` endpoint and report what came back.
1651
+ *
1652
+ * Uses the same client as `saveProfile`, on purpose: a probe that takes a different path proves
1653
+ * nothing about the path the feature will use.
1654
+ *
1655
+ * @returns {Promise<string>} a line to show in the pane.
1656
+ */
1657
+ async function panelSelfCheck() {
1658
+ if (panelRpcCaller === undefined) {
1659
+ return "面板通道不可用:" + (panelRpcError === "" ? "未安装" : panelRpcError);
1660
+ }
1661
+ try {
1662
+ const result = await panelRpcCaller("/a2a-panel", "ping", { at: Date.now() });
1663
+ if (result !== null && result !== undefined && result.ok === true) {
1664
+ const value = result.value ?? {};
1665
+ return "面板通道正常(" + String(value.at ?? "?") + ")";
1666
+ }
1667
+ return "面板通道返回失败:" + String(result && result.error ? result.error.code : "?");
1668
+ } catch (error) {
1669
+ return "面板通道异常:" + String((error && error.message) || error);
1670
+ }
1671
+ }
1672
+
1673
+ /**
1674
+ * Inline editor for my group role: a description and tags, saved in place.
1675
+ *
1676
+ * Two fields rather than one, because they answer different questions — "what do I do here" and
1677
+ * "what should people search for to find me" — and the Host stores them separately.
1678
+ *
1679
+ * @param {object} props - `{ groupId, sessionId, about, tags, onDone, onCancel }`.
1680
+ * @returns {object} the editor.
1681
+ */
1682
+ function RoleEditor(props) {
1683
+ const [about, setAbout] = react.useState(props.about ?? "");
1684
+ const [tags, setTags] = react.useState((props.tags ?? []).join("、"));
1685
+ // The person-level headline. Different scope from `about`: that one is this membership, this one
1686
+ // follows the person into every group and onto every colleague card.
1687
+ const [headline, setHeadline] = react.useState(props.headline ?? "");
1688
+ const [state, setState] = react.useState("idle");
1689
+ const [message, setMessage] = react.useState("");
1690
+
1691
+ /** Save both fields; report the outcome inline rather than in an alert. */
1692
+ function save() {
1693
+ setState("saving");
1694
+ setMessage("");
1695
+ // Tags are typed as a separated list because that is how a person thinks of them; the Host
1696
+ // stores an array.
1697
+ const parsed = String(tags ?? "")
1698
+ .split(/[、,,\s]+/u)
1699
+ .map((tag) => tag.trim())
1700
+ .filter((tag) => tag !== "");
1701
+ saveProfile({
1702
+ groupId: props.groupId,
1703
+ sessionId: props.sessionId,
1704
+ about: String(about ?? "").trim(),
1705
+ tags: parsed,
1706
+ // Omitted when untouched rather than sent empty: an absent field means "leave it alone",
1707
+ // and a cleared one would wipe the person's headline on every role edit.
1708
+ headline: String(headline ?? "").trim() === String(props.headline ?? "").trim()
1709
+ ? undefined
1710
+ : String(headline ?? "").trim(),
1711
+ }).then(function (result) {
1712
+ setState(result.ok ? "done" : "failed");
1713
+ setMessage(result.message);
1714
+ if (result.ok && typeof props.onSaved === "function") props.onSaved();
1715
+ });
1716
+ }
1717
+
1718
+ return h(
1719
+ "div",
1720
+ { style: membersLayout.editorBox },
1721
+ // Scope stated in the label, not only in a placeholder: editing the wrong one produces a
1722
+ // change that does not show up where the reader expects, and the difference is invisible in a
1723
+ // screenshot.
1724
+ h("span", { style: { fontSize: "10px", opacity: 0.6 } }, "个人介绍(所有群共用,显示在同事名片上)"),
1725
+ h("input", {
1726
+ type: "text",
1727
+ value: headline,
1728
+ placeholder: "我是谁,例如:后端工程师,专注对账系统",
1729
+ onChange: function (event) {
1730
+ setHeadline(event !== null && event !== undefined && event.target !== undefined ? event.target.value : "");
1731
+ },
1732
+ style: membersLayout.editorInput
1733
+ }),
1734
+ h("span", { style: { fontSize: "10px", opacity: 0.6 } }, "群内介绍(只在这个群生效)"),
1735
+ h("input", {
1736
+ type: "text",
1737
+ value: about,
1738
+ placeholder: "我在这个群负责什么,例如:负责对账与发票生成",
1739
+ onChange: function (event) {
1740
+ setAbout(event !== null && event !== undefined && event.target !== undefined ? event.target.value : "");
1741
+ },
1742
+ style: membersLayout.editorInput
1743
+ }),
1744
+ h("input", {
1745
+ type: "text",
1746
+ value: tags,
1747
+ placeholder: "标签,用顿号或逗号分隔,例如:研发、后端",
1748
+ onChange: function (event) {
1749
+ setTags(event !== null && event !== undefined && event.target !== undefined ? event.target.value : "");
1750
+ },
1751
+ style: membersLayout.editorInput
1752
+ }),
1753
+ h(
1754
+ "div",
1755
+ { style: { display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" } },
1756
+ h(
1757
+ "button",
1758
+ {
1759
+ type: "button",
1760
+ disabled: state === "saving",
1761
+ title: "保存到群宿主,立即对所有成员生效",
1762
+ style: Object.assign({}, membersLayout.editorSave, state === "saving" ? { opacity: 0.5 } : {}),
1763
+ onClick: save
1764
+ },
1765
+ state === "saving" ? "保存中…" : "保存"
1766
+ ),
1767
+ h(
1768
+ "button",
1769
+ {
1770
+ type: "button",
1771
+ title: "放弃修改",
1772
+ style: membersLayout.editorCancel,
1773
+ onClick: function () { if (typeof props.onCancel === "function") props.onCancel(); }
1774
+ },
1775
+ "取消"
1776
+ ),
1777
+ message === ""
1778
+ ? null
1779
+ : h(
1780
+ "span",
1781
+ { style: { fontSize: "10px", opacity: 0.75, color: state === "failed" ? "#c0392b" : "inherit" } },
1782
+ message
1783
+ )
1784
+ )
1785
+ );
1786
+ }
1787
+
1788
+ function draftGroupRoleEdit(input) {
1789
+ const given = input === null || input === undefined ? {} : input;
1790
+ const groupName = pickString(given.groupName);
1791
+ const groupId = pickString(given.groupId);
1792
+ const about = pickString(given.about);
1793
+ const tags = (Array.isArray(given.tags) ? given.tags : []).filter(function (tag) {
1794
+ return typeof tag === "string" && tag !== "";
1795
+ }).join("、");
1796
+
1797
+ const lines = [
1798
+ "用 a2a_group_role 更新我在「" + (groupName === "" ? "这个群" : groupName) + "」的群内介绍" + (groupId === "" ? "" : "(groupId: " + groupId + ")") + ":"
1799
+ ];
1800
+ lines.push("about:" + about);
1801
+ if (tags !== "") lines.push("tags:" + tags);
1802
+ lines.push("");
1803
+ lines.push("(改上面的内容后直接发送即可。about 是一句话、最多 120 字;清空 about 就是删掉这条介绍。)");
1804
+ return lines.join("\n") + " ";
1805
+ }
1806
+
1807
+ /**
1808
+ * The instruction that removes MY role in one group.
1809
+ *
1810
+ * Separate from {@link draftGroupRoleEdit} because "clear" is the one edit a reader
1811
+ * cannot express by editing text in place: deleting the line `about:` reads as
1812
+ * "leave it alone" to a careful reader (the tool leaves a field unchanged when it is
1813
+ * omitted), so the intent has to be stated rather than implied.
1814
+ *
1815
+ * @param {{groupName?: string, groupId?: string}} input
1816
+ * @returns {string} text to insert into the composer.
1817
+ */
1818
+ function draftGroupRoleClear(input) {
1819
+ const given = input === null || input === undefined ? {} : input;
1820
+ const groupName = pickString(given.groupName);
1821
+ const groupId = pickString(given.groupId);
1822
+ return "用 a2a_group_role 把我在「" + (groupName === "" ? "这个群" : groupName) + "」的群内介绍清空"
1823
+ + (groupId === "" ? "" : "(groupId: " + groupId + ")")
1824
+ + ":about 传空字符串,tags 传空数组。发送即可。";
1825
+ }
1826
+
1827
+ function ColleagueCard(props) {
1828
+ const card = props.card;
1829
+ return h(
1830
+ "div",
1831
+ {
1832
+ style: {
1833
+ display: "flex",
1834
+ flexDirection: "column",
1835
+ gap: "3px",
1836
+ padding: "8px 10px",
1837
+ // Mine is outlined: the list is otherwise read-only, and the one card the
1838
+ // reader owns should not look like the ones they cannot touch.
1839
+ border: card.isSelf === true
1840
+ ? "1px solid currentColor"
1841
+ : "1px solid rgba(128, 128, 128, 0.35)",
1842
+ borderRadius: "6px"
1843
+ }
1844
+ },
1845
+ h(
1846
+ "div",
1847
+ { style: { display: "flex", alignItems: "baseline", gap: "6px" } },
1848
+ h("span", { style: { fontWeight: 600, fontSize: "13px" } }, card.name),
1849
+ card.isSelf === true
1850
+ ? h(
1851
+ "span",
1852
+ {
1853
+ style: {
1854
+ padding: "0 5px",
1855
+ borderRadius: "3px",
1856
+ fontSize: "10px",
1857
+ // Same inverted-chip pair as membersLayout.selfTag; currentColor here produced a
1858
+ // near-white pill with white text in a dark theme. See the note there.
1859
+ background: "light-dark(#1a1a1a, #f2f2f2)",
1860
+ color: "light-dark(#f2f2f2, #1a1a1a)"
1861
+ },
1862
+ title: "这是你自己的名片,只有你能改"
1863
+ },
1864
+ "我"
1865
+ )
1866
+ : null,
1867
+ card.headline !== ""
1868
+ ? h("span", { style: { fontSize: "11px", opacity: 0.7 } }, card.headline)
1869
+ : null
1870
+ ),
1871
+ card.tags.length === 0
1872
+ ? null
1873
+ : h(
1874
+ "div",
1875
+ { style: { display: "flex", flexWrap: "wrap", gap: "3px" } },
1876
+ card.tags.map(function (tag) {
1877
+ return h(
1878
+ "button",
1879
+ {
1880
+ key: tag,
1881
+ type: "button",
1882
+ title: "只看标签「" + tag + "」的同事",
1883
+ style: {
1884
+ padding: "1px 7px",
1885
+ border: "1px solid rgba(128, 128, 128, 0.5)",
1886
+ borderRadius: "999px",
1887
+ background: "transparent",
1888
+ color: "inherit",
1889
+ font: "inherit",
1890
+ fontSize: "11px",
1891
+ cursor: "pointer",
1892
+ opacity: 0.85
1893
+ },
1894
+ onClick: function () {
1895
+ props.onPickTag(tag);
1896
+ }
1897
+ },
1898
+ tag
1899
+ );
1900
+ })
1901
+ ),
1902
+ // Where to find them, by name. The group COUNT used to live here, which told the
1903
+ // reader nothing: they are looking at the card from inside a group they already
1904
+ // share, so the number restates the premise. Names answer the real question —
1905
+ // which context to approach this person in.
1906
+ // Status, then context. The dot alone is decoration — "在线" has to be legible at a
1907
+ // glance, because its whole job is to answer "will they see this now".
1908
+ h(
1909
+ "div",
1910
+ { style: { display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" } },
1911
+ h("span", {
1912
+ style: {
1913
+ width: "6px",
1914
+ height: "6px",
1915
+ borderRadius: "50%",
1916
+ flex: "0 0 auto",
1917
+ background: card.online
1918
+ ? "#3fb950"
1919
+ : "rgba(128, 128, 128, 0.5)"
1920
+ },
1921
+ "aria-hidden": "true"
1922
+ }),
1923
+ h(
1924
+ "span",
1925
+ {
1926
+ style: {
1927
+ fontSize: "11px",
1928
+ opacity: card.online ? 0.8 : 0.55,
1929
+ color: card.online ? "#3fb950" : "inherit"
1930
+ },
1931
+ title: card.online
1932
+ ? "对方正开着 DSH,发消息会立即送到"
1933
+ : "对方当前不在线,消息会存着等他上线"
1934
+ },
1935
+ card.online ? "在线" : "不在线"
1936
+ ),
1937
+ card.groups.length === 0
1938
+ ? null
1939
+ : h(
1940
+ "span",
1941
+ { style: { fontSize: "11px", opacity: 0.6 } },
1942
+ "· " + card.groups.slice(0, 3).join("、")
1943
+ + (card.groups.length > 3 ? " 等 " + String(card.groups.length) + " 个群" : "")
1944
+ )
1945
+ ),
1946
+ // The edit affordance. The panel cannot write a profile itself — it renders before
1947
+ // any Session exists and holds no member credential — so it does the part it CAN
1948
+ // do: put a prefilled instruction in the composer, so editing is "change the words
1949
+ // and press enter" rather than "know the incantation".
1950
+ card.isSelf === true
1951
+ ? h(
1952
+ "div",
1953
+ { style: { display: "flex", alignItems: "center", gap: "6px", marginTop: "2px" } },
1954
+ h(
1955
+ "button",
1956
+ {
1957
+ type: "button",
1958
+ title: "把编辑指令填进输入框,改完发送即可",
1959
+ style: {
1960
+ padding: "2px 8px",
1961
+ border: "1px solid currentColor",
1962
+ borderRadius: "4px",
1963
+ background: "transparent",
1964
+ color: "inherit",
1965
+ font: "inherit",
1966
+ fontSize: "11px",
1967
+ cursor: "pointer"
1968
+ },
1969
+ onClick: function () {
1970
+ // Replace rather than append: the draft is being replaced with an
1971
+ // instruction, and appending to whatever was typed would produce a
1972
+ // sentence nobody wrote.
1973
+ const text = draftProfileEdit(card, props.groupName ?? "");
1974
+ const actions = props.inputActions;
1975
+ if (actions !== null && actions !== undefined
1976
+ && typeof actions.setDraft === "function") {
1977
+ actions.setDraft(text);
1978
+ } else if (actions !== null && actions !== undefined
1979
+ && typeof actions.captureInsertion === "function"
1980
+ && typeof actions.insertText === "function") {
1981
+ actions.insertText(text, actions.captureInsertion());
1982
+ }
1983
+ }
1984
+ },
1985
+ "改我的名片"
1986
+ ),
1987
+ h("span", { style: { fontSize: "10px", opacity: 0.5 } }, "点一下把指令填进输入框")
1988
+ )
1989
+ : null
1990
+ );
1991
+ }
1992
+
1993
+ /** A tag chip row: "all" plus every tag present in the roster. */
1994
+ function TagFilter(props) {
1995
+ return h(
1996
+ "div",
1997
+ { style: { display: "flex", flexWrap: "wrap", gap: "4px" } },
1998
+ [ "全部" ].concat(props.tags).map(function (tag, index) {
1999
+ const value = index === 0 ? "" : tag;
2000
+ const active = props.active === value;
2001
+ return h(
2002
+ "button",
2003
+ {
2004
+ key: tag,
2005
+ type: "button",
2006
+ style: {
2007
+ padding: "2px 8px",
2008
+ border: "1px solid rgba(128, 128, 128, 0.5)",
2009
+ borderRadius: "999px",
2010
+ background: active ? "light-dark(#1a1a1a, #f2f2f2)" : "transparent",
2011
+ color: active ? "light-dark(#f2f2f2, #1a1a1a)" : "inherit",
2012
+ font: "inherit",
2013
+ fontSize: "11px",
2014
+ cursor: "pointer"
2015
+ },
2016
+ onClick: function () {
2017
+ props.onSelect(value);
2018
+ }
2019
+ },
2020
+ tag
2021
+ );
2022
+ })
2023
+ );
2024
+ }
2025
+
2026
+ /**
2027
+ * One titled section of the panel.
2028
+ *
2029
+ * Rendered only when non-empty. `hint` carries the one line that explains why the
2030
+ * section exists ("你是群主" / "还没加入"), because a heading alone leaves a person
2031
+ * guessing why two groups with the same shape sit in different places.
2032
+ */
2033
+ function GroupSection(props) {
2034
+ if (props.groups.length === 0) return null;
2035
+ return h(
2036
+ "div",
2037
+ { style: { display: "flex", flexDirection: "column", gap: "6px" } },
2038
+ h(
2039
+ "div",
2040
+ { style: { display: "flex", alignItems: "baseline", gap: "8px" } },
2041
+ h("p", { style: S.sectionLabel }, props.title),
2042
+ h("span", { style: S.hint }, String(props.groups.length)),
2043
+ props.hint ? h("span", { style: S.hint }, props.hint) : null
2044
+ ),
2045
+ props.groups.map((group) =>
2046
+ h(GroupCard, {
2047
+ key: group.id,
2048
+ group: group,
2049
+ open: props.expanded[group.id] === true,
2050
+ onToggle: () => {
2051
+ props.onToggle(group.id);
2052
+ }
2053
+ })
2054
+ )
2055
+ );
2056
+ }
2057
+
2058
+
2059
+
2060
+
2061
+ /**
2062
+ * Layout for the right-column members pane.
2063
+ *
2064
+ * ## Why nothing here sets `maxWidth`
2065
+ *
2066
+ * The right column is user-resizable. An earlier version bounded the pane at 300px,
2067
+ * which is correct at the default width and wrong everywhere else: widen the column
2068
+ * and the roster stays pinned to the left of an ever-larger empty gutter. A pane that
2069
+ * fills its column is correct at every width, so the constraint moves from the
2070
+ * container to the *text*: each row is `flex` with `minWidth: 0`, so a name takes the
2071
+ * space its column has and ellipsizes only when that space genuinely runs out.
2072
+ *
2073
+ * ## Why `height` is absent, and why every column child is `flex: 0 0 auto`
2074
+ *
2075
+ * Two independent faults pushed the roster to the bottom of a mostly empty column:
2076
+ *
2077
+ * 1. `height: 100%` resolved against a parent whose height is not definite (the tab
2078
+ * seat between the slot owner and this pane has no fixed height), so the pane
2079
+ * became taller than its content and left the slack at the bottom;
2080
+ * 2. `groupName` carried `flex: 1 1 auto`, written when it was meant to stretch in a
2081
+ * ROW. As a direct child of this column container, `flex-grow` applies to the
2082
+ * BLOCK axis, so the group name inflated and pushed everything below it down.
2083
+ *
2084
+ * The fix is the layout itself, not a tuned height: the pane sizes to its content
2085
+ * (`height: auto`) and every direct child is `flex: 0 0 auto`, so no child can grow
2086
+ * and swallow the slack. Scrolling is the parent's job — one scroll container in the
2087
+ * chain, at the level that actually knows its height.
2088
+ */
2089
+ const membersLayout = {
2090
+ // The inline role editor: a box that appears under my row, in the roster's own visual language so
2091
+ // it reads as part of that row rather than as a separate screen.
2092
+ editorBox: {
2093
+ display: "flex",
2094
+ flexDirection: "column",
2095
+ gap: "5px",
2096
+ margin: "4px 0 6px",
2097
+ padding: "8px",
2098
+ border: "1px solid rgba(128, 128, 128, 0.4)",
2099
+ borderRadius: "6px",
2100
+ background: "transparent"
2101
+ },
2102
+ editorInput: {
2103
+ width: "100%",
2104
+ boxSizing: "border-box",
2105
+ padding: "4px 7px",
2106
+ border: "1px solid rgba(128, 128, 128, 0.35)",
2107
+ borderRadius: "4px",
2108
+ background: "transparent",
2109
+ color: "inherit",
2110
+ font: "inherit",
2111
+ fontSize: "11px",
2112
+ colorScheme: "light dark"
2113
+ },
2114
+ // Solid border and full-strength text: the one action that commits, distinguished by contrast
2115
+ // rather than by a colour that would only work on one theme.
2116
+ editorSave: {
2117
+ padding: "3px 12px",
2118
+ border: "1px solid currentColor",
2119
+ borderRadius: "4px",
2120
+ background: "transparent",
2121
+ color: "inherit",
2122
+ font: "inherit",
2123
+ fontSize: "11px",
2124
+ fontWeight: 600,
2125
+ cursor: "pointer"
2126
+ },
2127
+ editorCancel: {
2128
+ padding: "3px 10px",
2129
+ border: "1px solid rgba(128, 128, 128, 0.4)",
2130
+ borderRadius: "4px",
2131
+ background: "transparent",
2132
+ color: "inherit",
2133
+ font: "inherit",
2134
+ fontSize: "11px",
2135
+ opacity: 0.75,
2136
+ cursor: "pointer"
2137
+ },
2138
+
2139
+ // A fixed overlay: the pane clips its own overflow, so a dialog parented inside it would be cut
2140
+ // off at the pane's edge and the search box would appear to be missing.
2141
+ dialogOverlay: {
2142
+ position: "fixed",
2143
+ inset: "0",
2144
+ // Translucent black works on both themes: it darkens without asserting a palette.
2145
+ background: "rgba(0, 0, 0, 0.35)",
2146
+ display: "flex",
2147
+ alignItems: "center",
2148
+ justifyContent: "center",
2149
+ zIndex: 9999
2150
+ },
2151
+ dialogPanel: {
2152
+ display: "flex",
2153
+ flexDirection: "column",
2154
+ gap: "8px",
2155
+ width: "min(420px, calc(100vw - 32px))",
2156
+ maxHeight: "min(460px, calc(100vh - 64px))",
2157
+ padding: "14px",
2158
+ // `Canvas` is the system colour for a window surface: it resolves to white on a light theme
2159
+ // and near-black on a dark one without this file knowing which is active. The previous
2160
+ // version guessed a dark hex behind a variable that does not exist, so on a light theme it
2161
+ // drew dark grey on black.
2162
+ background: "Canvas",
2163
+ color: "CanvasText",
2164
+ // Two borders: a hairline for shape, and a soft one for separation on themes where the panel
2165
+ // colour and the page colour are nearly the same.
2166
+ border: "1px solid rgba(128, 128, 128, 0.35)",
2167
+ borderRadius: "8px",
2168
+ boxShadow: "0 12px 32px rgba(0, 0, 0, 0.28)",
2169
+ boxSizing: "border-box",
2170
+ overflow: "hidden",
2171
+ // Tells the browser to render form controls and scrollbars for whichever theme is active.
2172
+ colorScheme: "light dark"
2173
+ },
2174
+ dialogHeader: {
2175
+ display: "flex",
2176
+ alignItems: "center",
2177
+ justifyContent: "space-between",
2178
+ gap: "8px"
2179
+ },
2180
+ dialogClose: {
2181
+ padding: "0 4px",
2182
+ border: "none",
2183
+ background: "transparent",
2184
+ color: "inherit",
2185
+ font: "inherit",
2186
+ fontSize: "14px",
2187
+ lineHeight: 1,
2188
+ cursor: "pointer",
2189
+ opacity: 0.6
2190
+ },
2191
+ dialogSearch: {
2192
+ width: "100%",
2193
+ boxSizing: "border-box",
2194
+ padding: "6px 8px",
2195
+ border: "1px solid rgba(128, 128, 128, 0.4)",
2196
+ borderRadius: "5px",
2197
+ background: "transparent",
2198
+ color: "inherit",
2199
+ font: "inherit",
2200
+ fontSize: "12px",
2201
+ // Without this the placeholder uses a browser default that ignores the theme.
2202
+ colorScheme: "light dark"
2203
+ },
2204
+ // Scrolls on its own: the header and the search stay put while the list moves, which is what
2205
+ // makes a long result set usable in a short dialog.
2206
+ dialogList: {
2207
+ display: "flex",
2208
+ flexDirection: "column",
2209
+ gap: "2px",
2210
+ overflowY: "auto",
2211
+ minHeight: 0
2212
+ },
2213
+ dialogItem: {
2214
+ display: "flex",
2215
+ alignItems: "baseline",
2216
+ gap: "8px",
2217
+ width: "100%",
2218
+ padding: "6px 8px",
2219
+ border: "1px solid transparent",
2220
+ borderRadius: "5px",
2221
+ background: "transparent",
2222
+ color: "inherit",
2223
+ font: "inherit",
2224
+ textAlign: "left",
2225
+ cursor: "pointer"
2226
+ },
2227
+ // The pane's single, quiet entry point into the dialog.
2228
+ inviteButton: {
2229
+ display: "inline-flex",
2230
+ alignItems: "center",
2231
+ gap: "5px",
2232
+ alignSelf: "flex-start",
2233
+ padding: "3px 10px",
2234
+ border: "1px dashed rgba(128, 128, 128, 0.5)",
2235
+ borderRadius: "5px",
2236
+ background: "transparent",
2237
+ color: "inherit",
2238
+ font: "inherit",
2239
+ fontSize: "11px",
2240
+ cursor: "pointer"
2241
+ },
2242
+
2243
+ // The unbound pane: an explainer with one action, not a wall of text. Generous line-height
2244
+ // because the copy is read once, carefully, by somebody who does not yet know what a
2245
+ // collaboration group is.
2246
+ unboundBody: {
2247
+ display: "flex",
2248
+ flexDirection: "column",
2249
+ gap: "6px",
2250
+ paddingTop: "2px"
2251
+ },
2252
+ unboundLead: {
2253
+ margin: 0,
2254
+ fontSize: "12px",
2255
+ lineHeight: 1.65,
2256
+ opacity: 0.78
2257
+ },
2258
+ // Step headings are numbered in the text, so the style only has to make them stand out
2259
+ // without implying a required order.
2260
+ unboundStep: {
2261
+ marginTop: "4px",
2262
+ fontSize: "12px",
2263
+ fontWeight: 600,
2264
+ opacity: 0.9
2265
+ },
2266
+ unboundHint: {
2267
+ margin: 0,
2268
+ fontSize: "11px",
2269
+ lineHeight: 1.6,
2270
+ opacity: 0.62
2271
+ },
2272
+ unboundInput: {
2273
+ width: "100%",
2274
+ boxSizing: "border-box",
2275
+ padding: "5px 7px",
2276
+ border: "1px solid rgba(128, 128, 128, 0.35)",
2277
+ borderRadius: "4px",
2278
+ background: "transparent",
2279
+ color: "inherit",
2280
+ font: "inherit",
2281
+ fontSize: "11px",
2282
+ resize: "vertical"
2283
+ },
2284
+ unboundButton: {
2285
+ alignSelf: "flex-start",
2286
+ padding: "3px 10px",
2287
+ border: "1px solid currentColor",
2288
+ borderRadius: "4px",
2289
+ background: "transparent",
2290
+ color: "inherit",
2291
+ font: "inherit",
2292
+ fontSize: "11px",
2293
+ cursor: "pointer"
2294
+ },
2295
+ // Dimmed rather than hidden while the box is empty: a button that disappears teaches the
2296
+ // reader nothing about why.
2297
+ unboundButtonOff: {
2298
+ opacity: 0.4,
2299
+ cursor: "default",
2300
+ borderColor: "rgba(128, 128, 128, 0.5)"
2301
+ },
2302
+ unboundFootnote: {
2303
+ marginTop: "6px",
2304
+ paddingTop: "6px",
2305
+ borderTop: "1px solid rgba(128, 128, 128, 0.35)",
2306
+ fontSize: "10px",
2307
+ lineHeight: 1.6,
2308
+ opacity: 0.5
2309
+ },
2310
+
2311
+ // The invite area sits below the roster, separated by a rule so it does not read as another
2312
+ // member row — an invitee is not in the group yet.
2313
+ inviteBlock: {
2314
+ display: "flex",
2315
+ flexDirection: "column",
2316
+ gap: "4px",
2317
+ marginTop: "6px",
2318
+ paddingTop: "6px",
2319
+ borderTop: "1px solid rgba(128, 128, 128, 0.35)"
2320
+ },
2321
+ inviteLabel: {
2322
+ fontSize: "11px",
2323
+ opacity: 0.6
2324
+ },
2325
+ // A chip rather than a button: several colleagues may be listed, and a row of full-width
2326
+ // buttons would push the roster off the panel.
2327
+ inviteChip: {
2328
+ padding: "2px 8px",
2329
+ border: "1px dashed rgba(128, 128, 128, 0.5)",
2330
+ borderRadius: "999px",
2331
+ background: "transparent",
2332
+ color: "inherit",
2333
+ font: "inherit",
2334
+ fontSize: "11px",
2335
+ cursor: "pointer",
2336
+ maxWidth: "100%",
2337
+ overflow: "hidden",
2338
+ textOverflow: "ellipsis",
2339
+ whiteSpace: "nowrap"
2340
+ },
2341
+
2342
+ root: {
2343
+ display: "flex",
2344
+ flexDirection: "column",
2345
+ gap: "6px",
2346
+ padding: "10px 10px 14px",
2347
+ width: "100%",
2348
+ minWidth: "0",
2349
+ // No `height`. `auto` is what makes the content start at the top.
2350
+ height: "auto",
2351
+ boxSizing: "border-box",
2352
+ fontSize: "12px"
2353
+ },
2354
+ header: {
2355
+ display: "flex",
2356
+ alignItems: "center",
2357
+ justifyContent: "space-between",
2358
+ gap: "6px",
2359
+ flex: "0 0 auto",
2360
+ minWidth: "0",
2361
+ paddingBottom: "6px",
2362
+ borderBottom: "1px solid rgba(128, 128, 128, 0.35)"
2363
+ },
2364
+ headerTitle: {
2365
+ display: "inline-flex",
2366
+ alignItems: "center",
2367
+ gap: "5px",
2368
+ flex: "0 0 auto",
2369
+ fontSize: "11px",
2370
+ letterSpacing: "0.04em",
2371
+ opacity: 0.6
2372
+ },
2373
+ headerMeta: {
2374
+ flex: "0 1 auto",
2375
+ minWidth: "0",
2376
+ overflow: "hidden",
2377
+ textOverflow: "ellipsis",
2378
+ whiteSpace: "nowrap",
2379
+ fontSize: "11px",
2380
+ opacity: 0.6
2381
+ },
2382
+ /* The group name is a heading, so it truncates rather than wraps: two lines of
2383
+ group name above a roster costs a row of people for no new information.
2384
+ `0 0 auto` — it must never grow along the column axis (see root's comment). */
2385
+ groupName: {
2386
+ margin: 0,
2387
+ flex: "0 0 auto",
2388
+ minWidth: "0",
2389
+ overflow: "hidden",
2390
+ textOverflow: "ellipsis",
2391
+ whiteSpace: "nowrap",
2392
+ fontSize: "12px",
2393
+ fontWeight: 600,
2394
+ opacity: 0.9
2395
+ },
2396
+ sectionLabel: {
2397
+ flex: "0 0 auto",
2398
+ fontSize: "11px",
2399
+ letterSpacing: "0.04em",
2400
+ opacity: 0.45,
2401
+ margin: "2px 0 0"
2402
+ },
2403
+ /* The roster list. `flex: 0 0 auto` so it sits at its natural height instead of
2404
+ absorbing the column's leftover space. */
2405
+ roster: {
2406
+ display: "flex",
2407
+ flexDirection: "column",
2408
+ gap: "1px",
2409
+ flex: "0 0 auto",
2410
+ minWidth: "0"
2411
+ },
2412
+ /*
2413
+ * 「所有人」 is a member row, not a banner.
2414
+ *
2415
+ * It shares {@link personRow} with everybody else — same height, same padding, same
2416
+ * font — because that is what @所有人 looks like in WeChat / Feishu / Slack: the
2417
+ * first row of the roster with a special name. An earlier version gave it a dashed
2418
+ * border, which made it read as a dropdown or a button rather than as a row.
2419
+ *
2420
+ * The only difference is the leading glyph: an `@` instead of a presence dot. That
2421
+ * is enough of a cue, and it says "broadcast" without a second visual language.
2422
+ */
2423
+ personLead: {
2424
+ flex: "0 0 auto",
2425
+ width: "14px",
2426
+ display: "inline-flex",
2427
+ alignItems: "center",
2428
+ justifyContent: "center",
2429
+ alignSelf: "flex-start",
2430
+ marginTop: "1px",
2431
+ opacity: 0.75
2432
+ },
2433
+ /* A person row: name line on top, group role underneath. Button reset in full so
2434
+ it inherits the theme's type instead of the platform form-control styling. */
2435
+ personRow: {
2436
+ display: "flex",
2437
+ alignItems: "flex-start",
2438
+ gap: "6px",
2439
+ width: "100%",
2440
+ minWidth: "0",
2441
+ padding: "5px 6px",
2442
+ border: "1px solid transparent",
2443
+ borderRadius: "5px",
2444
+ background: "transparent",
2445
+ color: "inherit",
2446
+ font: "inherit",
2447
+ fontSize: "12px",
2448
+ textAlign: "left",
2449
+ cursor: "pointer"
2450
+ },
2451
+ /* The row I can edit, marked as such before the pointer arrives. */
2452
+ personRowSelf: {
2453
+ border: "1px solid currentColor",
2454
+ background: "rgba(76, 141, 255, 0.07)"
2455
+ },
2456
+ personBody: {
2457
+ flex: "1 1 auto",
2458
+ minWidth: "0",
2459
+ display: "flex",
2460
+ flexDirection: "column",
2461
+ gap: "1px"
2462
+ },
2463
+ personTop: {
2464
+ display: "flex",
2465
+ alignItems: "baseline",
2466
+ gap: "5px",
2467
+ minWidth: "0",
2468
+ width: "100%"
2469
+ },
2470
+ /* The only cell that shrinks: every sibling is `flex: 0 0 auto`. */
2471
+ personName: {
2472
+ flex: "0 1 auto",
2473
+ minWidth: "0",
2474
+ overflow: "hidden",
2475
+ textOverflow: "ellipsis",
2476
+ whiteSpace: "nowrap",
2477
+ fontWeight: 600
2478
+ },
2479
+ selfTag: {
2480
+ flex: "0 0 auto",
2481
+ fontSize: "10px",
2482
+ padding: "0 4px",
2483
+ borderRadius: "7px",
2484
+ lineHeight: "14px",
2485
+ // An INVERTED chip: dark on light themes, light on dark ones. `currentColor` was used before,
2486
+ // which inverts only one element - a dark theme produced a near-white pill with white text,
2487
+ // i.e. invisible. `light-dark()` picks both sides; every root here declares colorScheme.
2488
+ background: "light-dark(#1a1a1a, #f2f2f2)",
2489
+ color: "light-dark(#f2f2f2, #1a1a1a)"
2490
+ },
2491
+ roleChip: {
2492
+ flex: "0 0 auto",
2493
+ fontSize: "10px",
2494
+ opacity: 0.55
2495
+ },
2496
+ editChip: {
2497
+ flex: "0 0 auto",
2498
+ marginLeft: "auto",
2499
+ fontSize: "10px",
2500
+ padding: "0 4px",
2501
+ border: "1px solid rgba(128, 128, 128, 0.35)",
2502
+ borderRadius: "7px",
2503
+ lineHeight: "14px",
2504
+ opacity: 0.75
2505
+ },
2506
+ /* Wraps and clamps: the group role is content, not decoration. */
2507
+ personAbout: {
2508
+ display: "-webkit-box",
2509
+ WebkitLineClamp: 2,
2510
+ WebkitBoxOrient: "vertical",
2511
+ overflow: "hidden",
2512
+ margin: 0,
2513
+ fontSize: "11px",
2514
+ lineHeight: "1.45",
2515
+ opacity: 0.7,
2516
+ whiteSpace: "normal",
2517
+ wordBreak: "break-word",
2518
+ overflowWrap: "anywhere"
2519
+ },
2520
+ /* My own row shows the whole sentence: it is the thing I am about to edit. */
2521
+ personAboutSelf: {
2522
+ display: "block",
2523
+ margin: 0,
2524
+ fontSize: "11px",
2525
+ lineHeight: "1.45",
2526
+ opacity: 0.8,
2527
+ whiteSpace: "normal",
2528
+ wordBreak: "break-word",
2529
+ overflowWrap: "anywhere"
2530
+ },
2531
+ aboutEmpty: {
2532
+ margin: 0,
2533
+ fontSize: "11px",
2534
+ lineHeight: "1.45",
2535
+ opacity: 0.45,
2536
+ fontStyle: "italic"
2537
+ },
2538
+ dot: {
2539
+ width: "6px",
2540
+ height: "6px",
2541
+ borderRadius: "50%",
2542
+ flex: "0 0 auto",
2543
+ marginTop: "6px"
2544
+ },
2545
+ hint: {
2546
+ margin: 0,
2547
+ fontSize: "11px",
2548
+ lineHeight: "1.5",
2549
+ opacity: 0.55,
2550
+ whiteSpace: "normal",
2551
+ wordBreak: "break-word"
2552
+ }
2553
+ };
2554
+
2555
+ /**
2556
+ * Write a whole draft sentence into the composer, through the owner's two-step API.
2557
+ *
2558
+ * Same contract as {@link insertMention}: `captureInsertion()` snapshots the
2559
+ * selection and the draft revision, and `insertText(text, span)` is refused if the
2560
+ * draft changed between the click and the write — which is the behaviour we want,
2561
+ * because inserting an edit instruction at a stale caret would splice it into
2562
+ * whatever the reader typed in the meantime.
2563
+ *
2564
+ * The one-argument `insertText(text)` form is NOT used: it silently returns false.
2565
+ *
2566
+ * @param {object|undefined} inputActions - the composer's action surface.
2567
+ * @param {string} text - the text to insert.
2568
+ * @returns {boolean} whether something was actually written.
2569
+ */
2570
+ function fillDraft(inputActions, text) {
2571
+ try {
2572
+ if (inputActions === null || inputActions === undefined) return false;
2573
+ if (typeof inputActions.captureInsertion === "function"
2574
+ && typeof inputActions.insertText === "function") {
2575
+ const span = inputActions.captureInsertion();
2576
+ return inputActions.insertText(text, span) === true;
2577
+ }
2578
+ // Last resort for a Host that only offers a whole-draft setter. It replaces
2579
+ // the draft, which is why it is the fallback and not the first choice.
2580
+ if (typeof inputActions.setDraft === "function") {
2581
+ inputActions.setDraft(text);
2582
+ return true;
2583
+ }
2584
+ return false;
2585
+ } catch (error) {
2586
+ reportUnavailable("inputActions.insertText", error);
2587
+ return false;
2588
+ }
2589
+ }
2590
+
2591
+ /**
2592
+ * Members of the group the current Session belongs to, as a right-hand panel.
2593
+ *
2594
+ * Data comes from the same snapshot the central panel reads, filtered to the group
2595
+ * this Session is in. The panel therefore shows nothing when the Session has not
2596
+ * joined a group — which is correct, and says so, rather than listing strangers.
2597
+ *
2598
+ * ## The row order is IM order, not alphabetical
2599
+ *
2600
+ * 「所有人」 first because it is an address, not a person. 「我」 second and pinned,
2601
+ * because "where am I in this list" is the question a reader asks on opening it, and
2602
+ * because my row is the only one with a second action. Then everyone else, online
2603
+ * before offline, then by name — presence is the fact that changes by the minute.
2604
+ *
2605
+ * ## Why the edit is an instruction and not a form
2606
+ *
2607
+ * See {@link draftGroupRoleEdit}: this bundle holds no membership credential, so it
2608
+ * cannot write. Clicking my row fills the composer with a prefilled instruction and
2609
+ * the Session turn — which does hold the credential — performs the write.
2610
+ *
2611
+ * Props provided by the slot owner: `sessionId` and `inputActions`.
2612
+ */
2613
+ /**
2614
+ * What the members pane shows in a conversation that has not joined a group.
2615
+ *
2616
+ * Three things, in the order a newcomer needs them: what the feature is, how to get in, and the
2617
+ * one-step path for somebody who already has a link. The paste box fills the composer because
2618
+ * this component has no credential — the Session turn performs the join.
2619
+ *
2620
+ * @param {object} props - `{ inputActions }`.
2621
+ * @returns {object} the pane element.
2622
+ */
2623
+ function UnboundMembersPane(props) {
2624
+ const inputActions = props !== null && props !== undefined ? props.inputActions : undefined;
2625
+ const [link, setLink] = react.useState("");
2626
+
2627
+ /** Hand the pasted link to the Session as a ready-to-send instruction. */
2628
+ function joinWithLink() {
2629
+ const trimmed = String(link ?? "").trim();
2630
+ if (trimmed === "") return;
2631
+ fillDraft(inputActions, "用 a2a_group_join 加入这个群:" + trimmed);
2632
+ setLink("");
2633
+ }
2634
+
2635
+ return h(
2636
+ "div",
2637
+ { style: membersLayout.root },
2638
+ h(
2639
+ "div",
2640
+ { style: membersLayout.header },
2641
+ h("span", { style: membersLayout.headerTitle }, icon("users", 12), "协作群"),
2642
+ h("span", { style: membersLayout.headerMeta }, "未加入")
2643
+ ),
2644
+ h(
2645
+ "div",
2646
+ { style: membersLayout.unboundBody },
2647
+ // 1. What it is. Without this the pane is a form for something the reader cannot name.
2648
+ h(
2649
+ "p",
2650
+ { style: membersLayout.unboundLead },
2651
+ "这个会话还没有加入协作群。协作群是几个同事一起干活的地方:消息、分工和上下文都留在群里,谁进来都能看到前因后果。"
2652
+ ),
2653
+ // 2a. The common case: somebody sent me a link.
2654
+ h("div", { style: membersLayout.unboundStep }, "① 同事给了你邀请链接"),
2655
+ h(
2656
+ "p",
2657
+ { style: membersLayout.unboundHint },
2658
+ "粘贴到下面,点「填入会话」,再按回车发送即可。"
2659
+ ),
2660
+ h("textarea", {
2661
+ value: link,
2662
+ placeholder: "agent-a2a://group?v=1&…",
2663
+ rows: 2,
2664
+ spellcheck: false,
2665
+ onChange: function (event) {
2666
+ setLink(event !== null && event !== undefined && event.target !== undefined
2667
+ ? event.target.value
2668
+ : "");
2669
+ },
2670
+ style: membersLayout.unboundInput
2671
+ }),
2672
+ h(
2673
+ "button",
2674
+ {
2675
+ type: "button",
2676
+ // Disabled while empty so the button cannot produce a half-sentence.
2677
+ disabled: String(link ?? "").trim() === "",
2678
+ title: "把加入指令填进输入框,按回车发送",
2679
+ style: Object.assign(
2680
+ {},
2681
+ membersLayout.unboundButton,
2682
+ String(link ?? "").trim() === "" ? membersLayout.unboundButtonOff : {}
2683
+ ),
2684
+ onClick: joinWithLink
2685
+ },
2686
+ "填入会话"
2687
+ ),
2688
+ // 2b. The other route, for somebody with no link.
2689
+ h("div", { style: membersLayout.unboundStep }, "② 自己找群"),
2690
+ h(
2691
+ "p",
2692
+ { style: membersLayout.unboundHint },
2693
+ "点左侧栏的「协作群」,从列表里挑一个申请加入;群主同意后你就会收到通知。"
2694
+ ),
2695
+ // A thread somebody may already belong to. Saying so prevents the confusing case of
2696
+ // "I know I joined a group" while this pane insists otherwise.
2697
+ h(
2698
+ "p",
2699
+ { style: membersLayout.unboundFootnote },
2700
+ "已经有别的会话加入过群?那个群属于那个会话,不会自动跟过来——在这里加入同一个群即可,两边看到的是同一份消息。"
2701
+ )
2702
+ )
2703
+ );
2704
+ }
2705
+
2706
+ /**
2707
+ * The roster of a group, whichever name the data arrived under.
2708
+ *
2709
+ * `normalizeGroup` emits `members`; the raw injected snapshot uses `roster`. The pane is fed
2710
+ * both, so it asks this instead of assuming — the alternative is a list that is mysteriously
2711
+ * empty on one of the two paths.
2712
+ *
2713
+ * @param {object} group - the group row, normalized or raw.
2714
+ * @returns {object[]} the roster, never undefined.
2715
+ */
2716
+ function rosterOf(group) {
2717
+ if (group === null || group === undefined) return [];
2718
+ if (Array.isArray(group.members) && group.members.length > 0) return group.members;
2719
+ if (Array.isArray(group.roster)) return group.roster;
2720
+ if (Array.isArray(group.members)) return group.members;
2721
+ return [];
2722
+ }
2723
+
2724
+ /**
2725
+ * One person's id, whichever field the row carries.
2726
+ *
2727
+ * A card has `personId`; a normalized roster row has `id`. Comparing one to the other without
2728
+ * this returns false for every pair, which is how "already in this group" stops working.
2729
+ *
2730
+ * @param {object} row - a card or a roster row.
2731
+ * @returns {string} the id, or an empty string.
2732
+ */
2733
+ function personIdOf(row) {
2734
+ if (row === null || row === undefined) return "";
2735
+ const direct = pickString(row.personId);
2736
+ if (direct !== "") return direct;
2737
+ return pickString(row.id);
2738
+ }
2739
+
2740
+ /**
2741
+ * The invite chooser.
2742
+ *
2743
+ * A dialog rather than a list inside the pane, because "who is in this group" and "who could
2744
+ * join it" are different questions and a directory of a thousand answers the second one badly in
2745
+ * a narrow column.
2746
+ *
2747
+ * Search is the primary control: nobody pages through a thousand colleagues, they type part of
2748
+ * a name. The list under the box is what an empty search shows, capped, with the count stated —
2749
+ * a silently truncated list makes a reader conclude somebody is not invitable.
2750
+ *
2751
+ * @param {object} props - `{ candidates, groupName, onPick, onClose }`.
2752
+ * @returns {object} the overlay.
2753
+ */
2754
+ function InviteDialog(props) {
2755
+ const [query, setQuery] = react.useState("");
2756
+ // Sending / sent-online / sent-offline / failed. `sending` also disables the list: each invitation
2757
+ // mints a single-use link, and a second click would mint a second live one.
2758
+ const [inviteState, setInviteState] = react.useState("idle");
2759
+ const [inviteNote, setInviteNote] = react.useState("");
2760
+
2761
+ /** Send one invitation and record what happened. */
2762
+ function handleInvitePick(person) {
2763
+ if (panelRpcCaller === undefined) {
2764
+ // No channel: fall back to the instruction, so a host without Connection loses the
2765
+ // feedback rather than the ability to invite.
2766
+ fillDraft(props.inputActions, draftGroupInvite(props.group, person));
2767
+ props.onClose();
2768
+ return;
2769
+ }
2770
+ setInviteState("sending");
2771
+ setInviteNote("正在发送…");
2772
+ panelRpcCaller("/a2a-panel", "invite", {
2773
+ groupId: props.groupId,
2774
+ sessionId: props.sessionId,
2775
+ toPersonId: person.id,
2776
+ }).then(function (result) {
2777
+ if (result !== null && result !== undefined && result.ok === true) {
2778
+ const online = result.value !== undefined && result.value !== null && result.value.online === true;
2779
+ setInviteState("sent");
2780
+ setInviteNote(online
2781
+ ? `已邀请 ${person.name},对方在线,会立即收到`
2782
+ : `已邀请 ${person.name},对方当前离线,上线后会看到`);
2783
+ return;
2784
+ }
2785
+ const code = result && result.error ? result.error.code : "unknown";
2786
+ setInviteState("failed");
2787
+ setInviteNote(`发送失败:${code}`);
2788
+ }).catch(function (error) {
2789
+ setInviteState("failed");
2790
+ setInviteNote("发送失败:" + String((error && error.message) || error));
2791
+ });
2792
+ }
2793
+ const candidates = Array.isArray(props.candidates) ? props.candidates : [];
2794
+
2795
+ // Local, case-insensitive, and matched against the description too: a person looking for
2796
+ // "the DBA" should find 赵六 even if they cannot recall the name.
2797
+ const needle = String(query ?? "").trim().toLowerCase();
2798
+ const matched = needle === ""
2799
+ ? candidates
2800
+ : candidates.filter(function (person) {
2801
+ const hay = (person.name + " " + (person.headline ?? "") + " " + (person.tags ?? []).join(" ")).toLowerCase();
2802
+ return hay.indexOf(needle) !== -1;
2803
+ });
2804
+
2805
+ const LIMIT = 6;
2806
+ const shown = matched.slice(0, LIMIT);
2807
+ const hidden = matched.length - shown.length;
2808
+
2809
+ return h(
2810
+ "div",
2811
+ { style: membersLayout.dialogOverlay, onClick: props.onClose },
2812
+ // Clicks inside must not close: the overlay closes, the panel does not.
2813
+ h(
2814
+ "div",
2815
+ { style: membersLayout.dialogPanel, onClick: function (event) { event.stopPropagation(); } },
2816
+ h(
2817
+ "div",
2818
+ { style: membersLayout.dialogHeader },
2819
+ h("span", { style: { fontWeight: 600, fontSize: "13px" } }, "邀请同事入群"),
2820
+ h(
2821
+ "button",
2822
+ {
2823
+ type: "button",
2824
+ title: "关闭",
2825
+ style: membersLayout.dialogClose,
2826
+ onClick: props.onClose
2827
+ },
2828
+ "✕"
2829
+ )
2830
+ ),
2831
+ h(
2832
+ "p",
2833
+ { style: membersLayout.unboundHint },
2834
+ "选中的同事会收到一条消息,里面带着加入「" + (props.groupName ?? "这个群") + "」的链接。"
2835
+ ),
2836
+ h("input", {
2837
+ type: "search",
2838
+ value: query,
2839
+ placeholder: "搜索名字、角色或标签…",
2840
+ autoFocus: true,
2841
+ onChange: function (event) {
2842
+ setQuery(event !== null && event !== undefined && event.target !== undefined
2843
+ ? event.target.value
2844
+ : "");
2845
+ },
2846
+ style: membersLayout.dialogSearch
2847
+ }),
2848
+ matched.length === 0
2849
+ ? h("p", { style: membersLayout.unboundHint }, "没有匹配的同事。")
2850
+ : h(
2851
+ "div",
2852
+ { style: membersLayout.dialogList },
2853
+ shown.map(function (person) {
2854
+ return h(
2855
+ "button",
2856
+ {
2857
+ key: person.id,
2858
+ type: "button",
2859
+ style: membersLayout.dialogItem,
2860
+ title: "邀请 " + person.name + " 加入",
2861
+ // Disabled while a send is in flight: each invitation is single-use, and a
2862
+ // second click would mint a second live link.
2863
+ disabled: inviteState === "sending",
2864
+ onClick: function () { handleInvitePick(person); }
2865
+ },
2866
+ h("span", { style: { fontSize: "12px" } }, person.name),
2867
+ (person.headline ?? "") === ""
2868
+ ? null
2869
+ : h("span", { style: { fontSize: "11px", opacity: 0.6 } }, person.headline)
2870
+ );
2871
+ })
2872
+ ),
2873
+ // Stated rather than implied: a capped list with no count reads as "that is everybody".
2874
+ hidden > 0
2875
+ ? h(
2876
+ "p",
2877
+ { style: membersLayout.unboundHint },
2878
+ "还有 " + String(hidden) + " 位同事未显示,输入名字缩小范围。"
2879
+ )
2880
+ : null,
2881
+ inviteNote === ""
2882
+ ? null
2883
+ : h(
2884
+ "p",
2885
+ {
2886
+ style: Object.assign(
2887
+ {},
2888
+ membersLayout.unboundHint,
2889
+ inviteState === "failed" ? { color: "#c0392b" } : {}
2890
+ )
2891
+ },
2892
+ inviteNote
2893
+ )
2894
+ )
2895
+ );
2896
+ }
2897
+
2898
+ function MembersPane(props) {
2899
+ const snapshot = useSnapshot();
2900
+ const inputActions = props !== null && props !== undefined ? props.inputActions : undefined;
2901
+ const sessionId = props !== null && props !== undefined ? pickString(props.sessionId) : "";
2902
+ // Whether the invite chooser is open. Kept here rather than in the dialog so one Escape or
2903
+ // one pick closes it from the outside too.
2904
+ const [inviteOpen, setInviteOpen] = react.useState(false);
2905
+ // Whether my own row is being edited in place. Separate from `inviteOpen` because the two open
2906
+ // different surfaces and only one of them writes.
2907
+ const [editingRole, setEditingRole] = react.useState(false);
2908
+
2909
+ // Which group this Session belongs to. A Session can hold several; the pane shows
2910
+ // the one with the most recent activity, because that is the one being worked in.
2911
+ const candidates = snapshot.groups.filter(function (group) {
2912
+ if (group.discovered === true) return false;
2913
+ if (sessionId === "") return true;
2914
+ return group.sessionId === "" || group.sessionId === sessionId;
2915
+ });
2916
+ candidates.sort(function (left, right) {
2917
+ return (right.lastTime || 0) - (left.lastTime || 0);
2918
+ });
2919
+ const group = candidates[0];
2920
+
2921
+ /** Insert `@name ` into the composer through the owner's two-step API. */
2922
+ function address(name) {
2923
+ fillDraft(inputActions, "@" + name + " ");
2924
+ }
2925
+
2926
+ /** Fill the composer with the prefilled role-edit instruction. */
2927
+ function editOwnRole(current) {
2928
+ fillDraft(inputActions, draftGroupRoleEdit({
2929
+ groupName: group === undefined ? "" : group.name,
2930
+ groupId: group === undefined ? "" : group.id,
2931
+ about: current.about,
2932
+ tags: current.tags
2933
+ }));
2934
+ }
2935
+
2936
+ if (group === undefined) {
2937
+ // A real pane, not one sentence: the entry point exists, so it owes the reader the three
2938
+ // answers a new conversation needs — what this is, how to join, and the one-step path
2939
+ // for somebody who already has a link.
2940
+ return h(UnboundMembersPane, { inputActions: inputActions });
2941
+ }
2942
+
2943
+ // The snapshot may be normalized (`members`) or raw (`roster`); ask once, here.
2944
+ const members = rosterOf(group);
2945
+ const self = pickSelf(group, snapshot.cards);
2946
+ const split = splitRoster(members, self);
2947
+ // A roster that arrived without an `isSelf` marker still gets a 「我」 row: the
2948
+ // person table knows who I am even when the cached roster was written by a Host
2949
+ // that predates the marker. This synthesis is why `split.mine` can be absent while
2950
+ // `self.hasSelf` is true.
2951
+ if (split.mine === undefined && split.owner === undefined && self.hasSelf === true && self.personId !== "") {
2952
+ split.mine = {
2953
+ id: self.personId,
2954
+ name: self.name === "" ? "我" : self.name,
2955
+ role: "",
2956
+ roleAbout: self.about,
2957
+ tags: self.tags,
2958
+ online: self.online,
2959
+ self: true,
2960
+ isSelf: true
2961
+ };
2962
+ }
2963
+
2964
+ const countLine = String(group.memberCount || members.length) + " 人"
2965
+ + (group.onlineCount > 0 ? " · " + String(group.onlineCount) + " 在线" : "");
2966
+
2967
+ /**
2968
+ * The body shared by every roster row, including 「所有人」.
2969
+ *
2970
+ * One builder for all of them is the point: the broadcast row differs only in its
2971
+ * leading glyph and its name, so any future change to row rhythm (padding, chips,
2972
+ * clamp) lands on it for free instead of drifting into a second visual language.
2973
+ *
2974
+ * @param {string} name - display text.
2975
+ * @param {object} options - `about`, `role`, `badges` (extra chips), `aboutEmpty`.
2976
+ * @returns {object} vnode for the row's text column.
2977
+ */
2978
+ function rowBody(name, options) {
2979
+ const given = options === null || options === undefined ? {} : options;
2980
+ const about = pickString(given.about);
2981
+ const badges = Array.isArray(given.badges) ? given.badges : [];
2982
+ const chips = [];
2983
+ for (const badge of badges) {
2984
+ if (badge !== null && badge !== undefined) chips.push(badge);
2985
+ }
2986
+ return h(
2987
+ "span",
2988
+ { style: membersLayout.personBody },
2989
+ h(
2990
+ "span",
2991
+ { style: membersLayout.personTop },
2992
+ h("span", { style: membersLayout.personName }, name),
2993
+ chips.length === 0 ? null : chips,
2994
+ given.owner === true ? h("span", { style: membersLayout.roleChip }, "群主") : null,
2995
+ about !== "" || given.showEmptyRole !== true
2996
+ ? null
2997
+ : h("span", { style: membersLayout.roleChip }, "未填群内介绍")
2998
+ ),
2999
+ about === ""
3000
+ ? given.emptyText === undefined
3001
+ ? null
3002
+ : h("p", { style: membersLayout.aboutEmpty }, given.emptyText)
3003
+ : h("span", { style: given.full ? membersLayout.personAboutSelf : membersLayout.personAbout }, about)
3004
+ );
3005
+ }
3006
+
3007
+ /**
3008
+ * One roster row.
3009
+ *
3010
+ * There are exactly two behaviours, and which one applies is decided by one fact:
3011
+ * whether this row is me.
3012
+ *
3013
+ * - **me** → fill the composer with the role-edit instruction. I cannot @ myself,
3014
+ * so an @-mention here would be a click that produces nothing useful.
3015
+ * - **anyone else** (including the owner) → insert `@name `. That covers the
3016
+ * owner row too: addressing the owner is the common reason to click them.
3017
+ *
3018
+ * @param {object} member - normalized member, with `isSelf` when I am this row.
3019
+ * @param {object} options - `lead` (glyph or `@`), `owner`, `editable`, `key`.
3020
+ * @returns {object} vnode.
3021
+ */
3022
+ function personRow(member, options) {
3023
+ const given = options === null || options === undefined ? {} : options;
3024
+ const isSelf = member.isSelf === true;
3025
+ const canEdit = isSelf && inputActions !== null && inputActions !== undefined;
3026
+ const about = isSelf ? pickString(self.about) : pickString(member.roleAbout);
3027
+
3028
+ const badges = [];
3029
+ // Suppress the self chip when the display name already says 我: the row would read
3030
+ // 「我 我」, which looks like a bug rather than a label. The row is still marked as
3031
+ // mine by its accent border and its edit chip.
3032
+ if (isSelf && pickString(member.name) !== "我") {
3033
+ badges.push(h("span", { key: "me", style: membersLayout.selfTag }, "我"));
3034
+ }
3035
+ if (given.owner === true) badges.push(h("span", { key: "owner", style: membersLayout.roleChip }, "群主"));
3036
+ if (canEdit) badges.push(h("span", { key: "edit", style: membersLayout.editChip }, "编辑介绍"));
3037
+ if (isSelf && !canEdit) badges.push(h("span", { key: "ro", style: membersLayout.editChip }, "只读"));
3038
+
3039
+ const label = isSelf
3040
+ ? (canEdit
3041
+ ? "点击修改我在这个群的介绍(会填入输入框,按回车发送)"
3042
+ : "这个面板拿不到输入框,无法发起编辑")
3043
+ : "点击在输入框里 @ " + member.name;
3044
+
3045
+ const button = h(
3046
+ "button",
3047
+ {
3048
+ key: given.key === undefined ? member.id : given.key,
3049
+ type: "button",
3050
+ title: label,
3051
+ style: isSelf
3052
+ ? Object.assign({}, membersLayout.personRow, membersLayout.personRowSelf)
3053
+ : membersLayout.personRow,
3054
+ onClick: function () {
3055
+ if (isSelf) {
3056
+ // Prefer the inline editor: it writes straight to the Host. Fall back to the composer
3057
+ // instruction only when the write channel is missing, and say which happened rather than
3058
+ // silently doing the other thing.
3059
+ if (panelRpcCaller !== undefined) {
3060
+ setEditingRole(true);
3061
+ } else {
3062
+ editOwnRole(self);
3063
+ }
3064
+ }
3065
+ else address(member.name);
3066
+ }
3067
+ },
3068
+ given.lead === undefined
3069
+ ? h("span", {
3070
+ style: Object.assign({}, membersLayout.dot, {
3071
+ background: member.online === true
3072
+ ? "#3fb950"
3073
+ : "rgba(128, 128, 128, 0.5)"
3074
+ }),
3075
+ title: member.online === true ? "在线" : "离线或未知"
3076
+ })
3077
+ : h("span", { style: membersLayout.personLead, "aria-hidden": "true" }, given.lead),
3078
+ rowBody(member.name, {
3079
+ about: about,
3080
+ owner: false,
3081
+ full: isSelf,
3082
+ showEmptyRole: !isSelf,
3083
+ emptyText: isSelf ? (canEdit ? "还没写群内介绍,点这一行添加" : "还没写群内介绍") : undefined,
3084
+ badges: badges
3085
+ })
3086
+ );
3087
+
3088
+ // No separate "clear" control. Clearing is not a sibling of editing — it is the
3089
+ // empty case of it, and the edit draft already says so ("清空 about 就是删掉这条
3090
+ // 介绍"). A second destructive button under the row would be one more thing to
3091
+ // mis-click, and it has no IM precedent: WeChat and Feishu put destructive
3092
+ // actions inside the row's own ⋯ menu, never as a line of the list.
3093
+ //
3094
+ // When that ⋯ menu exists, clearing belongs there. Until then the edit path is
3095
+ // the only writer, which is also the property that keeps the credential on the
3096
+ // host.
3097
+ return button;
3098
+ }
3099
+
3100
+ /**
3101
+ * 「所有人」: a roster row, first, with an `@` instead of a presence dot.
3102
+ *
3103
+ * No dashed border and no separate padding — in WeChat / Feishu / Slack, @所有人 is
3104
+ * simply the first line of the member list with a special name. The glyph is the
3105
+ * whole cue.
3106
+ */
3107
+ function broadcastRow() {
3108
+ return h(
3109
+ "button",
3110
+ {
3111
+ key: "broadcast",
3112
+ type: "button",
3113
+ title: "点击 @ 所有人",
3114
+ style: membersLayout.personRow,
3115
+ onClick: function () {
3116
+ address("所有人");
3117
+ }
3118
+ },
3119
+ // A literal `@`, not a glyph: it is the universal broadcast marker, it is what
3120
+ // the inserted text actually looks like, and no icon in the product set says
3121
+ // "everyone" more clearly than the character the reader is about to type.
3122
+ h("span", { style: membersLayout.personLead, "aria-hidden": "true" }, "@"),
3123
+ rowBody("所有人", { about: "", showEmptyRole: false })
3124
+ );
3125
+ }
3126
+
3127
+
3128
+ // One flat roster, in the order a person scans it:
3129
+ //
3130
+ // 所有人 → 群主 → 我 → 其他人
3131
+ //
3132
+ // 「所有人」 first because it is an address, not a person. 群主 second because they
3133
+ // are the one to ask when the group is stuck. 我 third because that is the row with
3134
+ // a second action. Everyone else by presence, then name.
3135
+ //
3136
+ // `split.owner` carries `isSelf` when I run the group, so that case renders exactly
3137
+ // one row (labelled 群主 · 我) rather than listing me twice.
3138
+ const rosterRows = [broadcastRow()];
3139
+ if (split.owner !== undefined) {
3140
+ rosterRows.push(personRow(split.owner, { key: "owner", owner: true }));
3141
+ }
3142
+ if (split.mine !== undefined) {
3143
+ rosterRows.push(personRow(split.mine, { key: "self" }));
3144
+ }
3145
+ if (split.others.length === 0 && members.length > 0 && split.owner === undefined && split.mine === undefined) {
3146
+ rosterRows.push(h("p", { key: "alone", style: membersLayout.hint }, "这个群目前只有你。"));
3147
+ }
3148
+ for (const member of split.others) rosterRows.push(personRow(member, {}));
3149
+
3150
+ // Inviting lives HERE, not on the colleague card: the group is the subject of the action, so
3151
+ // the group is what the pane knows. Cards show the group the instruction guessed would have
3152
+ // come from — a first group the reader never picked.
3153
+ const invitable = (Array.isArray(snapshot.cards) ? snapshot.cards : [])
3154
+ .filter(function (card) {
3155
+ return card.isSelf !== true;
3156
+ })
3157
+ .filter(function (card) {
3158
+ // Somebody already in this group cannot be invited to it again.
3159
+ return !members.some(function (member) { return personIdOf(member) === personIdOf(card); });
3160
+ });
3161
+
3162
+ // One quiet button. The choice happens in a dialog sized for choosing; an inline list either
3163
+ // crowds the roster (a big team) or hides its own extent (twelve shown, no count).
3164
+ function inviteRow() {
3165
+ if (invitable.length === 0) return null;
3166
+ return h(
3167
+ "div",
3168
+ { style: membersLayout.inviteBlock },
3169
+ h(
3170
+ "button",
3171
+ {
3172
+ type: "button",
3173
+ title: "选择要拉进这个群的同事",
3174
+ style: membersLayout.inviteButton,
3175
+ onClick: function () { setInviteOpen(true); }
3176
+ },
3177
+ icon("users", 12),
3178
+ "邀请同事入群"
3179
+ )
3180
+ );
3181
+ }
3182
+ const pane = h(
3183
+ "div",
3184
+ { style: membersLayout.root },
3185
+ h(
3186
+ "div",
3187
+ { style: membersLayout.header },
3188
+ h(
3189
+ "span",
3190
+ { style: membersLayout.headerTitle },
3191
+ icon("users", 12),
3192
+ "群成员"
3193
+ ),
3194
+ h("span", { style: membersLayout.headerMeta }, countLine)
3195
+ ),
3196
+ h("p", { style: membersLayout.groupName, title: group.topic === "" ? group.name : group.name + " · " + group.topic }, group.name),
3197
+ members.length === 0
3198
+ ? h("p", { style: membersLayout.hint }, "名单需要在会话里查一次(a2a_group_info)才会出现。")
3199
+ : h("div", { style: membersLayout.roster }, rosterRows),
3200
+ editingRole && group !== undefined
3201
+ ? h(RoleEditor, {
3202
+ groupId: group.id,
3203
+ sessionId: sessionId,
3204
+ about: self.about,
3205
+ tags: self.tags,
3206
+ // Read from the person table, not the roster row: the headline is a person fact and a
3207
+ // roster written before it existed would not carry it.
3208
+ headline: (function () {
3209
+ const mine = (Array.isArray(snapshot.cards) ? snapshot.cards : []).filter(function (card) {
3210
+ return card.isSelf === true;
3211
+ })[0];
3212
+ return mine === undefined ? "" : (mine.headline ?? "");
3213
+ })(),
3214
+ onCancel: function () { setEditingRole(false); },
3215
+ onSaved: function () {
3216
+ // Close on success; the panel refreshes from the Host's next snapshot, so the new
3217
+ // value appears without a reload.
3218
+ setEditingRole(false);
3219
+ }
3220
+ })
3221
+ : null,
3222
+ inviteRow(),
3223
+ inviteOpen
3224
+ ? h(InviteDialog, {
3225
+ candidates: invitable,
3226
+ groupName: group.name,
3227
+ // The facts the dialog needs to send for itself: which group, which Session's credential,
3228
+ // and the composer for the no-channel fallback.
3229
+ groupId: group.id,
3230
+ sessionId: sessionId,
3231
+ group: group,
3232
+ inputActions: inputActions,
3233
+ onClose: function () { setInviteOpen(false); }
3234
+ })
3235
+ : null,
3236
+ );
3237
+
3238
+ // The dialog is position: fixed, so it covers the viewport even though it is a child of the pane.
3239
+ // A fixed element is positioned against the viewport regardless of its ancestors, which is why the
3240
+ // pane's own overflow cannot clip it — and why no wrapper is needed.
3241
+ return pane;
3242
+ }
3243
+
3244
+
3245
+ /**
3246
+ * Composer button that opens the members column.
3247
+ *
3248
+ * Lives in `conversation.input.left` beside the other small controls rather than in
3249
+ * the global sidebar: the column shows THIS conversation's group, so a global
3250
+ * destination for it would be a lie about its scope.
3251
+ */
3252
+ function MembersButton(props) {
3253
+ const snapshot = useSnapshot();
3254
+ const sessionId = props !== null && props !== undefined ? pickString(props.sessionId) : "";
3255
+
3256
+ // Which group THIS conversation is in decides the count, and whether the button is worth
3257
+ // showing at all. Without the session filter this took the most recently active group in
3258
+ // the whole instance, so a new conversation showed the member count of a group it had
3259
+ // never joined. The pane has always filtered this way; the button has to agree with it,
3260
+ // because two answers to one question show up as a visible contradiction.
3261
+ const joined = snapshot.groups.filter(function (group) {
3262
+ if (group.discovered === true) return false;
3263
+ if (sessionId === "") return true;
3264
+ // An older record with no session stamp belongs to whoever asks: turning a missing
3265
+ // field into a hidden feature is worse than showing it to the wrong conversation
3266
+ // once.
3267
+ return group.sessionId === "" || group.sessionId === sessionId;
3268
+ });
3269
+ // The button ALWAYS renders. Hiding it when nothing is bound made the entry point and the pane
3270
+ // depend on the same condition, so closing the pane left no way back. What changes is the
3271
+ // label: a count when bound, the feature's name when not — "群成员 0" would claim the group is
3272
+ // empty, which is a different and false statement.
3273
+ joined.sort(function (left, right) {
3274
+ return (right.lastTime || 0) - (left.lastTime || 0);
3275
+ });
3276
+ const group = joined[0];
3277
+
3278
+ function openColumn() {
3279
+ try {
3280
+ // Record what happened on the window so a missing service is diagnosable
3281
+ // from the page instead of looking like a dead button. Cheap, and the only
3282
+ // way to see the difference between "no service" and "service refused".
3283
+ window.__dsh_a2a_open = {
3284
+ hasService: services.sidebarRight !== undefined,
3285
+ hasTabs: services.sidebarRightTabs !== undefined,
3286
+ kind: MEMBERS_TAB_KIND
3287
+ };
3288
+ const right = services.sidebarRight;
3289
+ if (right === undefined || typeof right.openTab !== "function") {
3290
+ window.__dsh_a2a_open.error = "sidebarRight.openTab unavailable";
3291
+ reportUnavailable("sidebarRight.openTab", undefined);
3292
+ return;
3293
+ }
3294
+ right.openTab(MEMBERS_TAB_KIND);
3295
+ window.__dsh_a2a_open.opened = true;
3296
+ } catch (error) {
3297
+ window.__dsh_a2a_open.error = String(error && error.message ? error.message : error);
3298
+ reportUnavailable("sidebarRight.openTab", error);
3299
+ }
3300
+ }
3301
+
3302
+ return h(
3303
+ "button",
3304
+ {
3305
+ type: "button",
3306
+ title: group === undefined
3307
+ ? "打开协作群面板:加入一个群,或查看当前群的成员"
3308
+ : "展开「" + group.name + "」的群成员,点人即可 @ 对方",
3309
+ style: {
3310
+ display: "inline-flex",
3311
+ alignItems: "center",
3312
+ gap: "4px",
3313
+ padding: "2px 8px",
3314
+ border: "1px solid rgba(128, 128, 128, 0.5)",
3315
+ borderRadius: "999px",
3316
+ background: "transparent",
3317
+ color: "inherit",
3318
+ font: "inherit",
3319
+ fontSize: "12px",
3320
+ cursor: "pointer"
3321
+ },
3322
+ onClick: openColumn
3323
+ },
3324
+ group === undefined ? "协作群" : "群成员",
3325
+ group !== undefined && group.memberCount > 0
3326
+ ? h("span", { style: { opacity: 0.7 } }, String(group.memberCount))
3327
+ : null
3328
+ );
3329
+ }
3330
+
3331
+ function GroupsPanel(props) {
3332
+ const snapshot = useSnapshot();
3333
+ const [expanded, toggle] = useExpanded();
3334
+ // One state object for the pair: switching tabs clears the tag filter, which is
3335
+ // what a person expects when they change the question they are asking.
3336
+ const local = useLocalState({ tab: "all", tag: "" });
3337
+ const state = local.value !== null && typeof local.value === "object"
3338
+ ? local.value
3339
+ : { tab: "all", tag: "" };
3340
+ const tab = typeof state.tab === "string" ? state.tab : "all";
3341
+ const cards = typeof state.tag === "string" ? state.tag : "";
3342
+
3343
+ const sections = groupByRelationship(snapshot.groups);
3344
+ const counts = {
3345
+ all: snapshot.groups.length,
3346
+ mine: sections.mine.length,
3347
+ joined: sections.joined.length,
3348
+ cards: snapshot.cards.length
3349
+ };
3350
+
3351
+ /** The group rows for the active tab; empty on the cards tab. */
3352
+ const activeGroups =
3353
+ tab === "mine"
3354
+ ? sections.mine
3355
+ : tab === "joined"
3356
+ ? sections.joined
3357
+ : tab === "cards"
3358
+ ? []
3359
+ : snapshot.groups;
3360
+
3361
+ /** Every tag present across the roster, so the filter offers real choices only. */
3362
+ const allTags = [];
3363
+ for (const card of snapshot.cards) {
3364
+ for (const tag of card.tags) if (allTags.indexOf(tag) < 0) allTags.push(tag);
3365
+ }
3366
+ allTags.sort();
3367
+
3368
+ const visibleCards = cards === ""
3369
+ ? snapshot.cards
3370
+ : snapshot.cards.filter(function (card) {
3371
+ return card.tags.indexOf(cards) > -1;
3372
+ });
3373
+
3374
+ const groupBody = activeGroups.length === 0
3375
+ ? h(
3376
+ "p",
3377
+ { style: S.emptyBody },
3378
+ tab === "mine"
3379
+ ? "还没有创建过协作群。在会话里说「建个群叫 xxx」即可。"
3380
+ : tab === "joined"
3381
+ ? "这个会话还没有加入协作群。在「全部」里找一个申请加入,或向同事要邀请链接。"
3382
+ : "还没有任何协作群。"
3383
+ )
3384
+ : h(
3385
+ "div",
3386
+ { style: { display: "flex", flexDirection: "column", gap: "8px" } },
3387
+ activeGroups.map(function (group) {
3388
+ return h(GroupCard, {
3389
+ key: group.id,
3390
+ group: group,
3391
+ open: expanded[group.id] === true,
3392
+ onToggle: function () {
3393
+ toggle(group.id);
3394
+ }
3395
+ });
3396
+ })
3397
+ );
3398
+
3399
+ const cardsBody = snapshot.cards.length === 0
3400
+ ? h(
3401
+ "p",
3402
+ { style: S.emptyBody },
3403
+ "还没有同事名片。名片由各人在群里声明的角色汇总而成——在会话里让助手查一次群成员即可。"
3404
+ )
3405
+ : h(
3406
+ "div",
3407
+ { style: { display: "flex", flexDirection: "column", gap: "10px" } },
3408
+ h(TagFilter, {
3409
+ tags: allTags,
3410
+ active: cards,
3411
+ onSelect: function (value) {
3412
+ local.set({ tab: tab, tag: value });
3413
+ }
3414
+ }),
3415
+ visibleCards.length === 0
3416
+ ? h("p", { style: S.emptyBody }, "没有标签为「" + cards + "」的同事。")
3417
+ : h(
3418
+ "div",
3419
+ { style: { display: "flex", flexDirection: "column", gap: "6px" } },
3420
+ visibleCards.map(function (card) {
3421
+ return h(ColleagueCard, {
3422
+ key: card.id,
3423
+ card: card,
3424
+ // The composer API, so the self card can fill the draft. Only the
3425
+ // slot owner can provide it, which is why it is threaded down
3426
+ // rather than reached for.
3427
+ inputActions: props !== null && props !== undefined ? props.inputActions : undefined,
3428
+ // Named in the drafted instruction: the profile tool authenticates
3429
+ // through a group, so the text has to say which one.
3430
+ groupName: (function () {
3431
+ const mine = snapshot.groups.filter(function (group) {
3432
+ return group.discovered !== true;
3433
+ });
3434
+ return mine.length > 0 ? mine[0].name : "";
3435
+ })(),
3436
+ onPickTag: function (tag) {
3437
+ local.set({ tab: "cards", tag: tag });
3438
+ }
3439
+ });
3440
+ })
3441
+ )
3442
+ );
3443
+
3444
+ const tabs = PANEL_TABS.map(function (def) {
3445
+ return { id: def.id, label: def.label, icon: def.icon, count: counts[def.id] };
3446
+ });
3447
+
3448
+ return h(
3449
+ "div",
3450
+ { style: S.root },
3451
+ h(
3452
+ "div",
3453
+ { style: S.header },
3454
+ h("h2", { style: S.title }, "协作群"),
3455
+ snapshot.unreadTotal > 0
3456
+ ? h("span", { style: S.badge }, String(snapshot.unreadTotal) + " 条未读")
3457
+ : null,
3458
+ h("span", { style: S.hint }, "只读")
3459
+ ),
3460
+ h(TabBar, {
3461
+ tabs: tabs,
3462
+ active: tab,
3463
+ onSelect: function (id) {
3464
+ local.set({ tab: id, tag: "" });
3465
+ }
3466
+ }),
3467
+ tab === "cards" ? cardsBody : groupBody
3468
+ );
3469
+ }
3470
+
3471
+ /** Two-person glyph, drawn inline so the row needs no extra dependency. */
3472
+ function GroupGlyph(props) {
3473
+ const given = props === null || props === undefined ? {} : props;
3474
+ const size = typeof given.size === "number" && given.size > 0 ? given.size : 16;
3475
+ const IconUsers = primitives.IconUsersOutlineRegular;
3476
+ if (typeof IconUsers === "function" || (IconUsers !== null && typeof IconUsers === "object")) {
3477
+ return h(IconUsers, { size: size });
3478
+ }
3479
+ return h(
3480
+ "svg",
3481
+ {
3482
+ width: size,
3483
+ height: size,
3484
+ viewBox: "0 0 16 16",
3485
+ fill: "none",
3486
+ stroke: "currentColor",
3487
+ strokeWidth: 1.4,
3488
+ strokeLinecap: "round",
3489
+ strokeLinejoin: "round",
3490
+ "aria-hidden": "true"
3491
+ },
3492
+ h("circle", { cx: 6, cy: 5.2, r: 2.3 }),
3493
+ h("path", { d: "M1.8 13.2c0-2.2 1.9-3.6 4.2-3.6s4.2 1.4 4.2 3.6" }),
3494
+ h("path", { d: "M10.7 3.2a2.3 2.3 0 0 1 0 4.2" }),
3495
+ h("path", { d: "M12.4 9.9c1.2.5 1.9 1.5 1.9 2.9" })
3496
+ );
3497
+ }
3498
+
3499
+ /**
3500
+ * Sidebar row glyph (registered into `sidebar.panellist`). The shell passes
3501
+ * `{ size, active }`; unread total is drawn as a corner badge.
3502
+ */
3503
+ function GroupsSidebarIcon(props) {
3504
+ const snapshot = useSnapshot();
3505
+ const given = props === null || props === undefined ? {} : props;
3506
+ const size = typeof given.size === "number" && given.size > 0 ? given.size : 16;
3507
+ return h(
3508
+ "span",
3509
+ { style: S.sidebarGlyph },
3510
+ h(GroupGlyph, { size: size }),
3511
+ snapshot.unreadTotal > 0 ? h("span", { style: S.sidebarBadge }, snapshot.unreadTotal > 99 ? "99+" : String(snapshot.unreadTotal)) : null
3512
+ );
3513
+ }
3514
+ //#endregion
3515
+
3516
+ //#region plugin surface
3517
+ /**
3518
+ * Services this plugin hard-requires. `slots` is the only one: everything
3519
+ * else (locale, layout) is read opportunistically through `ctx.get(...)`
3520
+ * so a version skew silently degrades instead of parking the fiber.
3521
+ */
3522
+ // `sidebarRight` and `sidebarRightTabs` are OPTIONAL services: declaring them makes
3523
+ // the right-column controller and its tab registry reachable, and Cordis keeps the
3524
+ // plugin running on a Host that does not provide them (which is why every use site
3525
+ // still checks for undefined).
3526
+ //
3527
+ // Without them in this list, `ctx.get("sidebarRight")` returns undefined: the
3528
+ // service exists, but this plugin never declared it, so the proxy guard refuses.
3529
+ const inject = ["slots", "sidebarRight", "sidebarRightTabs"];
3530
+
3531
+ /** Registration failures are reported once, never thrown into the shell. */
3532
+ let reported = false;
3533
+
3534
+ function reportUnavailable(stage, error) {
3535
+ if (reported) return;
3536
+ reported = true;
3537
+ warn("panel not mounted (" + stage + ")", error);
3538
+ }
3539
+
3540
+ /**
3541
+ * Client plugin body.
3542
+ *
3543
+ * Two registrations, both evidenced by shipped plugins:
3544
+ * - `sidebar.panellist` (list slot declared by @deepseek-ai/dsh-client-ui-sidebar;
3545
+ * consumed by schedule as `{ name, id, order, locale, label }` + glyph component);
3546
+ * - `main` (keyed slot declared by @deepseek-ai/dsh-client-ui-layout; the sidebar
3547
+ * row's `id` must equal this `key` or `layout.selectPanel` throws).
3548
+ *
3549
+ * @param ctx - browser fiber context; only `slots` is assumed present.
3550
+ */
3551
+ function apply(ctx) {
3552
+ // Optional services: absent on a Host without the sidebar-right package, and a
3553
+ // missing one must cost the column, not the whole plugin.
3554
+ try {
3555
+ if (ctx !== null && ctx !== undefined && typeof ctx.get === "function") {
3556
+ services.sidebarRight = ctx.get("sidebarRight");
3557
+ services.sidebarRightTabs = ctx.get("sidebarRightTabs");
3558
+ }
3559
+ } catch (error) {
3560
+ reportUnavailable("sidebarRight lookup", error);
3561
+ }
3562
+
3563
+ // The Host-facing caller for panel writes. Installed here because `ctx.connection` exists at apply time,
3564
+ // not at render time: the pane receives the call function as a prop rather than looking a service up.
3565
+ installPanelRpc(ctx);
3566
+
3567
+ const slots = ctx === null || ctx === undefined ? undefined : ctx.slots;
3568
+ if (slots === undefined || slots === null || typeof slots.inject !== "function" || typeof slots.register !== "function") {
3569
+ reportUnavailable("slots service", undefined);
3570
+ return;
3571
+ }
3572
+
3573
+ const panelPage = () => {
3574
+ try {
3575
+ return slots.register(
3576
+ {
3577
+ name: "main",
3578
+ key: PANEL_ID,
3579
+ label: "协作群"
3580
+ },
3581
+ GroupsPanel
3582
+ );
3583
+ } catch (error) {
3584
+ reportUnavailable("main slot register", error);
3585
+ return undefined;
3586
+ }
3587
+ };
3588
+
3589
+ /**
3590
+ * Register the members panel into the right column.
3591
+ *
3592
+ * `sidebar.right.pane.tab` is a keyed, session-scoped slot: the panel receives the
3593
+ * current `sessionId` and the composer's `inputActions`, which is exactly what it
3594
+ * needs and nothing more. A failure here must not stop the main panel from
3595
+ * mounting, so it is reported and swallowed.
3596
+ */
3597
+ const membersTab = () => {
3598
+ try {
3599
+ return slots.register(
3600
+ {
3601
+ name: "sidebar.right.pane.tab",
3602
+ key: MEMBERS_TAB_KEY
3603
+ },
3604
+ MembersPane
3605
+ );
3606
+ } catch (error) {
3607
+ reportUnavailable("sidebar.right.pane.tab register", error);
3608
+ return undefined;
3609
+ }
3610
+ };
3611
+
3612
+ /**
3613
+ * The tab's title chip.
3614
+ *
3615
+ * Registered separately because the owner dispatches body and title through
3616
+ * different slots: a tab with a body but no title renders as blank chrome.
3617
+ */
3618
+ const membersTabTitle = () => {
3619
+ try {
3620
+ return slots.register(
3621
+ {
3622
+ name: "sidebar.right.pane.tab.title",
3623
+ key: MEMBERS_TAB_KEY
3624
+ },
3625
+ function MembersTabTitle() {
3626
+ return h("span", { style: { fontSize: "12px" } }, "群成员");
3627
+ }
3628
+ );
3629
+ } catch (error) {
3630
+ reportUnavailable("sidebar.right.pane.tab.title register", error);
3631
+ return undefined;
3632
+ }
3633
+ };
3634
+
3635
+
3636
+ /**
3637
+ * Declare the members page type.
3638
+ *
3639
+ * `priority: "extension"` because this type comes from outside the product: it
3640
+ * outranks shipped viewers and may take over a builtin kind, which is the right
3641
+ * posture for a page only this plugin can draw. `patterns` is omitted because a
3642
+ * page type is opened by kind and recognizes no address.
3643
+ */
3644
+ const membersTabType = () => {
3645
+ try {
3646
+ const tabs = services.sidebarRightTabs;
3647
+ if (tabs === undefined || tabs === null || typeof tabs.register !== "function") return undefined;
3648
+ return tabs.register({
3649
+ id: MEMBERS_TAB_KIND,
3650
+ kind: MEMBERS_TAB_KIND,
3651
+ priority: "extension",
3652
+ title: function () {
3653
+ return "群成员";
3654
+ }
3655
+ });
3656
+ } catch (error) {
3657
+ reportUnavailable("sidebarRightTabs.register", error);
3658
+ return undefined;
3659
+ }
3660
+ };
3661
+
3662
+ /** Register the members page body under the tab definition id. */
3663
+ const membersTabBody = () => {
3664
+ try {
3665
+ return slots.register(
3666
+ { name: "sidebar.right.pane.tab", key: MEMBERS_TAB_KIND },
3667
+ MembersPane
3668
+ );
3669
+ } catch (error) {
3670
+ reportUnavailable("sidebar.right.pane.tab register", error);
3671
+ return undefined;
3672
+ }
3673
+ };
3674
+
3675
+ /** Register the tab chip text; a body without one renders as blank chrome. */
3676
+ const membersTabChip = () => {
3677
+ try {
3678
+ return slots.register(
3679
+ { name: "sidebar.right.pane.tab.title", key: MEMBERS_TAB_KIND },
3680
+ function MembersTabChip() {
3681
+ return h("span", { style: { fontSize: "12px" } }, "群成员");
3682
+ }
3683
+ );
3684
+ } catch (error) {
3685
+ reportUnavailable("sidebar.right.pane.tab.title register", error);
3686
+ return undefined;
3687
+ }
3688
+ };
3689
+
3690
+ /** The opener, in the composer tool row. */
3691
+ const membersButtonSlot = () => {
3692
+ try {
3693
+ return slots.register(
3694
+ { name: "conversation.input.left", id: MEMBERS_TAB_KIND, order: 40 },
3695
+ MembersButton
3696
+ );
3697
+ } catch (error) {
3698
+ reportUnavailable("conversation.input.left register", error);
3699
+ return undefined;
3700
+ }
3701
+ };
3702
+
3703
+ const sidebarRow = () => {
3704
+ try {
3705
+ return slots.register(
3706
+ {
3707
+ name: "sidebar.panellist",
3708
+ id: PANEL_ID,
3709
+ order: PANEL_ORDER,
3710
+ label: "协作群"
3711
+ },
3712
+ GroupsSidebarIcon
3713
+ );
3714
+ } catch (error) {
3715
+ reportUnavailable("sidebar slot register", error);
3716
+ return undefined;
3717
+ }
3718
+ };
3719
+
3720
+ try {
3721
+ slots.inject("sidebar.panellist", sidebarRow);
3722
+ } catch (error) {
3723
+ reportUnavailable("sidebar.panellist inject", error);
3724
+ }
3725
+ try {
3726
+ slots.inject("main", panelPage);
3727
+ } catch (error) {
3728
+ reportUnavailable("main inject", error);
3729
+ }
3730
+ // Members render in the right column only; see MembersPane.
3731
+
3732
+ // The right-hand members column. All four pieces are needed for it to appear:
3733
+ // the type so openTab has a kind, the body and chip so the seat can draw it, and
3734
+ // the button so a person can ask for it.
3735
+ try {
3736
+ slots.inject("sidebar.right.pane.tab", membersTabBody);
3737
+ } catch (error) {
3738
+ reportUnavailable("sidebar.right.pane.tab inject", error);
3739
+ }
3740
+ try {
3741
+ slots.inject("sidebar.right.pane.tab.title", membersTabChip);
3742
+ } catch (error) {
3743
+ reportUnavailable("sidebar.right.pane.tab.title inject", error);
3744
+ }
3745
+ try {
3746
+ slots.inject("conversation.input.left", membersButtonSlot);
3747
+ } catch (error) {
3748
+ reportUnavailable("conversation.input.left inject", error);
3749
+ }
3750
+ // The type registration is not a slot and needs no inject: it is live as soon as
3751
+ // it is registered, and it must exist before the first click.
3752
+ membersTabType();
3753
+ }
3754
+ //#endregion
3755
+
3756
+ exports.apply = apply;
3757
+ exports.inject = inject;
3758
+ exports.PANEL_ID = PANEL_ID;
3759
+ exports.GLOBAL_KEY = GLOBAL_KEY;
3760
+ exports.CHANGE_EVENT = CHANGE_EVENT;
3761
+ exports.setDataSource = setDataSource;
3762
+ exports.readSnapshot = readSnapshot;
3763
+ exports.currentSnapshot = currentSnapshot;
3764
+ exports.subscribeGroups = subscribeGroups;
3765
+ exports.formatTime = formatTime;
3766
+ exports.GroupsPanel = GroupsPanel;
3767
+ exports.GroupsSidebarIcon = GroupsSidebarIcon;
3768
+ exports.GroupCard = GroupCard;
3769
+ exports.EmptyState = EmptyState;
3770
+ exports.MembersPane = MembersPane;
3771
+ exports.MembersButton = MembersButton;
3772
+ exports.pickSelf = pickSelf;
3773
+ exports.splitRoster = splitRoster;
3774
+ exports.draftGroupRoleEdit = draftGroupRoleEdit;
3775
+ exports.draftGroupInvite = draftGroupInvite;
3776
+ exports.draftGroupRoleClear = draftGroupRoleClear;
3777
+ exports.MEMBERS_TAB_KIND = MEMBERS_TAB_KIND;
3778
+ exports.MEMBERS_TAB_KEY = MEMBERS_TAB_KEY;
3779
+ exports.groupByRelationship = groupByRelationship;
3780
+ return module.exports;
3781
+ }
3782
+ });