@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,101 @@
1
+ // Structural sharing between two list views: whatever did not change keeps
2
+ // the previous object, so memoized rows and groups skip their render. Pure.
3
+ import type { GroupView, ListView, Row } from "./view";
4
+
5
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
6
+ if (value === null || typeof value !== "object") return false;
7
+ const proto = Object.getPrototypeOf(value);
8
+ return proto === Object.prototype || proto === null;
9
+ }
10
+
11
+ function sameSet(previous: ReadonlySet<unknown>, next: ReadonlySet<unknown>): boolean {
12
+ if (previous.size !== next.size) return false;
13
+ for (const value of next) if (!previous.has(value)) return false;
14
+ return true;
15
+ }
16
+
17
+ /**
18
+ * Returns `next` with every part that deep-equals the same part of
19
+ * `previous` replaced by the previous object; `previous` itself when the two
20
+ * are equal. Plain objects and arrays are compared by value, sets of
21
+ * primitives by membership, anything else by identity.
22
+ */
23
+ export function share<T>(previous: T, next: T): T {
24
+ if (Object.is(previous, next)) return previous;
25
+ if (Array.isArray(previous) && Array.isArray(next)) {
26
+ let same = previous.length === next.length;
27
+ const result = next.map((value, index) => {
28
+ const shared = index < previous.length ? share(previous[index], value) : value;
29
+ if (shared !== previous[index]) same = false;
30
+ return shared;
31
+ });
32
+ return (same ? previous : result) as T;
33
+ }
34
+ if (previous instanceof Set && next instanceof Set) return (sameSet(previous, next) ? previous : next) as T;
35
+ if (isPlainObject(previous) && isPlainObject(next)) {
36
+ const keys = Object.keys(next);
37
+ let same = keys.length === Object.keys(previous).length;
38
+ const result: Record<string, unknown> = {};
39
+ for (const key of keys) {
40
+ if (!(key in previous)) same = false;
41
+ const shared = key in previous ? share(previous[key], next[key]) : next[key];
42
+ if (shared !== previous[key]) same = false;
43
+ result[key] = shared;
44
+ }
45
+ return (same ? previous : result) as T;
46
+ }
47
+ return next;
48
+ }
49
+
50
+ /** Rows matched by key, so an insertion doesn't renew every row after it. */
51
+ function shareRows(previous: readonly Row[], next: Row[]): Row[] {
52
+ const byKey = new Map(previous.map((row) => [row.key, row]));
53
+ let same = previous.length === next.length;
54
+ const result = next.map((row, index) => {
55
+ const before = byKey.get(row.key);
56
+ const shared = before === undefined ? row : share(before, row);
57
+ if (shared !== previous[index]) same = false;
58
+ return shared;
59
+ });
60
+ return same ? (previous as Row[]) : result;
61
+ }
62
+
63
+ /** `next` with each field shared from `previous`; `previous` when every field is. */
64
+ function shareFields<T extends object>(previous: T, next: T, shared: Partial<T>): T {
65
+ const result = { ...next } as Record<string, unknown>;
66
+ const before = previous as Record<string, unknown>;
67
+ let same = Object.keys(result).length === Object.keys(before).length;
68
+ for (const key of Object.keys(result)) {
69
+ if (!(key in before)) same = false;
70
+ result[key] = key in shared ? (shared as Record<string, unknown>)[key] : share(before[key], result[key]);
71
+ if (result[key] !== before[key]) same = false;
72
+ }
73
+ return same ? previous : (result as T);
74
+ }
75
+
76
+ function sameItems<T>(previous: readonly T[], next: readonly T[]): boolean {
77
+ return previous.length === next.length && next.every((item, index) => item === previous[index]);
78
+ }
79
+
80
+ function shareGroups(previousGroups: GroupView[], all: ReadonlyMap<string, GroupView>, next: GroupView[]): GroupView[] {
81
+ const result = next.map((group) => {
82
+ const before = all.get(group.descriptor.id);
83
+ if (before === undefined) return group;
84
+ return shareFields(before, group, { rows: shareRows(before.rows, group.rows) });
85
+ });
86
+ return sameItems(previousGroups, result) ? previousGroups : result;
87
+ }
88
+
89
+ /**
90
+ * `next`, sharing every group and row that did not change since `previous`.
91
+ * Groups match by id and rows by key, never by position. A group keeps its
92
+ * object when its header and every row are unchanged.
93
+ */
94
+ export function shareView(previous: ListView | null, next: ListView): ListView {
95
+ if (previous === null) return next;
96
+ const all = new Map([...previous.groups, ...previous.more].map((group) => [group.descriptor.id, group]));
97
+ return shareFields(previous, next, {
98
+ groups: shareGroups(previous.groups, all, next.groups),
99
+ more: shareGroups(previous.more, all, next.more),
100
+ });
101
+ }
@@ -0,0 +1,78 @@
1
+ // Sort order inside a group and in Pinned. Pure.
2
+ import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
3
+ import type { SortDirection, SortField } from "@/shared/preferences";
4
+
5
+ export interface SortOptions {
6
+ field: SortField;
7
+ direction: SortDirection;
8
+ workingFirst: boolean;
9
+ }
10
+
11
+ /** What a comparator needs to know about a root beyond the thread itself. */
12
+ export interface SortKey {
13
+ thread: PluginSidebarThread;
14
+ /** Largest `latestAttentionAt` over the family. */
15
+ familyAttention: number;
16
+ }
17
+
18
+ function byCreated(a: SortKey, b: SortKey): number {
19
+ const diff = b.thread.createdAt - a.thread.createdAt;
20
+ if (diff !== 0) return diff;
21
+ return a.thread.id < b.thread.id ? -1 : a.thread.id > b.thread.id ? 1 : 0;
22
+ }
23
+
24
+ function byAlpha(a: SortKey, b: SortKey): number {
25
+ const diff = a.thread.displayTitle.localeCompare(b.thread.displayTitle);
26
+ if (diff !== 0) return diff;
27
+ return a.thread.id.localeCompare(b.thread.id);
28
+ }
29
+
30
+ function byUpdated(a: SortKey, b: SortKey): number {
31
+ const diff = b.familyAttention - a.familyAttention;
32
+ if (diff !== 0) return diff;
33
+ return byCreated(a, b);
34
+ }
35
+
36
+ /** `none` is bb's legacy value and reads as `updated`. */
37
+ export function effectiveSortField(field: SortField): Exclude<SortField, "none"> {
38
+ return field === "none" ? "updated" : field;
39
+ }
40
+
41
+ /**
42
+ * The comparator for roots in a group. Direction follows bb: `default` keeps
43
+ * the field's natural direction (ascending for alpha, descending otherwise).
44
+ * Working first keeps active threads on top whatever the direction.
45
+ */
46
+ export function makeComparator(options: SortOptions): (a: SortKey, b: SortKey) => number {
47
+ const field = effectiveSortField(options.field);
48
+ const natural = field === "alpha" ? "ascending" : "descending";
49
+ const sign = options.direction === "default" || options.direction === natural ? 1 : -1;
50
+ const base = field === "alpha" ? byAlpha : field === "created" ? byCreated : byUpdated;
51
+ return (a, b) => {
52
+ if (field === "updated" && options.workingFirst) {
53
+ const aActive = a.thread.status === "active";
54
+ const bActive = b.thread.status === "active";
55
+ if (aActive !== bActive) return aActive ? -1 : 1;
56
+ }
57
+ return sign * base(a, b);
58
+ };
59
+ }
60
+
61
+ /** Pinned order: pinSortKey, then pinnedAt desc, createdAt desc, id. */
62
+ export function comparePinned(a: PluginSidebarThread, b: PluginSidebarThread): number {
63
+ if (a.pinSortKey !== null && b.pinSortKey !== null && a.pinSortKey !== b.pinSortKey) {
64
+ return a.pinSortKey < b.pinSortKey ? -1 : 1;
65
+ }
66
+ const pinned = (b.pinnedAt ?? 0) - (a.pinnedAt ?? 0);
67
+ if (pinned !== 0) return pinned;
68
+ const created = b.createdAt - a.createdAt;
69
+ if (created !== 0) return created;
70
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
71
+ }
72
+
73
+ /** Children in Folded nesting: creation order. */
74
+ export function compareCreationAscending(a: PluginSidebarThread, b: PluginSidebarThread): number {
75
+ const diff = a.createdAt - b.createdAt;
76
+ if (diff !== 0) return diff;
77
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
78
+ }
@@ -0,0 +1,330 @@
1
+ // Thread state model and unread rule. Pure functions over the
2
+ // sidebar payload; no React.
3
+ import type {
4
+ PluginSidebarThread,
5
+ PluginSidebarThreadRowStatus,
6
+ } from "@get-bb/plugin-sdk/app";
7
+
8
+ export type StateKind =
9
+ | "needs-you"
10
+ | "failed"
11
+ | "queue-failed"
12
+ | "offline"
13
+ | "working"
14
+ | "background"
15
+ | "scheduled"
16
+ | "queued"
17
+ | "unread"
18
+ | "draft"
19
+ | "idle";
20
+
21
+ /** A rollup flag. Set independently of the first-match state. */
22
+ export type Flag = "needs-you" | "unread-failed" | "queue-failed" | "offline" | "working" | "unread";
23
+
24
+ /** Flags, most urgent first. */
25
+ export const FLAG_ORDER: readonly Flag[] = [
26
+ "needs-you",
27
+ "unread-failed",
28
+ "queue-failed",
29
+ "offline",
30
+ "working",
31
+ "unread",
32
+ ];
33
+
34
+ /**
35
+ * `working` is bb's timeline accent, so a running thread stands out in both
36
+ * themes; `background` keeps bb's faint tone for background
37
+ * activity, so the two stay apart.
38
+ */
39
+ export type Tone = "attention" | "destructive" | "working" | "background" | "muted" | "muted-strong" | "none";
40
+
41
+ /** What a thread that needs you is waiting for, from its note. */
42
+ export type NeedsKind = "question" | "approval" | "plan" | "input";
43
+
44
+ const NEEDS: Record<NeedsKind, { icon: string; label: string }> = {
45
+ question: { icon: "CircleQuestion", label: "Asks a question" },
46
+ approval: { icon: "SecurityCheck", label: "Needs approval" },
47
+ plan: { icon: "ListTodo", label: "Plan to review" },
48
+ input: { icon: "MessageQuestion", label: "Needs your input" },
49
+ };
50
+
51
+ /** How a glyph is drawn. `dot` is the unread dot; everything else a host icon. */
52
+ export interface Glyph {
53
+ icon: string | "dot" | null;
54
+ tone: Tone;
55
+ spin: boolean;
56
+ shine: boolean;
57
+ }
58
+
59
+ export interface ThreadState {
60
+ kind: StateKind;
61
+ label: string;
62
+ glyph: Glyph;
63
+ /** Earliest scheduled send time, for the `scheduled` tooltip. */
64
+ sendAt: number | null;
65
+ }
66
+
67
+ const KNOWN_STATUSES = new Set(["pending", "starting", "active", "stopping", "idle", "error"]);
68
+ const WORKING_RUNTIME = new Set([
69
+ "provisioning",
70
+ "starting",
71
+ "active",
72
+ "stopping",
73
+ "host-reconnecting",
74
+ ]);
75
+ const KNOWN_RUNTIME = new Set([...KNOWN_STATUSES, ...WORKING_RUNTIME, "waiting-for-host"]);
76
+
77
+ /** `status`, with unknown values read as idle. */
78
+ export function normalizeStatus(thread: Pick<PluginSidebarThread, "status">): string {
79
+ return KNOWN_STATUSES.has(thread.status) ? thread.status : "idle";
80
+ }
81
+
82
+ /** `runtimeStatus`, with unknown values read as the thread's status. */
83
+ export function normalizeRuntime(
84
+ thread: Pick<PluginSidebarThread, "status" | "runtimeStatus">,
85
+ ): string {
86
+ return KNOWN_RUNTIME.has(thread.runtimeStatus) ? thread.runtimeStatus : normalizeStatus(thread);
87
+ }
88
+
89
+ /** `queuedWork`, with unknown values read as none. */
90
+ export function normalizeQueued(
91
+ thread: Pick<PluginSidebarThread, "queuedWork">,
92
+ ): "none" | "waiting" | "failed" {
93
+ return thread.queuedWork === "waiting" || thread.queuedWork === "failed"
94
+ ? thread.queuedWork
95
+ : "none";
96
+ }
97
+
98
+ export function isWorking(thread: Pick<PluginSidebarThread, "status" | "runtimeStatus">): boolean {
99
+ return WORKING_RUNTIME.has(normalizeRuntime(thread));
100
+ }
101
+
102
+ export function isOffline(thread: Pick<PluginSidebarThread, "status" | "runtimeStatus">): boolean {
103
+ return normalizeRuntime(thread) === "waiting-for-host";
104
+ }
105
+
106
+ /** Per-thread facts the host payload doesn't carry. */
107
+ export interface ThreadContext {
108
+ activeThreadId: string | null;
109
+ /** Stamps, from the plugin server. */
110
+ finishedAt: Readonly<Record<string, number>>;
111
+ seenAt: Readonly<Record<string, number>>;
112
+ }
113
+
114
+ /**
115
+ * bb's rule for every thread: unread when it has finished (idle or
116
+ * error) since it was last read. Children are also unread when they finished
117
+ * after you last looked at them (done-unseen).
118
+ */
119
+ export function isUnread(thread: PluginSidebarThread, context: ThreadContext): boolean {
120
+ const status = normalizeStatus(thread);
121
+ const lastRead = thread.lastReadAt ?? 0;
122
+ if ((status === "idle" || status === "error") && lastRead < thread.latestAttentionAt) {
123
+ return true;
124
+ }
125
+ return isDoneUnseen(thread, context);
126
+ }
127
+
128
+ /** Done-unseen: a child that finished after you last read or viewed it. */
129
+ export function isDoneUnseen(thread: PluginSidebarThread, context: ThreadContext): boolean {
130
+ if (thread.parentThreadId === null) return false;
131
+ if (normalizeStatus(thread) !== "idle") return false;
132
+ if (thread.id === context.activeThreadId) return false;
133
+ const finished = context.finishedAt[thread.id];
134
+ if (finished === undefined) return false;
135
+ return finished > Math.max(thread.lastReadAt ?? 0, context.seenAt[thread.id] ?? 0);
136
+ }
137
+
138
+ export interface StateInputs {
139
+ unread: boolean;
140
+ hasDraft: boolean;
141
+ /** Earliest future `sendAt` for this thread, or null. */
142
+ scheduledAt: number | null;
143
+ now: number;
144
+ /** What the pending interaction asks for, when the server saw it. */
145
+ needsKind?: NeedsKind | null;
146
+ }
147
+
148
+ const BACKGROUND: readonly [keyof PluginSidebarThread["activity"], string, string][] = [
149
+ ["planMode", "ListTodo", "Plan mode active"],
150
+ ["goals", "Target", "Goal active"],
151
+ ["workflows", "Workflow", "Workflow running"],
152
+ ["backgroundAgents", "UserRoundPlus", "Background agent running"],
153
+ ["backgroundCommands", "Terminal", "Background command running"],
154
+ ];
155
+
156
+ function glyph(icon: Glyph["icon"], tone: Tone, spin = false, shine = false): Glyph {
157
+ return { icon, tone, spin, shine };
158
+ }
159
+
160
+ function workingLabel(runtime: string): string {
161
+ switch (runtime) {
162
+ case "provisioning":
163
+ return "Setting up";
164
+ case "host-reconnecting":
165
+ return "Reconnecting";
166
+ case "stopping":
167
+ return "Stopping";
168
+ default:
169
+ return "Working";
170
+ }
171
+ }
172
+
173
+ /** First match wins. */
174
+ export function computeState(thread: PluginSidebarThread, inputs: StateInputs): ThreadState {
175
+ const status = normalizeStatus(thread);
176
+ const runtime = normalizeRuntime(thread);
177
+ const queued = normalizeQueued(thread);
178
+ const none = { sendAt: null };
179
+ if (thread.hasPendingInteraction) {
180
+ const needs = NEEDS[inputs.needsKind ?? "input"] ?? NEEDS.input;
181
+ const icon = inputs.needsKind ? needs.icon : "CircleQuestion";
182
+ return { kind: "needs-you", label: needs.label, glyph: glyph(icon, "attention"), ...none };
183
+ }
184
+ if (status === "error") {
185
+ return {
186
+ kind: "failed",
187
+ label: "Failed",
188
+ glyph: glyph("CircleX", "destructive"),
189
+ ...none,
190
+ };
191
+ }
192
+ if (queued === "failed") {
193
+ return {
194
+ kind: "queue-failed",
195
+ label: "Queued message failed to send",
196
+ glyph: glyph("AlertTriangle", "destructive"),
197
+ ...none,
198
+ };
199
+ }
200
+ if (runtime === "waiting-for-host") {
201
+ return { kind: "offline", label: "Machine offline", glyph: glyph("CloudOff", "attention"), ...none };
202
+ }
203
+ if (WORKING_RUNTIME.has(runtime)) {
204
+ const label = workingLabel(runtime);
205
+ if (inputs.hasDraft) {
206
+ return { kind: "working", label: `${label}, unsubmitted draft`, glyph: glyph("Edit", "working", false, true), ...none };
207
+ }
208
+ if (thread.activity.planMode > 0) {
209
+ return { kind: "working", label: `${label}, plan mode`, glyph: glyph("ListTodo", "working", false, true), ...none };
210
+ }
211
+ if (thread.activity.goals > 0) {
212
+ return { kind: "working", label: `${label}, goal active`, glyph: glyph("Target", "working", false, true), ...none };
213
+ }
214
+ return { kind: "working", label, glyph: glyph("Loading", "working", true), ...none };
215
+ }
216
+ for (const [field, icon, label] of BACKGROUND) {
217
+ if (thread.activity[field] > 0) {
218
+ return { kind: "background", label, glyph: glyph(icon, "background", false, true), ...none };
219
+ }
220
+ }
221
+ if (queued === "waiting") {
222
+ if (inputs.scheduledAt !== null && inputs.scheduledAt > inputs.now) {
223
+ return {
224
+ kind: "scheduled",
225
+ label: "Scheduled message",
226
+ glyph: glyph("Calendar", "muted-strong"),
227
+ sendAt: inputs.scheduledAt,
228
+ };
229
+ }
230
+ return { kind: "queued", label: "Message waiting to send", glyph: glyph("Clock", "muted-strong"), ...none };
231
+ }
232
+ if (inputs.unread) return { kind: "unread", label: "Unread", glyph: glyph("dot", "none"), ...none };
233
+ if (inputs.hasDraft) return { kind: "draft", label: "Unsubmitted draft", glyph: glyph("Edit", "muted"), ...none };
234
+ return { kind: "idle", label: "Idle", glyph: glyph(null, "none"), ...none };
235
+ }
236
+
237
+ /**
238
+ * A plugin row status replaces the glyph for every state except
239
+ * needs-you, failed, and working with its plain spinner (the rule bb's list uses).
240
+ */
241
+ export function pluginStatusWins(
242
+ state: ThreadState,
243
+ rowStatus: PluginSidebarThreadRowStatus | null,
244
+ ): boolean {
245
+ if (rowStatus === null) return false;
246
+ if (state.kind === "needs-you" || state.kind === "failed") return false;
247
+ if (state.kind === "working" && state.glyph.spin) return false;
248
+ return true;
249
+ }
250
+
251
+ /** The rollup flags a single thread carries. */
252
+ export function threadFlags(thread: PluginSidebarThread, unread: boolean): Set<Flag> {
253
+ const flags = new Set<Flag>();
254
+ if (thread.hasPendingInteraction) flags.add("needs-you");
255
+ if (normalizeStatus(thread) === "error" && unread) flags.add("unread-failed");
256
+ if (normalizeQueued(thread) === "failed") flags.add("queue-failed");
257
+ if (isOffline(thread)) flags.add("offline");
258
+ if (isWorking(thread)) flags.add("working");
259
+ if (unread) flags.add("unread");
260
+ return flags;
261
+ }
262
+
263
+ /** Flags hidden threads contribute: needs-you and unread-failed only. */
264
+ export function hiddenThreadFlags(flags: ReadonlySet<Flag>): Set<Flag> {
265
+ const kept = new Set<Flag>();
266
+ if (flags.has("needs-you")) kept.add("needs-you");
267
+ if (flags.has("unread-failed")) kept.add("unread-failed");
268
+ return kept;
269
+ }
270
+
271
+ /** The most urgent flag, or null. */
272
+ export function mostUrgent(flags: ReadonlySet<Flag>): Flag | null {
273
+ return FLAG_ORDER.find((flag) => flags.has(flag)) ?? null;
274
+ }
275
+
276
+ /** The colour a child chip takes: only what asks for you, fails, or works. */
277
+ export type ChipTone = "attention" | "destructive" | "working" | "neutral";
278
+
279
+ /** The tone of a chip whose most urgent rolled-up flag is `flag`. */
280
+ export function chipTone(flag: Flag | null): ChipTone {
281
+ switch (flag) {
282
+ case "needs-you":
283
+ return "attention";
284
+ case "unread-failed":
285
+ case "queue-failed":
286
+ return "destructive";
287
+ case "working":
288
+ return "working";
289
+ default:
290
+ return "neutral";
291
+ }
292
+ }
293
+
294
+ export const FLAG_GLYPHS: Readonly<Record<Flag, Glyph & { label: string }>> = {
295
+ "needs-you": { ...glyph("CircleQuestion", "attention"), label: "needs your input" },
296
+ "unread-failed": { ...glyph("CircleX", "destructive"), label: "failed" },
297
+ "queue-failed": { ...glyph("AlertTriangle", "destructive"), label: "queued message failed" },
298
+ offline: { ...glyph("CloudOff", "attention"), label: "machine offline" },
299
+ working: { ...glyph("Loading", "working", true), label: "working" },
300
+ unread: { ...glyph("dot", "none"), label: "unread" },
301
+ };
302
+
303
+ /** A thread is quiet when idle, a draft, or failed and read, and read. */
304
+ export function isQuietThread(
305
+ state: ThreadState,
306
+ unread: boolean,
307
+ isActive: boolean,
308
+ ): boolean {
309
+ if (unread || isActive) return false;
310
+ return state.kind === "idle" || state.kind === "draft" || state.kind === "failed";
311
+ }
312
+
313
+ /** Every host icon name this module can draw. */
314
+ export const STATE_ICON_NAMES: readonly string[] = [
315
+ "CircleQuestion",
316
+ "CircleX",
317
+ "AlertTriangle",
318
+ "CloudOff",
319
+ "Loading",
320
+ "Edit",
321
+ "ListTodo",
322
+ "Target",
323
+ "Workflow",
324
+ "UserRoundPlus",
325
+ "Terminal",
326
+ "Calendar",
327
+ "Clock",
328
+ "SecurityCheck",
329
+ "MessageQuestion",
330
+ ];
@@ -0,0 +1,107 @@
1
+ // Row age and working timer. Pure.
2
+ import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
3
+
4
+ const MINUTE = 60_000;
5
+ const HOUR = 60 * MINUTE;
6
+ const DAY = 24 * HOUR;
7
+ const WEEK = 7 * DAY;
8
+
9
+ /** `now`, `5m`, `3h`, `2d` or `4w`. */
10
+ export function formatDuration(ms: number): string {
11
+ const elapsed = Math.max(0, ms);
12
+ if (elapsed < MINUTE) return "now";
13
+ if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m`;
14
+ if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h`;
15
+ if (elapsed < WEEK) return `${Math.floor(elapsed / DAY)}d`;
16
+ return `${Math.floor(elapsed / WEEK)}w`;
17
+ }
18
+
19
+ /** When the thread last finished: max(latestAttentionAt, finishedAt ?? createdAt). */
20
+ export function lastFinishedAt(
21
+ thread: Pick<PluginSidebarThread, "id" | "latestAttentionAt" | "createdAt">,
22
+ finishedAt: Readonly<Record<string, number>>,
23
+ ): number {
24
+ return Math.max(thread.latestAttentionAt, finishedAt[thread.id] ?? thread.createdAt);
25
+ }
26
+
27
+ export interface TrailingTime {
28
+ text: string;
29
+ label: string;
30
+ /** A working timer is drawn in the working tone, a wait and an age muted. */
31
+ kind: "timer" | "waiting" | "age";
32
+ }
33
+
34
+ /**
35
+ * The trailing slot: time since start for a working row, else the age
36
+ *. A working row whose start wasn't stamped shows nothing.
37
+ */
38
+ export function trailingTime(
39
+ thread: Pick<PluginSidebarThread, "id" | "latestAttentionAt" | "createdAt">,
40
+ working: boolean,
41
+ stamps: { startedAt: Readonly<Record<string, number>>; finishedAt: Readonly<Record<string, number>> },
42
+ now: number,
43
+ /**
44
+ * When the thread started waiting on you, if it is: bb reports such a
45
+ * thread as active, but it isn't working, so it gets no working timer.
46
+ */
47
+ waitingSince?: number | null,
48
+ ): TrailingTime | null {
49
+ if (waitingSince !== undefined) {
50
+ if (waitingSince === null) return null;
51
+ const elapsed = formatDuration(now - waitingSince);
52
+ const text = elapsed === "now" ? "<1m" : elapsed;
53
+ return { text, label: `Waiting on you for ${text === "<1m" ? "less than a minute" : text}`, kind: "waiting" };
54
+ }
55
+ if (working) {
56
+ const started = stamps.startedAt[thread.id];
57
+ if (started === undefined) return null;
58
+ const elapsed = formatDuration(now - started);
59
+ // "now" would read like "finished now"; a timer says "<1m".
60
+ const text = elapsed === "now" ? "<1m" : elapsed;
61
+ return { text, label: `Working for ${text === "<1m" ? "less than a minute" : text}`, kind: "timer" };
62
+ }
63
+ const text = formatDuration(now - lastFinishedAt(thread, stamps.finishedAt));
64
+ return { text, label: text === "now" ? "Finished just now" : `Finished ${text} ago`, kind: "age" };
65
+ }
66
+
67
+ /**
68
+ * When the thread last finished, or null while it never has: it is working,
69
+ * waiting for you, or has no finish on record.
70
+ */
71
+ export function finishedAtFor(
72
+ thread: Pick<PluginSidebarThread, "id" | "status" | "latestAttentionAt" | "hasPendingInteraction" | "parentThreadId" | "createdAt">,
73
+ finishedAt: Readonly<Record<string, number>>,
74
+ ): number | null {
75
+ if (thread.status !== "idle" && thread.status !== "error") return null;
76
+ if (thread.hasPendingInteraction) return null;
77
+ const stamped = finishedAt[thread.id];
78
+ if (stamped !== undefined) return Math.max(stamped, thread.latestAttentionAt);
79
+ // bb bumps a root's attention when it finishes; a child's only on failure.
80
+ if (thread.latestAttentionAt > thread.createdAt && (thread.parentThreadId === null || thread.status === "error")) {
81
+ return thread.latestAttentionAt;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /** "since 5m" for the hover card, or null when the state has no stamp. */
87
+ export function stateSince(
88
+ kind: string,
89
+ threadId: string,
90
+ stamps: {
91
+ startedAt: Readonly<Record<string, number>>;
92
+ finishedAt: Readonly<Record<string, number>>;
93
+ pendingAt: Readonly<Record<string, number>>;
94
+ },
95
+ now: number,
96
+ ): string | null {
97
+ const at =
98
+ kind === "working"
99
+ ? stamps.startedAt[threadId]
100
+ : kind === "needs-you"
101
+ ? stamps.pendingAt[threadId]
102
+ : kind === "idle" || kind === "unread" || kind === "failed"
103
+ ? stamps.finishedAt[threadId]
104
+ : undefined;
105
+ if (at === undefined) return null;
106
+ return formatDuration(now - at);
107
+ }