@op1/threads 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,781 @@
1
+ import type { Plugin } from "@opencode/plugin/tui";
2
+ import type { BoxRenderable, TextRenderable } from "@opentui/core";
3
+ import { z } from "zod";
4
+ import {
5
+ activityThreads,
6
+ activitySubtitle,
7
+ activityTime,
8
+ cleanRoleTitle,
9
+ type ActivityItem,
10
+ } from "./activity-model";
11
+ import { activityRail } from "./activity-rail";
12
+ import { themeColor, themeHue, themeMuted } from "./activity-theme";
13
+ import { ActivityPicker } from "./activity-picker";
14
+ import { ThreadsRpc, WorkerView } from "./rpc";
15
+
16
+ type Session = NonNullable<
17
+ ReturnType<Plugin.Context["data"]["session"]["get"]>
18
+ >;
19
+ const Link = z
20
+ .object({
21
+ workerID: z.string(),
22
+ coordinatorID: z.string(),
23
+ key: z.string(),
24
+ fingerprint: z.string(),
25
+ initialMessageID: z.string(),
26
+ reportMessageID: z.string(),
27
+ })
28
+ .strict();
29
+
30
+ export function activity(
31
+ ctx: Plugin.Context,
32
+ core: Pick<
33
+ typeof import("@opentui/core"),
34
+ | "BoxRenderable"
35
+ | "ScrollBoxRenderable"
36
+ | "TextRenderable"
37
+ | "TextAttributes"
38
+ | "RGBA"
39
+ >,
40
+ solid: Pick<typeof import("solid-js"), "createEffect" | "createSignal">,
41
+ Spinner:
42
+ | ReturnType<
43
+ typeof import("@opentui/solid/components").getComponentCatalogue
44
+ >["spinner"]
45
+ | undefined,
46
+ ) {
47
+ const { BoxRenderable, ScrollBoxRenderable, TextRenderable, TextAttributes } =
48
+ core;
49
+ const { createEffect, createSignal } = solid;
50
+ const fallbackColor = core.RGBA.fromHex("#808080");
51
+ const [revision, setRevision] = createSignal(0);
52
+ const [pins, savePins] = ctx.storage.store("activity-pins", {
53
+ initial: { ids: [] as string[] },
54
+ });
55
+ const [sections, saveSections] = ctx.storage.store("activity-sections", {
56
+ initial: { collapsed: [] as string[] },
57
+ });
58
+ const [threadState, saveThreadState] = ctx.storage.store("activity-threads", {
59
+ initial: { collapsed: [] as string[] },
60
+ });
61
+ const [dismissed, saveDismissed] = ctx.storage.store("activity-dismissed", {
62
+ initial: { ids: [] as string[] },
63
+ });
64
+ const closingRows = new Set<string>();
65
+ const sessions = new Map<string, Session>();
66
+ const workers = new Map<string, z.infer<typeof WorkerView>>();
67
+ const deleted = new Set<string>();
68
+ const abort = new AbortController();
69
+ const rpc = ctx.client.rpc(ThreadsRpc);
70
+ let stopped = false;
71
+ let loading = false;
72
+ let lastError: string | undefined;
73
+ let render = () => {};
74
+ const changed = () => {
75
+ if (!stopped) setRevision((value) => value + 1);
76
+ };
77
+ const error = (value: unknown) => {
78
+ const detail = z.object({ message: z.string() }).safeParse(value);
79
+ const message = `Activity: ${detail.success ? detail.data.message : String(value)}`;
80
+ if (!stopped && message !== lastError)
81
+ ctx.ui.toast.show({ message, variant: "error" });
82
+ lastError = message;
83
+ };
84
+ async function pin(id: string) {
85
+ try {
86
+ await savePins((draft) => {
87
+ draft.ids = draft.ids.includes(id)
88
+ ? draft.ids.filter((value) => value !== id)
89
+ : [...draft.ids, id];
90
+ });
91
+ changed();
92
+ } catch (value) {
93
+ error(value);
94
+ }
95
+ }
96
+ async function restore(ids: string[]) {
97
+ if (!ids.some((id) => dismissed.ids.includes(id))) return;
98
+ await saveDismissed((draft) => {
99
+ draft.ids = draft.ids.filter((id) => !ids.includes(id));
100
+ });
101
+ changed();
102
+ }
103
+ async function focus(id: string) {
104
+ try {
105
+ await restore([id]);
106
+ if (!stopped) ctx.ui.tabs.focus(id);
107
+ } catch (value) {
108
+ error(value);
109
+ }
110
+ }
111
+ async function close(id: string) {
112
+ if (stopped || closingRows.has(id)) return;
113
+ closingRows.add(id);
114
+ try {
115
+ if (
116
+ ctx.ui.tabs.list().some((tab) => tab.sessionID === id) &&
117
+ !ctx.ui.tabs.close(id)
118
+ )
119
+ return;
120
+ await saveDismissed((draft) => {
121
+ if (!draft.ids.includes(id)) draft.ids.push(id);
122
+ });
123
+ } catch (value) {
124
+ error(value);
125
+ } finally {
126
+ closingRows.delete(id);
127
+ changed();
128
+ }
129
+ }
130
+ async function toggleSection(name: string) {
131
+ try {
132
+ await saveSections((draft) => {
133
+ draft.collapsed = draft.collapsed.includes(name)
134
+ ? draft.collapsed.filter((value) => value !== name)
135
+ : [...draft.collapsed, name];
136
+ });
137
+ changed();
138
+ } catch (value) {
139
+ error(value);
140
+ }
141
+ }
142
+ async function toggleThread(id: string) {
143
+ try {
144
+ await saveThreadState((draft) => {
145
+ draft.collapsed = draft.collapsed.includes(id)
146
+ ? draft.collapsed.filter((value) => value !== id)
147
+ : [...draft.collapsed, id];
148
+ });
149
+ changed();
150
+ } catch (value) {
151
+ error(value);
152
+ }
153
+ }
154
+ function items(includeDismissed = false) {
155
+ revision();
156
+ const tabs = new Map(ctx.ui.tabs.list().map((tab) => [tab.sessionID, tab]));
157
+ const merged = new Map(sessions);
158
+ for (const id of [
159
+ ...tabs.keys(),
160
+ ...pins.ids,
161
+ ...[...workers.values()].map((worker) => worker.coordinatorID),
162
+ ]) {
163
+ const session = ctx.data.session.get(id);
164
+ if (session) merged.set(id, session);
165
+ }
166
+ const result: ActivityItem[] = [];
167
+ const coordinators = new Set(
168
+ [...workers.values()].map((worker) => worker.coordinatorID),
169
+ );
170
+ for (const saved of merged.values()) {
171
+ const session = ctx.data.session.get(saved.id) ?? saved;
172
+ if (deleted.has(session.id) || session.parentID || session.time.archived)
173
+ continue;
174
+ const tab = tabs.get(session.id);
175
+ const isDismissed = dismissed.ids.includes(session.id);
176
+ if (!includeDismissed && isDismissed && !tab?.active) continue;
177
+ const link = Link.safeParse(session.metadata?.opThreads);
178
+ const worker = workers.get(session.id);
179
+ const attention = tab
180
+ ? tab.attention
181
+ : Boolean(
182
+ ctx.data.session.permission.list(session.id)?.length ||
183
+ ctx.data.session.form.list(session.id)?.length,
184
+ );
185
+ const busy = tab
186
+ ? tab.busy
187
+ : ctx.data.session.status(session.id) === "running";
188
+ // Unknown history-worker visibility must not resurrect an auto-hidden worker.
189
+ if (
190
+ link.success &&
191
+ !tab &&
192
+ !busy &&
193
+ !attention &&
194
+ !(includeDismissed && isDismissed)
195
+ )
196
+ continue;
197
+ const role =
198
+ worker &&
199
+ link.success &&
200
+ link.data.workerID === session.id &&
201
+ link.data.coordinatorID === worker.coordinatorID
202
+ ? "Worker"
203
+ : coordinators.has(session.id)
204
+ ? "Main"
205
+ : undefined;
206
+ result.push({
207
+ id: session.id,
208
+ title: cleanRoleTitle(
209
+ tab?.title ?? session.title ?? "Untitled",
210
+ role !== undefined,
211
+ ),
212
+ subtitle: activitySubtitle({
213
+ directory: session.location.directory,
214
+ project: ctx.data.project.get(session.projectID),
215
+ role,
216
+ }),
217
+ updated: activityTime(session.time),
218
+ active: tab?.active ?? false,
219
+ attention,
220
+ busy,
221
+ unread: tab?.unread,
222
+ pinned: pins.ids.includes(session.id),
223
+ hidden: includeDismissed && isDismissed ? false : worker?.hidden ?? false,
224
+ open: Boolean(tab),
225
+ coordinatorID: role === "Worker" ? worker?.coordinatorID : undefined,
226
+ });
227
+ }
228
+ return activityThreads(result);
229
+ }
230
+ async function resolveSession(id: string) {
231
+ if (stopped || sessions.has(id) || deleted.has(id)) return;
232
+ try {
233
+ const result = await ctx.client.session.get(
234
+ { sessionID: id },
235
+ { signal: abort.signal },
236
+ );
237
+ if (!stopped) sessions.set(id, result);
238
+ } catch (value) {
239
+ const missing = z
240
+ .object({
241
+ _tag: z.literal("SessionNotFoundError"),
242
+ sessionID: z.string(),
243
+ })
244
+ .safeParse(value);
245
+ if (!missing.success || missing.data.sessionID !== id) throw value;
246
+ deleted.add(id);
247
+ if (pins.ids.includes(id))
248
+ await savePins((draft) => {
249
+ draft.ids = draft.ids.filter((value) => value !== id);
250
+ });
251
+ await restore([id]);
252
+ }
253
+ }
254
+ async function load() {
255
+ if (loading || stopped) return;
256
+ loading = true;
257
+ try {
258
+ const page = await ctx.client.session.list(
259
+ {
260
+ parentID: null,
261
+ limit: 100,
262
+ order: "desc",
263
+ },
264
+ { signal: abort.signal },
265
+ );
266
+ if (stopped) return;
267
+ for (const session of page.data) sessions.set(session.id, session);
268
+ for (const id of new Set([...pins.ids, ...dismissed.ids]))
269
+ await resolveSession(id);
270
+ const ids = [
271
+ ...new Set(
272
+ [...sessions.values()].flatMap((session) => {
273
+ const link = Link.safeParse(session.metadata?.opThreads);
274
+ return link.success
275
+ ? [session.id, link.data.coordinatorID]
276
+ : [session.id];
277
+ }),
278
+ ),
279
+ ];
280
+ for (let index = 0; index < ids.length && !stopped; index += 100) {
281
+ const result = await rpc.snapshot(
282
+ { coordinatorIDs: ids.slice(index, index + 100) },
283
+ {
284
+ signal: abort.signal,
285
+ location: ctx.location ?? ctx.data.location.default(),
286
+ },
287
+ );
288
+ for (const worker of result.workers) workers.set(worker.workerID, worker);
289
+ }
290
+ for (const id of new Set(
291
+ [...workers.values()].map((worker) => worker.coordinatorID),
292
+ )) {
293
+ await resolveSession(id);
294
+ }
295
+ lastError = undefined;
296
+ } catch (value) {
297
+ error(value);
298
+ } finally {
299
+ loading = false;
300
+ changed();
301
+ }
302
+ }
303
+ async function actions(item: ActivityItem, hasChildren: boolean) {
304
+ const action = await ctx.ui.dialog.select({
305
+ title: item.title,
306
+ options: [
307
+ { title: item.pinned ? "Unpin" : "Pin", value: "pin" },
308
+ {
309
+ title: "Close from Activity (keep history)",
310
+ value: "close",
311
+ },
312
+ ...(hasChildren
313
+ ? [{
314
+ title: threadState.collapsed.includes(item.id)
315
+ ? "Expand workers"
316
+ : "Collapse workers",
317
+ value: "workers",
318
+ }]
319
+ : []),
320
+ ],
321
+ });
322
+ if (stopped) return;
323
+ if (action === "pin") await pin(item.id);
324
+ if (action === "close") await close(item.id);
325
+ if (action === "workers") await toggleThread(item.id);
326
+ }
327
+ const rail = activityRail(ctx, core, (content) => {
328
+ const runningIndicator = (id: string) => {
329
+ if (!Spinner) return;
330
+ const node = new Spinner(ctx.renderer, {
331
+ id: `activity-running-${id}`,
332
+ frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
333
+ interval: 80,
334
+ color: themeColor(ctx.theme.text, fallbackColor),
335
+ });
336
+ if ("color" in node) return node;
337
+ node.destroy();
338
+ };
339
+ const text = (value: string) =>
340
+ new TextRenderable(ctx.renderer, {
341
+ content: value,
342
+ fg: themeColor(ctx.theme.text, fallbackColor),
343
+ height: 1,
344
+ flexShrink: 0,
345
+ });
346
+ const header = text("Activity");
347
+ header.attributes = TextAttributes.BOLD;
348
+ content.add(header);
349
+ const fresh = text("+ New session");
350
+ fresh.id = "activity-new-session";
351
+ fresh.marginTop = 1;
352
+ fresh.marginBottom = 1;
353
+ fresh.onMouseUp = (event) => {
354
+ event.stopPropagation();
355
+ if (event.button === 0) ctx.keymap.dispatch("session.new");
356
+ };
357
+ content.add(fresh);
358
+ const scroll = new ScrollBoxRenderable(ctx.renderer, {
359
+ flexGrow: 1,
360
+ scrollY: true,
361
+ scrollX: false,
362
+ });
363
+ content.add(scroll);
364
+ const rows = new Map<
365
+ string,
366
+ {
367
+ box: BoxRenderable;
368
+ selected: TextRenderable;
369
+ status: BoxRenderable;
370
+ marker: TextRenderable;
371
+ spinner: ReturnType<typeof runningIndicator>;
372
+ title: TextRenderable;
373
+ subtitle: TextRenderable;
374
+ workers: TextRenderable | undefined;
375
+ pin: TextRenderable;
376
+ close: TextRenderable;
377
+ }
378
+ >();
379
+ const headings = new Map<
380
+ string,
381
+ { box: BoxRenderable; label: TextRenderable }
382
+ >();
383
+ const help = text("/activities");
384
+ content.add(help);
385
+ render = () => {
386
+ const fg = themeColor(ctx.theme.text, fallbackColor);
387
+ const muted = themeMuted(ctx.theme.text, fg);
388
+ const background =
389
+ ctx.theme.background.raised?.base ??
390
+ themeColor(ctx.theme.background, fallbackColor);
391
+ const accent = themeHue(ctx.theme.hue?.accent, fg, background);
392
+ const workerColor = themeHue(ctx.theme.hue?.purple, fg, background);
393
+ const runningColor = themeColor(ctx.theme.text.feedback.info, fg);
394
+ const attentionColor = themeColor(ctx.theme.text.feedback.warning, fg);
395
+ header.fg = accent;
396
+ fresh.fg = accent;
397
+ help.fg = muted;
398
+ const desired: (BoxRenderable | TextRenderable)[] = [];
399
+ const keep = new Set<string>();
400
+ const groups = items();
401
+ for (const [name, group] of groups) {
402
+ let heading = headings.get(name);
403
+ if (!heading) {
404
+ const box = new BoxRenderable(ctx.renderer, {
405
+ id: `activity-section-${encodeURIComponent(name)}`,
406
+ flexDirection: "column",
407
+ flexShrink: 0,
408
+ shouldFill: false,
409
+ onMouseUp(event) {
410
+ event.stopPropagation();
411
+ if (event.button === 0) void toggleSection(name);
412
+ },
413
+ });
414
+ const label = text("");
415
+ label.attributes = TextAttributes.BOLD;
416
+ box.add(label);
417
+ heading = { box, label };
418
+ headings.set(name, heading);
419
+ }
420
+ const collapsed = sections.collapsed.includes(name);
421
+ heading.box.border = desired.length ? ["top"] : false;
422
+ heading.box.borderColor = themeColor(ctx.theme.border, fg);
423
+ heading.box.height = desired.length ? 3 : 2;
424
+ heading.label.fg =
425
+ name === "Priority"
426
+ ? attentionColor
427
+ : name === "Pinned"
428
+ ? workerColor
429
+ : accent;
430
+ const count = group.reduce(
431
+ (total, thread) => total + 1 + thread.children.length,
432
+ 0,
433
+ );
434
+ heading.label.content = `${collapsed ? "▸" : "▾"} ${name} (${count})`;
435
+ desired.push(heading.box);
436
+ if (collapsed) continue;
437
+ const displayed = group.flatMap((thread) => {
438
+ const collapsed = threadState.collapsed.includes(thread.item.id);
439
+ return [
440
+ {
441
+ item: thread.item,
442
+ depth: 0,
443
+ children: thread.children.length,
444
+ collapsed,
445
+ status: collapsed ? thread.status : thread.item,
446
+ },
447
+ ...(collapsed
448
+ ? []
449
+ : thread.children.map((item) => ({
450
+ item,
451
+ depth: 1,
452
+ children: 0,
453
+ collapsed: false,
454
+ status: item,
455
+ }))),
456
+ ];
457
+ });
458
+ for (const { item, depth, children, collapsed, status } of displayed) {
459
+ keep.add(item.id);
460
+ let row = rows.get(item.id);
461
+ if (!row) {
462
+ const box = new BoxRenderable(ctx.renderer, {
463
+ id: `activity-row-${item.id}`,
464
+ height: 3,
465
+ flexShrink: 0,
466
+ flexDirection: "column",
467
+ });
468
+ const line = new BoxRenderable(ctx.renderer, {
469
+ height: 1,
470
+ flexShrink: 0,
471
+ flexDirection: "row",
472
+ });
473
+ const selected = text("");
474
+ selected.id = `activity-selected-${item.id}`;
475
+ selected.width = 2;
476
+ const status = new BoxRenderable(ctx.renderer, {
477
+ width: 2,
478
+ height: 1,
479
+ flexShrink: 0,
480
+ });
481
+ const marker = text("");
482
+ marker.id = `activity-marker-${item.id}`;
483
+ status.add(marker);
484
+ const title = text("");
485
+ title.id = `activity-title-${item.id}`;
486
+ title.flexShrink = 1;
487
+ title.minWidth = 0;
488
+ title.wrapMode = "none";
489
+ title.truncate = true;
490
+ const pinButton = text("");
491
+ pinButton.id = `activity-pin-${item.id}`;
492
+ pinButton.marginLeft = 2;
493
+ pinButton.width = 4;
494
+ const closeButton = text("");
495
+ closeButton.id = `activity-close-${item.id}`;
496
+ closeButton.marginLeft = 1;
497
+ closeButton.width = 4;
498
+ row = {
499
+ box,
500
+ selected,
501
+ status,
502
+ marker,
503
+ spinner: undefined,
504
+ title,
505
+ subtitle: text(""),
506
+ workers: undefined,
507
+ pin: pinButton,
508
+ close: closeButton,
509
+ };
510
+ line.add(selected);
511
+ line.add(status);
512
+ line.add(title);
513
+ line.add(pinButton);
514
+ line.add(closeButton);
515
+ box.add(line);
516
+ row.subtitle.id = `activity-subtitle-${item.id}`;
517
+ row.subtitle.wrapMode = "none";
518
+ row.subtitle.truncate = true;
519
+ box.add(row.subtitle);
520
+ rows.set(item.id, row);
521
+ }
522
+ row.box.marginLeft = depth * 3;
523
+ row.box.height = children ? 5 : 3;
524
+ const running = status.busy && !status.attention;
525
+ if (running && !row.spinner) {
526
+ row.spinner = runningIndicator(item.id);
527
+ if (row.spinner) row.status.add(row.spinner);
528
+ } else if (!running && row.spinner) {
529
+ row.spinner.destroy();
530
+ row.spinner = undefined;
531
+ }
532
+ if (row.spinner) row.spinner.color = runningColor;
533
+ row.selected.content = item.active ? "> " : " ";
534
+ row.selected.fg = accent;
535
+ row.selected.attributes = item.active ? TextAttributes.BOLD : 0;
536
+ row.marker.content = status.attention
537
+ ? "?"
538
+ : running
539
+ ? "⠋"
540
+ : status.unread === "error"
541
+ ? "!"
542
+ : status.unread
543
+ ? "•"
544
+ : " ";
545
+ row.marker.visible = !row.spinner;
546
+ row.marker.fg = status.attention
547
+ ? attentionColor
548
+ : running
549
+ ? runningColor
550
+ : status.unread === "error"
551
+ ? themeColor(ctx.theme.text.feedback.error, fg)
552
+ : accent;
553
+ row.title.content = item.title;
554
+ row.title.fg = status.attention
555
+ ? attentionColor
556
+ : item.active
557
+ ? accent
558
+ : fg;
559
+ row.subtitle.fg = muted;
560
+ row.title.attributes = item.active ? TextAttributes.BOLD : 0;
561
+ row.subtitle.content = ` ${item.subtitle}`;
562
+ if (children && !row.workers) {
563
+ row.workers = text("");
564
+ row.workers.id = `activity-workers-${item.id}`;
565
+ row.workers.marginLeft = 3;
566
+ row.workers.marginTop = 1;
567
+ row.workers.attributes = TextAttributes.BOLD;
568
+ row.workers.onMouseUp = (event) => {
569
+ event.stopPropagation();
570
+ if (event.button === 0) void toggleThread(item.id);
571
+ };
572
+ row.box.add(row.workers);
573
+ } else if (!children && row.workers) {
574
+ row.workers.destroy();
575
+ row.workers = undefined;
576
+ }
577
+ if (row.workers) {
578
+ row.workers.content = `${collapsed ? "▸" : "▾"} Workers (${children})`;
579
+ row.workers.fg = workerColor;
580
+ }
581
+ row.pin.content = item.pinned ? "[◆] " : "[◇] ";
582
+ row.pin.fg = item.pinned ? workerColor : muted;
583
+ row.pin.attributes = TextAttributes.BOLD;
584
+ row.pin.onMouseUp = (event) => {
585
+ event.stopPropagation();
586
+ if (event.button === 0) void pin(item.id);
587
+ };
588
+ row.close.content = "[×] ";
589
+ row.close.fg = muted;
590
+ row.close.attributes = TextAttributes.BOLD;
591
+ row.close.onMouseUp = (event) => {
592
+ event.stopPropagation();
593
+ if (event.button === 0) void close(item.id);
594
+ };
595
+ row.box.onMouseUp = (event) => {
596
+ event.stopPropagation();
597
+ if (event.button === 0) void focus(item.id);
598
+ if (event.button === 2) void actions(item, children > 0);
599
+ };
600
+ desired.push(row.box);
601
+ }
602
+ }
603
+ for (const [id, row] of rows)
604
+ if (!keep.has(id)) {
605
+ row.box.destroyRecursively();
606
+ rows.delete(id);
607
+ }
608
+ for (const [name, heading] of headings)
609
+ if (!groups.has(name)) {
610
+ heading.box.destroyRecursively();
611
+ headings.delete(name);
612
+ }
613
+ for (const [index, node] of desired.entries())
614
+ if (scroll.getChildren()[index] !== node) scroll.add(node, index);
615
+ };
616
+ render();
617
+ return () => {
618
+ render = () => {};
619
+ };
620
+ });
621
+ rail.toggle(ctx.options.activity !== false);
622
+ let refreshTimer: ReturnType<typeof setTimeout> | undefined;
623
+ const stopEvents = ctx.data.listen(({ details }) => {
624
+ if (!details.type.startsWith("session.")) return;
625
+ if (details.type === "session.deleted") {
626
+ const sessionID = details.data.sessionID;
627
+ deleted.add(sessionID);
628
+ sessions.delete(sessionID);
629
+ workers.delete(sessionID);
630
+ void restore([sessionID]).catch(error);
631
+ if (pins.ids.includes(sessionID))
632
+ void savePins((draft) => {
633
+ draft.ids = draft.ids.filter((id) => id !== sessionID);
634
+ }).catch(error);
635
+ changed();
636
+ }
637
+ if (details.type === "session.created") {
638
+ deleted.delete(details.data.sessionID);
639
+ changed();
640
+ }
641
+ if (!refreshTimer)
642
+ refreshTimer = setTimeout(() => {
643
+ refreshTimer = undefined;
644
+ void load();
645
+ }, 1000);
646
+ });
647
+ const timer = setInterval(() => {
648
+ rail.invalidate();
649
+ changed();
650
+ void load();
651
+ }, 30000);
652
+ const removeSlot = ctx.ui.slot({
653
+ append: "app",
654
+ render() {
655
+ let selected: string | undefined;
656
+ createEffect(() => {
657
+ const route = ctx.ui.router.current();
658
+ const id = route.type === "session" ? route.sessionID : undefined;
659
+ if (id === selected) return;
660
+ selected = id;
661
+ if (id && !closingRows.has(id)) void restore([id]).catch(error);
662
+ });
663
+ createEffect(() => {
664
+ ctx.themeMode;
665
+ themeColor(ctx.theme.text, fallbackColor);
666
+ themeColor(ctx.theme.border, fallbackColor);
667
+ sections.collapsed.length;
668
+ threadState.collapsed.length;
669
+ items();
670
+ render();
671
+ rail.invalidate();
672
+ });
673
+ ctx.keymap.layer(() => ({
674
+ mode: "global",
675
+ commands: [
676
+ {
677
+ id: "threads.activity.threads",
678
+ title: "Expand/collapse managed workers",
679
+ palette: true,
680
+ slash: { name: "activity-threads" },
681
+ async run() {
682
+ const id = await ctx.ui.dialog.select({
683
+ title: "Managed worker stacks",
684
+ options: [...items().values()]
685
+ .flat()
686
+ .filter((thread) => thread.children.length)
687
+ .map((thread) => ({
688
+ title: `${threadState.collapsed.includes(thread.item.id) ? "Expand" : "Collapse"} ${thread.item.title}`,
689
+ description: `${thread.children.length} workers`,
690
+ value: thread.item.id,
691
+ })),
692
+ });
693
+ if (id && !stopped) await toggleThread(id);
694
+ },
695
+ },
696
+ {
697
+ id: "threads.activity.sections",
698
+ title: "Expand/collapse Activity section",
699
+ palette: true,
700
+ slash: { name: "activity-sections" },
701
+ async run() {
702
+ const name = await ctx.ui.dialog.select({
703
+ title: "Activity sections",
704
+ options: [...items()].map(([name, group]) => ({
705
+ title: `${sections.collapsed.includes(name) ? "Expand" : "Collapse"} ${name}`,
706
+ description: `${group.reduce((total, thread) => total + 1 + thread.children.length, 0)} conversations`,
707
+ value: name,
708
+ })),
709
+ });
710
+ if (name && !stopped) await toggleSection(name);
711
+ },
712
+ },
713
+ {
714
+ id: "threads.activity.toggle",
715
+ title: "Toggle Activity sidebar",
716
+ palette: true,
717
+ slash: { name: "activity" },
718
+ run() {
719
+ rail.toggle();
720
+ },
721
+ },
722
+ {
723
+ id: "threads.activity.pin",
724
+ title: "Pin/unpin current Activity conversation",
725
+ palette: true,
726
+ slash: { name: "pin" },
727
+ async run() {
728
+ const route = ctx.ui.router.current();
729
+ if (route.type === "session") await pin(route.sessionID);
730
+ },
731
+ },
732
+ {
733
+ id: "threads.activity.choose",
734
+ title: "Choose Activity conversation",
735
+ palette: true,
736
+ slash: { name: "activities" },
737
+ run() {
738
+ const route = ctx.ui.router.current();
739
+ ctx.ui.dialog.show(() => ActivityPicker({
740
+ ctx,
741
+ fallbackColor,
742
+ current: route.type === "session" ? route.sessionID : undefined,
743
+ items: () => [...items(true)].flatMap(([category, group]) =>
744
+ group
745
+ .flatMap((thread) => [thread.item, ...thread.children])
746
+ .map((item) => ({
747
+ ...item,
748
+ category,
749
+ closed: dismissed.ids.includes(item.id),
750
+ })),
751
+ ),
752
+ pin,
753
+ open: focus,
754
+ }));
755
+ },
756
+ },
757
+ ],
758
+ }));
759
+ return null;
760
+ },
761
+ });
762
+ void load();
763
+ return {
764
+ mounted: rail.mounted,
765
+ isDismissed: (id: string) => closingRows.has(id) || dismissed.ids.includes(id),
766
+ restore,
767
+ updateWorkers(values: z.infer<typeof WorkerView>[]) {
768
+ for (const worker of values) workers.set(worker.workerID, worker);
769
+ changed();
770
+ },
771
+ dispose() {
772
+ stopped = true;
773
+ abort.abort();
774
+ clearInterval(timer);
775
+ clearTimeout(refreshTimer);
776
+ stopEvents();
777
+ removeSlot();
778
+ rail.dispose();
779
+ },
780
+ };
781
+ }