@gperezmz/bb-plugin-thread-glance 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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +20 -0
  3. package/THIRD_PARTY_NOTICES.md +230 -0
  4. package/app.tsx +12 -0
  5. package/components/ui/alert-dialog.tsx +142 -0
  6. package/components/ui/button.tsx +59 -0
  7. package/components/ui/card.tsx +83 -0
  8. package/components/ui/coarse-pointer-sizing.ts +66 -0
  9. package/components/ui/context-menu.tsx +355 -0
  10. package/components/ui/dialog.tsx +416 -0
  11. package/components/ui/dropdown-menu.tsx +640 -0
  12. package/components/ui/hooks/use-compact-viewport.tsx +37 -0
  13. package/components/ui/hooks/use-media-query.ts +70 -0
  14. package/components/ui/hooks/use-pointer-coarse.ts +7 -0
  15. package/components/ui/hover-card.tsx +36 -0
  16. package/components/ui/icon-extended.tsx +333 -0
  17. package/components/ui/icon-registry.ts +189 -0
  18. package/components/ui/icon.tsx +444 -0
  19. package/components/ui/input.tsx +30 -0
  20. package/components/ui/menu-item-hover.tsx +93 -0
  21. package/components/ui/motion.ts +4 -0
  22. package/components/ui/overlay-trigger.ts +84 -0
  23. package/components/ui/popover.tsx +223 -0
  24. package/components/ui/responsive-overlay.tsx +823 -0
  25. package/components/ui/tooltip.tsx +63 -0
  26. package/dist/app.css +2 -0
  27. package/dist/app.js +66 -0
  28. package/dist/app.meta.json +11 -0
  29. package/dist/server.js +71 -0
  30. package/dist/server.js.map +6 -0
  31. package/dist/server.meta.json +11 -0
  32. package/features/thread-list/components/Dialogs.tsx +299 -0
  33. package/features/thread-list/components/FoldRows.tsx +147 -0
  34. package/features/thread-list/components/GroupSection.tsx +370 -0
  35. package/features/thread-list/components/ProviderBadge.tsx +57 -0
  36. package/features/thread-list/components/PullRequestBadge.tsx +50 -0
  37. package/features/thread-list/components/Rails.tsx +28 -0
  38. package/features/thread-list/components/RenameEditor.tsx +118 -0
  39. package/features/thread-list/components/RowMenu.tsx +168 -0
  40. package/features/thread-list/components/SplitMiniMap.tsx +55 -0
  41. package/features/thread-list/components/ThreadDetails.tsx +138 -0
  42. package/features/thread-list/components/ThreadList.tsx +855 -0
  43. package/features/thread-list/components/ThreadRowView.tsx +631 -0
  44. package/features/thread-list/components/Toolbar.tsx +296 -0
  45. package/features/thread-list/components/controller.ts +64 -0
  46. package/features/thread-list/components/glyphs.tsx +129 -0
  47. package/features/thread-list/components/row-card.ts +127 -0
  48. package/features/thread-list/data/storage.ts +18 -0
  49. package/features/thread-list/data/useAutoExpand.ts +53 -0
  50. package/features/thread-list/data/useClientPreferences.ts +20 -0
  51. package/features/thread-list/data/useNotes.ts +41 -0
  52. package/features/thread-list/data/useNow.ts +19 -0
  53. package/features/thread-list/data/usePreferences.ts +126 -0
  54. package/features/thread-list/data/useScheduled.ts +43 -0
  55. package/features/thread-list/data/useStamps.ts +68 -0
  56. package/features/thread-list/icons.ts +44 -0
  57. package/features/thread-list/index.ts +2 -0
  58. package/features/thread-list/model/attention.ts +89 -0
  59. package/features/thread-list/model/counters.ts +67 -0
  60. package/features/thread-list/model/details.ts +79 -0
  61. package/features/thread-list/model/drag.ts +104 -0
  62. package/features/thread-list/model/expansion.ts +71 -0
  63. package/features/thread-list/model/families.ts +297 -0
  64. package/features/thread-list/model/groups.ts +263 -0
  65. package/features/thread-list/model/labels.ts +50 -0
  66. package/features/thread-list/model/layout.ts +61 -0
  67. package/features/thread-list/model/menu.ts +78 -0
  68. package/features/thread-list/model/move.ts +46 -0
  69. package/features/thread-list/model/notes.ts +52 -0
  70. package/features/thread-list/model/provider-mark.ts +59 -0
  71. package/features/thread-list/model/share.ts +101 -0
  72. package/features/thread-list/model/sort.ts +78 -0
  73. package/features/thread-list/model/state.ts +330 -0
  74. package/features/thread-list/model/time.ts +107 -0
  75. package/features/thread-list/model/toggles.ts +99 -0
  76. package/features/thread-list/model/view.ts +669 -0
  77. package/features/thread-list/model/windowing.ts +27 -0
  78. package/features/thread-list/testing/fixtures.ts +162 -0
  79. package/hooks/useBrowserDimmingModal.ts +5 -0
  80. package/lib/portal-scope.ts +15 -0
  81. package/lib/utils.ts +17 -0
  82. package/package.json +96 -0
  83. package/server/cli.ts +107 -0
  84. package/server/failures.ts +136 -0
  85. package/server/import.ts +144 -0
  86. package/server/notes.ts +236 -0
  87. package/server/preference-store.ts +96 -0
  88. package/server/scheduled.ts +129 -0
  89. package/server/serial.ts +12 -0
  90. package/server/stamps.ts +142 -0
  91. package/server/startup.ts +121 -0
  92. package/server.ts +126 -0
  93. package/shared/contract.ts +73 -0
  94. package/shared/preferences.ts +241 -0
  95. package/shared/signals.ts +71 -0
  96. package/skills/thread-glance/SKILL.md +69 -0
  97. package/tsconfig.json +36 -0
  98. package/types/global-jsx.d.ts +10 -0
@@ -0,0 +1,53 @@
1
+ // Transient auto-expansion. Transitions are diffed against the previous
2
+ // render, as bb does; nothing here is persisted.
3
+ import { useCallback, useRef, useState } from "react";
4
+ import type { Forest } from "../model/families";
5
+ import {
6
+ detectTransitions,
7
+ mergeTargets,
8
+ pruneTargets,
9
+ snapshotOf,
10
+ type Snapshot,
11
+ type Targets,
12
+ } from "../model/expansion";
13
+
14
+ export interface AutoExpand {
15
+ targets: Targets;
16
+ /** A user's collapse: drop targets it hides, until the next transition. */
17
+ prune(drop: (id: string) => boolean): void;
18
+ }
19
+
20
+ export function useAutoExpand(forest: Forest | null, activeThreadId: string | null): AutoExpand {
21
+ const previous = useRef<Snapshot | null>(null);
22
+ const lastForest = useRef<Forest | null>(null);
23
+ const lastActive = useRef<string | null | undefined>(undefined);
24
+ const [targets, setTargets] = useState<Targets>(() => new Map());
25
+ const targetsRef = useRef(targets);
26
+ targetsRef.current = targets;
27
+
28
+ // Derived during render so the first paint is already expanded. Idempotent:
29
+ // a repeated render diffs against the snapshot it stored.
30
+ let current = targets;
31
+ if (forest !== null && (forest !== lastForest.current || activeThreadId !== lastActive.current)) {
32
+ const snapshot = snapshotOf(forest, activeThreadId);
33
+ const added = detectTransitions(previous.current, snapshot);
34
+ previous.current = snapshot;
35
+ lastForest.current = forest;
36
+ lastActive.current = activeThreadId;
37
+ current = mergeTargets(targets, added);
38
+ if (current !== targets) {
39
+ targetsRef.current = current;
40
+ setTargets(current);
41
+ }
42
+ }
43
+
44
+ const prune = useCallback((drop: (id: string) => boolean) => {
45
+ const next = pruneTargets(targetsRef.current, drop);
46
+ if (next !== targetsRef.current) {
47
+ targetsRef.current = next;
48
+ setTargets(next);
49
+ }
50
+ }, []);
51
+
52
+ return { targets: current, prune };
53
+ }
@@ -0,0 +1,20 @@
1
+ // Per-client preferences: filter and density, localStorage only.
2
+ import { useCallback, useState } from "react";
3
+ import {
4
+ CLIENT_PREFERENCES_STORAGE_KEY,
5
+ parseClientPreferences,
6
+ type ClientPreferences,
7
+ } from "@/shared/preferences";
8
+ import { readJson, writeJson } from "./storage";
9
+
10
+ export function useClientPreferences(): [ClientPreferences, (patch: Partial<ClientPreferences>) => void] {
11
+ const [value, setValue] = useState(() => parseClientPreferences(readJson(CLIENT_PREFERENCES_STORAGE_KEY)));
12
+ const update = useCallback((patch: Partial<ClientPreferences>) => {
13
+ setValue((current) => {
14
+ const next = { ...current, ...patch };
15
+ writeJson(CLIENT_PREFERENCES_STORAGE_KEY, next);
16
+ return next;
17
+ });
18
+ }, []);
19
+ return [value, update];
20
+ }
@@ -0,0 +1,41 @@
1
+ // Thread notes from the plugin server, kept live over realtime.
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useRealtime, useRealtimeConnectionState, useRpc } from "@get-bb/plugin-sdk/app";
4
+ import type { RpcContract } from "@/shared/contract";
5
+ import { CHANNELS, threadNotesSchema, type NotesSignal, type ThreadNotes } from "@/shared/signals";
6
+
7
+ export function useNotes(): Readonly<Record<string, ThreadNotes>> {
8
+ const rpc = useRpc<RpcContract>();
9
+ const [notes, setNotes] = useState<Readonly<Record<string, ThreadNotes>>>({});
10
+ const connection = useRealtimeConnectionState();
11
+ const wasConnected = useRef(false);
12
+
13
+ const load = useCallback(() => {
14
+ rpc.call("listNotes", null).then(
15
+ (result) => setNotes(result.notes),
16
+ () => undefined,
17
+ );
18
+ }, [rpc]);
19
+
20
+ useEffect(load, [load]);
21
+ useEffect(() => {
22
+ if (connection !== "connected") return;
23
+ if (wasConnected.current) load();
24
+ wasConnected.current = true;
25
+ }, [connection, load]);
26
+
27
+ useRealtime(CHANNELS.notes, (payload) => {
28
+ const signal = payload as NotesSignal;
29
+ if (signal === null || typeof signal !== "object" || typeof signal.threadId !== "string") return;
30
+ const parsed = signal.notes === null ? null : threadNotesSchema.safeParse(signal.notes);
31
+ if (parsed !== null && !parsed.success) return;
32
+ setNotes((current) => {
33
+ const next = { ...current };
34
+ if (parsed === null) delete next[signal.threadId];
35
+ else next[signal.threadId] = parsed.data;
36
+ return next;
37
+ });
38
+ });
39
+
40
+ return notes;
41
+ }
@@ -0,0 +1,19 @@
1
+ // A clock that ticks each minute (row ages) and at the next deadline
2
+ // (scheduled → queued), so rows change without a request.
3
+ import { useEffect, useState } from "react";
4
+
5
+ export function useNow(nextDeadline: number | null): number {
6
+ const [now, setNow] = useState(() => Date.now());
7
+ useEffect(() => {
8
+ const tick = setInterval(() => setNow(Date.now()), 60_000);
9
+ return () => clearInterval(tick);
10
+ }, []);
11
+ useEffect(() => {
12
+ if (nextDeadline === null) return;
13
+ const wait = nextDeadline - Date.now();
14
+ if (wait <= 0 || wait > 2 ** 31 - 1) return;
15
+ const timeout = setTimeout(() => setNow(Date.now()), wait + 50);
16
+ return () => clearTimeout(timeout);
17
+ }, [nextDeadline]);
18
+ return now;
19
+ }
@@ -0,0 +1,126 @@
1
+ // Server-side preferences: kv via RPC, realtime to every window, a
2
+ // localStorage mirror for first paint, and debounced writes.
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { useRealtime, useRealtimeConnectionState, useRpc } from "@get-bb/plugin-sdk/app";
5
+ import { toast } from "sonner";
6
+ import type { RpcContract } from "@/shared/contract";
7
+ import { CHANNELS, type PreferenceSignal } from "@/shared/signals";
8
+ import {
9
+ BB_PREFERENCES_MIRROR_STORAGE_KEY,
10
+ coercePreferences,
11
+ isPreferenceKey,
12
+ parsePreference,
13
+ PREFERENCES_MIRROR_STORAGE_KEY,
14
+ type PreferenceKey,
15
+ type Preferences,
16
+ } from "@/shared/preferences";
17
+ import { readJson, writeJson } from "./storage";
18
+
19
+ const WRITE_DEBOUNCE_MS = 150;
20
+
21
+ export interface PreferencesState {
22
+ prefs: Preferences;
23
+ /** The server's values have arrived (or failed; the mirror stands in). */
24
+ hydrated: boolean;
25
+ update(patch: Partial<Preferences>): void;
26
+ }
27
+
28
+ export function usePreferences(): PreferencesState {
29
+ const rpc = useRpc<RpcContract>();
30
+ const [prefs, setPrefs] = useState<Preferences>(() =>
31
+ coercePreferences(readJson(PREFERENCES_MIRROR_STORAGE_KEY)),
32
+ );
33
+ const [hydrated, setHydrated] = useState(false);
34
+ const pending = useRef(new Map<PreferenceKey, unknown>());
35
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
36
+ const connection = useRealtimeConnectionState();
37
+ const wasConnected = useRef(false);
38
+
39
+ const load = useCallback(async () => {
40
+ try {
41
+ const { preferences } = await rpc.call("listPreferences", null);
42
+ const next = coercePreferences(preferences);
43
+ // Keys with a write in flight keep the local value.
44
+ for (const [key, value] of pending.current) (next as Record<string, unknown>)[key] = value;
45
+ setPrefs(next);
46
+ writeJson(PREFERENCES_MIRROR_STORAGE_KEY, next);
47
+ } finally {
48
+ setHydrated(true);
49
+ }
50
+ }, [rpc]);
51
+
52
+ useEffect(() => {
53
+ let cancelled = false;
54
+ (async () => {
55
+ try {
56
+ await rpc.call("importPreferences", { bbMirror: readJson(BB_PREFERENCES_MIRROR_STORAGE_KEY) });
57
+ } catch {
58
+ // Import is best effort; the defaults stand.
59
+ }
60
+ if (!cancelled) await load().catch(() => undefined);
61
+ })();
62
+ return () => {
63
+ cancelled = true;
64
+ };
65
+ }, [rpc, load]);
66
+
67
+ // Realtime signals aren't replayed: re-read after a reconnect.
68
+ useEffect(() => {
69
+ if (connection !== "connected") return;
70
+ if (wasConnected.current) void load().catch(() => undefined);
71
+ wasConnected.current = true;
72
+ }, [connection, load]);
73
+
74
+ useRealtime(CHANNELS.preferences, (payload) => {
75
+ const signal = payload as PreferenceSignal;
76
+ if (!isPreferenceKey(signal?.key) || pending.current.has(signal.key)) return;
77
+ const parsed = parsePreference(signal.key, signal.value);
78
+ if (!parsed.success) return;
79
+ setPrefs((current) => {
80
+ const next = { ...current, [signal.key]: parsed.value };
81
+ writeJson(PREFERENCES_MIRROR_STORAGE_KEY, next);
82
+ return next;
83
+ });
84
+ });
85
+
86
+ const flush = useCallback(() => {
87
+ const writes = [...pending.current];
88
+ pending.current.clear();
89
+ for (const [key, value] of writes) {
90
+ rpc.call("setPreference", { key, value }).catch((error: unknown) => {
91
+ toast.error(`Couldn't save the ${key} setting`, {
92
+ description: error instanceof Error ? error.message : String(error),
93
+ });
94
+ });
95
+ }
96
+ }, [rpc]);
97
+
98
+ useEffect(
99
+ () => () => {
100
+ if (timer.current !== null) clearTimeout(timer.current);
101
+ flush();
102
+ },
103
+ [flush],
104
+ );
105
+
106
+ const update = useCallback(
107
+ (patch: Partial<Preferences>) => {
108
+ setPrefs((current) => {
109
+ const next = { ...current, ...patch };
110
+ writeJson(PREFERENCES_MIRROR_STORAGE_KEY, next);
111
+ return next;
112
+ });
113
+ for (const [key, value] of Object.entries(patch)) {
114
+ if (isPreferenceKey(key)) pending.current.set(key, value);
115
+ }
116
+ if (timer.current !== null) clearTimeout(timer.current);
117
+ timer.current = setTimeout(() => {
118
+ timer.current = null;
119
+ flush();
120
+ }, WRITE_DEBOUNCE_MS);
121
+ },
122
+ [flush],
123
+ );
124
+
125
+ return { prefs, hydrated, update };
126
+ }
@@ -0,0 +1,43 @@
1
+ // Scheduled sends: one map pushed by the server, no per-row requests.
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { useRealtime, useRealtimeConnectionState, useRpc } from "@get-bb/plugin-sdk/app";
4
+ import type { RpcContract } from "@/shared/contract";
5
+ import { CHANNELS, type ScheduledSignal } from "@/shared/signals";
6
+
7
+ const NONE: Readonly<Record<string, number>> = {};
8
+
9
+ /** threadId → earliest future sendAt; empty until loaded or on error. */
10
+ export function useScheduled(): Readonly<Record<string, number>> {
11
+ const rpc = useRpc<RpcContract>();
12
+ const [scheduled, setScheduled] = useState<Readonly<Record<string, number>>>(NONE);
13
+ const connection = useRealtimeConnectionState();
14
+ const wasConnected = useRef(false);
15
+ const [reload, setReload] = useState(0);
16
+
17
+ useEffect(() => {
18
+ let cancelled = false;
19
+ rpc.call("listScheduled", null).then(
20
+ (result) => {
21
+ if (!cancelled) setScheduled(result.status === "ready" ? result.scheduled : NONE);
22
+ },
23
+ () => undefined,
24
+ );
25
+ return () => {
26
+ cancelled = true;
27
+ };
28
+ }, [rpc, reload]);
29
+
30
+ useEffect(() => {
31
+ if (connection !== "connected") return;
32
+ if (wasConnected.current) setReload((value) => value + 1);
33
+ wasConnected.current = true;
34
+ }, [connection]);
35
+
36
+ useRealtime(CHANNELS.scheduled, (payload) => {
37
+ const signal = payload as ScheduledSignal;
38
+ if (signal === null || typeof signal !== "object") return;
39
+ setScheduled(signal.status === "ready" && signal.scheduled ? signal.scheduled : NONE);
40
+ });
41
+
42
+ return scheduled;
43
+ }
@@ -0,0 +1,68 @@
1
+ // Thread stamps from the plugin server, kept live over realtime.
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useRealtime, useRealtimeConnectionState, useRpc } from "@get-bb/plugin-sdk/app";
4
+ import type { RpcContract } from "@/shared/contract";
5
+ import { CHANNELS, type StampSignal, type Stamps } from "@/shared/signals";
6
+
7
+ const EMPTY: Stamps = { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {} };
8
+ const KINDS = new Set(Object.keys(EMPTY));
9
+
10
+ export interface StampsState {
11
+ stamps: Stamps;
12
+ markSeen(threadIds: string[]): void;
13
+ clearSeen(threadIds: string[]): void;
14
+ }
15
+
16
+ export function useStamps(): StampsState {
17
+ const rpc = useRpc<RpcContract>();
18
+ const [stamps, setStamps] = useState<Stamps>(EMPTY);
19
+ const connection = useRealtimeConnectionState();
20
+ const wasConnected = useRef(false);
21
+
22
+ const load = useCallback(() => {
23
+ rpc.call("listStamps", null).then(
24
+ (result) => setStamps(result.stamps),
25
+ () => undefined,
26
+ );
27
+ }, [rpc]);
28
+
29
+ useEffect(load, [load]);
30
+ useEffect(() => {
31
+ if (connection !== "connected") return;
32
+ if (wasConnected.current) load();
33
+ wasConnected.current = true;
34
+ }, [connection, load]);
35
+
36
+ const apply = useCallback((signal: StampSignal) => {
37
+ if (!KINDS.has(signal.kind) || !Array.isArray(signal.threadIds)) return;
38
+ setStamps((current) => {
39
+ const map = { ...current[signal.kind] };
40
+ for (const id of signal.threadIds) {
41
+ if (signal.value === null) delete map[id];
42
+ else map[id] = signal.value;
43
+ }
44
+ return { ...current, [signal.kind]: map };
45
+ });
46
+ }, []);
47
+
48
+ useRealtime(CHANNELS.stamps, (payload) => apply(payload as StampSignal));
49
+
50
+ const markSeen = useCallback(
51
+ (threadIds: string[]) => {
52
+ if (threadIds.length === 0) return;
53
+ apply({ kind: "seenAt", threadIds, value: Date.now() });
54
+ rpc.call("markSeen", { threadIds }).catch(() => undefined);
55
+ },
56
+ [apply, rpc],
57
+ );
58
+ const clearSeen = useCallback(
59
+ (threadIds: string[]) => {
60
+ if (threadIds.length === 0) return;
61
+ apply({ kind: "seenAt", threadIds, value: null });
62
+ rpc.call("clearSeen", { threadIds }).catch(() => undefined);
63
+ },
64
+ [apply, rpc],
65
+ );
66
+
67
+ return { stamps, markSeen, clearSeen };
68
+ }
@@ -0,0 +1,44 @@
1
+ // Host icon names the list draws outside the state model. Components take
2
+ // names from here so one test can check them all against the host list.
3
+
4
+ export const ICONS = {
5
+ archive: "Archive",
6
+ unarchive: "ArchiveRestore",
7
+ more: "MoreHorizontal",
8
+ openInSplit: "Columns2",
9
+ copy: "Copy",
10
+ link: "ExternalLink",
11
+ markRead: "MailOpen",
12
+ markUnread: "Mail",
13
+ pin: "Pin",
14
+ unpin: "PinOff",
15
+ moveToSection: "SectionMove",
16
+ rename: "Edit",
17
+ remove: "Trash2",
18
+ details: "Info",
19
+ crossGroup: "FolderExport",
20
+ hidden: "EyeOff",
21
+ show: "Eye",
22
+ expand: "ChevronRight",
23
+ collapse: "ChevronDown",
24
+ moveUp: "ChevronUp",
25
+ newThread: "Plus",
26
+ newSection: "SectionAdd",
27
+ settings: "Settings",
28
+ customize: "SlidersHorizontal",
29
+ check: "Check",
30
+ pullRequest: "GitPullRequest",
31
+ branch: "GitBranch",
32
+ worktree: "FolderGit",
33
+ machine: "Laptop",
34
+ environmentFallback: "Zap",
35
+ filter: "FilterHorizontal",
36
+ drag: "DragDropVertical",
37
+ move: "MoveTo",
38
+ open: "ArrowUpRight",
39
+ } as const;
40
+
41
+ export const ROW_ICON_NAMES: readonly string[] = Object.values(ICONS);
42
+
43
+ /** Glyphs the group header counters draw. */
44
+ export const COUNTER_ICON_NAMES: readonly string[] = ["CircleQuestion", "CircleX", "CloudOff", "Loading"];
@@ -0,0 +1,2 @@
1
+ // The sidebar thread list feature: what the app entry may use.
2
+ export { ThreadList } from "./components/ThreadList";
@@ -0,0 +1,89 @@
1
+ // What counts as "needs attention", thread by thread. The filter, its
2
+ // badge, the header counters, the urgency order and auto-reveal all read the
3
+ // set this builds. Pure.
4
+ import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
5
+ import type { ChildAttention } from "@/shared/preferences";
6
+ import { type Flag, type StateKind, type ThreadState } from "./state";
7
+
8
+ /** The flags that count for a thread nobody manages. Working is not one. */
9
+ export const ROOT_ATTENTION: ReadonlySet<Flag> = new Set<Flag>([
10
+ "needs-you",
11
+ "unread-failed",
12
+ "queue-failed",
13
+ "offline",
14
+ "unread",
15
+ ]);
16
+
17
+ /** States in which a manager is busy and may still deal with a failed child. */
18
+ const BUSY: ReadonlySet<StateKind> = new Set<StateKind>(["working", "background", "queued", "scheduled"]);
19
+
20
+ export interface Manager {
21
+ thread: Pick<PluginSidebarThread, "latestAttentionAt">;
22
+ state: Pick<ThreadState, "kind">;
23
+ /** When the plugin server last saw the manager finish a turn, if it did. */
24
+ finishedAt?: number;
25
+ }
26
+
27
+ /** A manager is idle when it is not working, setting up, running background work or holding a queued message. */
28
+ export function isManagerIdle(manager: Manager): boolean {
29
+ return !BUSY.has(manager.state.kind);
30
+ }
31
+
32
+ /** When the thread's failure happened: an error bumps `latestAttentionAt`, a failed queue only `updatedAt`. */
33
+ export function failureTime(
34
+ thread: Pick<PluginSidebarThread, "latestAttentionAt" | "updatedAt">,
35
+ flags: ReadonlySet<Flag>,
36
+ ): number {
37
+ let at = 0;
38
+ if (flags.has("unread-failed")) at = Math.max(at, thread.latestAttentionAt);
39
+ if (flags.has("queue-failed")) at = Math.max(at, thread.updatedAt);
40
+ return at;
41
+ }
42
+
43
+ /**
44
+ * A failure nobody is dealing with: the manager is idle and has not run or
45
+ * finished since the child failed. A busy manager, or one that finished after
46
+ * the failure, is taken to know about it. The manager's own clock is its last
47
+ * finish (`finishedAt`, or `latestAttentionAt` when it failed): `updatedAt`
48
+ * also moves when someone merely opens or renames it.
49
+ */
50
+ export function isOrphanedFailure(
51
+ thread: Pick<PluginSidebarThread, "latestAttentionAt" | "updatedAt">,
52
+ flags: ReadonlySet<Flag>,
53
+ manager: Manager,
54
+ ): boolean {
55
+ if (!flags.has("unread-failed") && !flags.has("queue-failed")) return false;
56
+ if (!isManagerIdle(manager)) return false;
57
+ const managerActiveAt = Math.max(manager.thread.latestAttentionAt, manager.finishedAt ?? 0);
58
+ return managerActiveAt <= failureTime(thread, flags);
59
+ }
60
+
61
+ /**
62
+ * The flags of one thread that count towards Needs attention. A root keeps
63
+ * its own. A child counts when it waits on you or is offline, or when it
64
+ * failed with an idle manager; a finished-unread child never does. With
65
+ * `everything`, a child counts like a root.
66
+ */
67
+ export function attentionFlagsOf(
68
+ flags: ReadonlySet<Flag>,
69
+ options: { isRoot: boolean; mode: ChildAttention; orphaned: boolean },
70
+ ): Set<Flag> {
71
+ const kept = new Set<Flag>();
72
+ if (options.isRoot || options.mode === "everything") {
73
+ for (const flag of flags) if (ROOT_ATTENTION.has(flag)) kept.add(flag);
74
+ return kept;
75
+ }
76
+ if (flags.has("needs-you")) kept.add("needs-you");
77
+ if (flags.has("offline")) kept.add("offline");
78
+ if (options.orphaned) {
79
+ if (flags.has("unread-failed")) kept.add("unread-failed");
80
+ if (flags.has("queue-failed")) kept.add("queue-failed");
81
+ }
82
+ return kept;
83
+ }
84
+
85
+ /** Whether a set of attention flags is worth an auto-reveal: it waits on you or failed. */
86
+ export function revealsOn(attention: ReadonlySet<Flag>, isRoot: boolean): boolean {
87
+ if (attention.has("needs-you") || attention.has("unread-failed")) return true;
88
+ return !isRoot && attention.has("queue-failed");
89
+ }
@@ -0,0 +1,67 @@
1
+ // Group header counters and the Needs attention filter test. Pure.
2
+ import type { Family } from "./families";
3
+
4
+ export interface Counters {
5
+ needsYou: number;
6
+ /** Unread-failed plus queue-failed threads. */
7
+ failed: number;
8
+ offline: number;
9
+ working: number;
10
+ unread: number;
11
+ }
12
+
13
+ export const EMPTY_COUNTERS: Counters = { needsYou: 0, failed: 0, offline: 0, working: 0, unread: 0 };
14
+
15
+ /**
16
+ * Counts the threads in the families. A thread counts what its attention
17
+ * flags say, so a child adds to needs-you, failed, offline and unread only as
18
+ * the Child threads in Needs attention setting lets it; working counts every thread that runs. Hidden threads carry
19
+ * only needs-you and unread-failed flags already; archived threads never count.
20
+ */
21
+ export function countFamilies(families: readonly Family[]): Counters {
22
+ const counters = { ...EMPTY_COUNTERS };
23
+ for (const family of families) {
24
+ for (const info of [family.root, ...family.descendants]) {
25
+ if (info.thread.isArchived) continue;
26
+ const flags = info.attention;
27
+ if (flags.has("needs-you")) counters.needsYou += 1;
28
+ if (flags.has("unread-failed") || flags.has("queue-failed")) counters.failed += 1;
29
+ if (flags.has("offline")) counters.offline += 1;
30
+ if (info.flags.has("working")) counters.working += 1;
31
+ if (flags.has("unread")) counters.unread += 1;
32
+ }
33
+ }
34
+ return counters;
35
+ }
36
+
37
+ export function addCounters(a: Counters, b: Counters): Counters {
38
+ return {
39
+ needsYou: a.needsYou + b.needsYou,
40
+ failed: a.failed + b.failed,
41
+ offline: a.offline + b.offline,
42
+ working: a.working + b.working,
43
+ unread: a.unread + b.unread,
44
+ };
45
+ }
46
+
47
+ /** The Needs attention tooltip: short enough to read at a glance. */
48
+ export const ATTENTION_SUMMARY = "Waiting on you, failed, offline, or finished and unread. Child threads count only when stuck.";
49
+
50
+ /** What the Needs attention toggle keeps, in full, for its accessible description. */
51
+ export const ATTENTION_EXPLANATION =
52
+ "Threads only you can move forward: a question, approval or plan review waiting on you; a machine that is offline; a failed thread or queued message; finished threads you haven't opened. For a child thread, only what waits on you or is offline, or a failure whose manager is idle (Filter settings can widen this).";
53
+
54
+ /**
55
+ * Which counters a header draws. needs-you, failed and offline
56
+ * always; working only while collapsed, since the rows show it otherwise;
57
+ * unread only on the More trigger, where the rows are out of sight.
58
+ */
59
+ export function visibleCounters(counters: Counters, where: { collapsed: boolean; more: boolean }): Counters {
60
+ return {
61
+ needsYou: counters.needsYou,
62
+ failed: counters.failed,
63
+ offline: counters.offline,
64
+ working: where.collapsed || where.more ? counters.working : 0,
65
+ unread: where.more ? counters.unread : 0,
66
+ };
67
+ }
@@ -0,0 +1,79 @@
1
+ // Hover card and details drawer content. Pure.
2
+ import type { Family, ThreadInfo } from "./families";
3
+ import type { StateKind } from "./state";
4
+
5
+ const SUMMARY_ORDER: readonly [StateKind, string][] = [
6
+ ["needs-you", "needing you"],
7
+ ["failed", "failed"],
8
+ ["queue-failed", "queue failed"],
9
+ ["offline", "offline"],
10
+ ["working", "working"],
11
+ ["background", "in background"],
12
+ ["scheduled", "scheduled"],
13
+ ["queued", "queued"],
14
+ ["unread", "unread"],
15
+ ["draft", "drafts"],
16
+ ["idle", "idle"],
17
+ ];
18
+
19
+ /** "2 working, 1 need you, 3 idle": visible descendants by state. */
20
+ export function childSummary(descendants: readonly ThreadInfo[]): string | null {
21
+ const counts = new Map<StateKind, number>();
22
+ for (const info of descendants) {
23
+ if (info.thread.isHidden) continue;
24
+ counts.set(info.state.kind, (counts.get(info.state.kind) ?? 0) + 1);
25
+ }
26
+ const parts = SUMMARY_ORDER.filter(([kind]) => counts.has(kind)).map(
27
+ ([kind, label]) => `${counts.get(kind)} ${label}`,
28
+ );
29
+ return parts.length > 0 ? parts.join(", ") : null;
30
+ }
31
+
32
+ /** Descendants of `id` inside its family, for the card of any row. */
33
+ export function descendantsOf(family: Family | undefined, id: string): ThreadInfo[] {
34
+ if (family === undefined) return [];
35
+ if (family.root.thread.id === id) return family.descendants;
36
+ const result: ThreadInfo[] = [];
37
+ const within = new Set([id]);
38
+ for (const info of family.descendants) {
39
+ if (info.parentId !== null && within.has(info.parentId)) {
40
+ within.add(info.thread.id);
41
+ result.push(info);
42
+ }
43
+ }
44
+ return result;
45
+ }
46
+
47
+ export function formatDateTime(at: number): string {
48
+ return new Date(at).toLocaleString(undefined, {
49
+ month: "short",
50
+ day: "numeric",
51
+ hour: "2-digit",
52
+ minute: "2-digit",
53
+ });
54
+ }
55
+
56
+ /** How the card names the time in the current state. */
57
+ export function sinceLabel(kind: string, since: string | null): string | null {
58
+ if (since === null) return null;
59
+ const ago = since === "now" ? "just now" : `${since} ago`;
60
+ switch (kind) {
61
+ case "working":
62
+ case "background":
63
+ return `started ${ago}`;
64
+ case "needs-you":
65
+ return `waiting since ${ago}`;
66
+ case "failed":
67
+ return `failed ${ago}`;
68
+ default:
69
+ return `finished ${ago}`;
70
+ }
71
+ }
72
+
73
+ /** The model's display name from the provider catalog, else its id. */
74
+ export function modelDisplayName(
75
+ model: string,
76
+ catalog: readonly { id: string; model: string; displayName: string }[],
77
+ ): string {
78
+ return catalog.find((entry) => entry.model === model || entry.id === model)?.displayName ?? model;
79
+ }