@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,669 @@
1
+ // The list view: groups, their counters and the flat rows
2
+ // each one renders. Components draw this; they decide nothing. Pure.
3
+ import type {
4
+ PluginSidebarProject,
5
+ PluginSidebarSection,
6
+ PluginSidebarThread,
7
+ } from "@get-bb/plugin-sdk/app";
8
+ import type { Preferences } from "@/shared/preferences";
9
+ import { ancestorsOf, type Family, type Forest, type Subtree, type ThreadInfo } from "./families";
10
+ import {
11
+ builtinGroup,
12
+ entityGroups,
13
+ groupIdForRoot,
14
+ isGroupCollapsed,
15
+ ORDER_PREFERENCE,
16
+ PINNED_GROUP_ID,
17
+ resolveGroupOrder,
18
+ THREADS_GROUP_ID,
19
+ type GroupDescriptor,
20
+ } from "./groups";
21
+ import { addCounters, countFamilies, EMPTY_COUNTERS, type Counters } from "./counters";
22
+ import { comparePinned, effectiveSortField, makeComparator, type SortKey } from "./sort";
23
+ import { mostUrgent, type Flag } from "./state";
24
+ import type { Targets } from "./expansion";
25
+ import { railsFor, type Rail } from "./layout";
26
+
27
+ /** How many quiet roots stay in a group. */
28
+ export const KEEP_QUIET = 5;
29
+ /** How many quiet children stay in an expanded family. */
30
+ export const KEEP_QUIET_CHILDREN = 3;
31
+
32
+ export type ListFilter = "all" | "attention";
33
+
34
+ export interface Chip {
35
+ count: number;
36
+ flag: Flag | null;
37
+ /** Every child shows: folded, the user expanded it; tree, it's open. */
38
+ expanded: boolean;
39
+ /** Harnesses among the visible children that differ from the parent's. */
40
+ providerIds: string[];
41
+ }
42
+
43
+ export interface ThreadRow {
44
+ type: "thread";
45
+ key: string;
46
+ info: ThreadInfo;
47
+ /** Visual depth: 0 for roots; 1 for every child in folded nesting. */
48
+ depth: number;
49
+ /** Grandchild or deeper in folded nesting: draws `↳`. */
50
+ nested: boolean;
51
+ /** Title of the thread this one attaches to, for tooltips and labels. */
52
+ parentTitle: string | null;
53
+ chip: Chip | null;
54
+ /** Guide rails by nesting level. */
55
+ rails: (Rail | null)[];
56
+ /** A hidden thread shown because it needs you or failed. */
57
+ hiddenBadge: boolean;
58
+ /** "In project X" when the thread is outside its family's group. */
59
+ crossGroupLabel: string | null;
60
+ /** Tree nesting: this parent row sticks at this level (0-3), or null. */
61
+ stickyLevel: number | null;
62
+ projectId: string;
63
+ }
64
+
65
+ export interface OlderRow {
66
+ type: "older";
67
+ key: string;
68
+ /** A group id, or a parent thread id for a family's fold. */
69
+ scopeId: string;
70
+ /** `reveal`: an auto-reveal left `count` children hidden ("+N more"). */
71
+ scope: "group" | "family" | "reveal";
72
+ count: number;
73
+ expanded: boolean;
74
+ depth: number;
75
+ rails: (Rail | null)[];
76
+ }
77
+
78
+ export interface EnvironmentRow {
79
+ type: "environment";
80
+ key: string;
81
+ environmentId: string;
82
+ label: string;
83
+ environmentProviderId: string | null;
84
+ projectId: string;
85
+ sectionId: string | null;
86
+ threadIds: string[];
87
+ collapsed: boolean;
88
+ flag: Flag | null;
89
+ depth: number;
90
+ rails: (Rail | null)[];
91
+ }
92
+
93
+ /** Under an opened chip that Needs attention leaves with nothing to show. */
94
+ export interface EmptyRow {
95
+ type: "empty";
96
+ key: string;
97
+ /** The parent whose chip is open. */
98
+ scopeId: string;
99
+ depth: number;
100
+ rails: (Rail | null)[];
101
+ }
102
+
103
+ export const EMPTY_LEVEL_TEXT = "No child threads need attention";
104
+
105
+ export type Row = ThreadRow | OlderRow | EnvironmentRow | EmptyRow;
106
+
107
+ export interface GroupView {
108
+ descriptor: GroupDescriptor;
109
+ counters: Counters;
110
+ /** What the user stored. */
111
+ userCollapsed: boolean;
112
+ /** What is drawn: user collapse, unless a target opened it. */
113
+ collapsed: boolean;
114
+ hidden: boolean;
115
+ rows: Row[];
116
+ /** Every family root bucketed in the group, before filtering and folding. */
117
+ rootIds: string[];
118
+ }
119
+
120
+ export interface ListView {
121
+ groups: GroupView[];
122
+ /** Hidden groups, shown in the More popover. */
123
+ more: GroupView[];
124
+ moreCounters: Counters;
125
+ /** Resolved top-level order for the mode, for header drag. */
126
+ order: string[];
127
+ /** Threads span more than one host (the row's second line). */
128
+ multiHost: boolean;
129
+ /** Threads Needs attention would show for their own flags (the filter's badge). */
130
+ attentionCount: number;
131
+ }
132
+
133
+ export interface ViewInputs {
134
+ forest: Forest;
135
+ threads: readonly PluginSidebarThread[];
136
+ projects: readonly PluginSidebarProject[];
137
+ sections: readonly PluginSidebarSection[];
138
+ prefs: Preferences;
139
+ filter: ListFilter;
140
+ activeThreadId: string | null;
141
+ targets: Targets;
142
+ }
143
+
144
+ interface Context extends ViewInputs {
145
+ compare: (a: SortKey, b: SortKey) => number;
146
+ expandedChildren: ReadonlySet<string>;
147
+ collapsedChildren: ReadonlySet<string>;
148
+ expandedOlder: ReadonlySet<string>;
149
+ collapsedEnvironments: ReadonlySet<string>;
150
+ /** Parent ids on the path to a reveal target. */
151
+ revealPath: ReadonlySet<string>;
152
+ revealIds: ReadonlySet<string>;
153
+ /** The open thread and its ancestors. */
154
+ activePath: ReadonlySet<string>;
155
+ projectNames: ReadonlyMap<string, string>;
156
+ sectionNames: ReadonlyMap<string, string>;
157
+ subtreeAttention: Map<string, number>;
158
+ }
159
+
160
+ function titleOf(context: Context, id: string | null): string | null {
161
+ if (id === null) return null;
162
+ return context.forest.infos.get(id)?.thread.displayTitle ?? null;
163
+ }
164
+
165
+ function crossGroupLabel(context: Context, info: ThreadInfo, root: ThreadInfo): string | null {
166
+ const thread = info.thread;
167
+ const rootThread = root.thread;
168
+ if (info === root) return null;
169
+ switch (context.prefs.organizationMode) {
170
+ case "project":
171
+ if (thread.projectId === rootThread.projectId) return null;
172
+ return `In project ${context.projectNames.get(thread.projectId) ?? "another project"}`;
173
+ case "chronological":
174
+ if (thread.sectionId === rootThread.sectionId) return null;
175
+ return thread.sectionId === null
176
+ ? "In Threads"
177
+ : `In section ${context.sectionNames.get(thread.sectionId) ?? "another section"}`;
178
+ case "machine":
179
+ if ((thread.host?.id ?? null) === (rootThread.host?.id ?? null)) return null;
180
+ return thread.host === null ? "On no machine" : `On machine ${thread.host.name}`;
181
+ }
182
+ }
183
+
184
+ function threadRow(
185
+ context: Context,
186
+ info: ThreadInfo,
187
+ root: ThreadInfo,
188
+ options: { depth: number; nested: boolean; chip: Chip | null; stickyLevel?: number | null },
189
+ ): ThreadRow {
190
+ return {
191
+ type: "thread",
192
+ key: `thread:${info.thread.id}`,
193
+ info,
194
+ depth: options.depth,
195
+ nested: options.nested,
196
+ parentTitle: titleOf(context, info.parentId),
197
+ chip: options.chip,
198
+ rails: [],
199
+ hiddenBadge: info.thread.isHidden,
200
+ crossGroupLabel: crossGroupLabel(context, info, root),
201
+ stickyLevel: options.stickyLevel ?? null,
202
+ projectId: info.thread.projectId,
203
+ };
204
+ }
205
+
206
+ function matchesAttention(info: ThreadInfo): boolean {
207
+ return info.attention.size > 0;
208
+ }
209
+
210
+ /** The children of `parent` that get a row: hidden ones only when they need you or failed. */
211
+ function eligibleChildren(context: Context, parentId: string): ThreadInfo[] {
212
+ return (context.forest.children.get(parentId) ?? [])
213
+ .map((id) => context.forest.infos.get(id)!)
214
+ .filter((info) => !info.thread.isHidden || info.attention.size > 0);
215
+ }
216
+
217
+ function subtreeOf(context: Context, id: string): Subtree {
218
+ return context.forest.subtrees.get(id)!;
219
+ }
220
+
221
+ /**
222
+ * The children of one parent that show in folded nesting. Each level
223
+ * folds on its own: its direct children only, and a child's own children wait
224
+ * for that child's chip. A child never shows without its parent, because the
225
+ * parent's level is the only place it is drawn.
226
+ */
227
+ function foldedChildren(
228
+ context: Context,
229
+ parent: ThreadInfo,
230
+ depth: number,
231
+ ): { shown: ThreadInfo[]; older: OlderRow | null } {
232
+ const parentId = parent.thread.id;
233
+ const kids = eligibleChildren(context, parentId);
234
+ const holds = (kid: ThreadInfo, test: (info: ThreadInfo) => boolean) =>
235
+ test(kid) || subtreeOf(context, kid.thread.id).descendants.some(test);
236
+
237
+ if (context.filter === "attention") {
238
+ const shown = kids.filter((kid) => holds(kid, (info) => matchesAttention(info) || info.isActive));
239
+ return { shown, older: null };
240
+ }
241
+
242
+ const reveals = (kid: ThreadInfo) => context.revealIds.has(kid.thread.id) || context.revealPath.has(kid.thread.id);
243
+ const olderRow = (scope: "family" | "reveal", count: number, expanded: boolean): OlderRow | null =>
244
+ count > 0
245
+ ? {
246
+ type: "older",
247
+ key: `older:${scope}:${parentId}`,
248
+ scopeId: parentId,
249
+ scope,
250
+ count,
251
+ expanded,
252
+ depth,
253
+ rails: [],
254
+ }
255
+ : null;
256
+
257
+ if (!context.expandedChildren.has(parentId)) {
258
+ const shown = kids.filter(reveals);
259
+ // An auto-reveal says how much it left out.
260
+ const left = kids.filter((kid) => !reveals(kid) && !kid.thread.isHidden).length;
261
+ return { shown, older: shown.length > 0 ? olderRow("reveal", left, false) : null };
262
+ }
263
+
264
+ // The stable set reads `settled` for the child and everything under it, and
265
+ // ignores which thread is open and the reveal targets: they join afterwards
266
+ // and take no other row's place.
267
+ const quietTree = (kid: ThreadInfo) => kid.settled && subtreeOf(context, kid.thread.id).settled;
268
+ const stable = new Set<string>();
269
+ for (const kid of kids) if (kid.thread.isHidden || !quietTree(kid)) stable.add(kid.thread.id);
270
+ const recentQuiet = kids
271
+ .filter((kid) => !stable.has(kid.thread.id) && !kid.thread.isHidden)
272
+ .sort((a, b) => b.thread.createdAt - a.thread.createdAt)
273
+ .slice(0, KEEP_QUIET_CHILDREN);
274
+ for (const kid of recentQuiet) stable.add(kid.thread.id);
275
+ const olderExpanded = context.expandedOlder.has(parentId);
276
+ const kept = (kid: ThreadInfo) =>
277
+ stable.has(kid.thread.id) || reveals(kid) || context.activePath.has(kid.thread.id);
278
+ // Open, the fold still counts what it holds, so its row can close it again.
279
+ const folded = kids.filter((kid) => !kept(kid)).length;
280
+ const shown = olderExpanded ? kids : kids.filter(kept);
281
+ return { shown, older: olderRow("family", folded, olderExpanded) };
282
+ }
283
+
284
+ function subtreeAttention(context: Context, id: string): number {
285
+ const cached = context.subtreeAttention.get(id);
286
+ if (cached !== undefined) return cached;
287
+ context.subtreeAttention.set(id, context.forest.infos.get(id)?.thread.latestAttentionAt ?? 0);
288
+ let max = context.forest.infos.get(id)?.thread.latestAttentionAt ?? 0;
289
+ for (const child of context.forest.children.get(id) ?? []) {
290
+ max = Math.max(max, subtreeAttention(context, child));
291
+ }
292
+ context.subtreeAttention.set(id, max);
293
+ return max;
294
+ }
295
+
296
+ function environmentLabel(thread: PluginSidebarThread): string {
297
+ const environment = thread.environment;
298
+ if (environment === null) return "Environment";
299
+ if (environment.name) return environment.name;
300
+ if (environment.branchName) return environment.branchName;
301
+ if (environment.path) return environment.path.split(/[\\/]/).filter(Boolean).pop() ?? "Environment";
302
+ return "Environment";
303
+ }
304
+
305
+ type Unit = { info: ThreadInfo; rows: Row[]; flags: ReadonlySet<Flag> };
306
+
307
+ /**
308
+ * Folds sibling units that share a worktree environment (2 or more) into a
309
+ * folder row. Off unless the environment grouping preference is on.
310
+ */
311
+ function clusterEnvironments(context: Context, units: Unit[], depth: number): Row[] {
312
+ if (!context.prefs.environmentGrouping) return units.flatMap((unit) => unit.rows);
313
+ const counts = new Map<string, number>();
314
+ for (const unit of units) {
315
+ const environment = unit.info.thread.environment;
316
+ if (environment?.isWorktree === true && environment.id !== null) {
317
+ counts.set(environment.id, (counts.get(environment.id) ?? 0) + 1);
318
+ }
319
+ }
320
+ const rows: Row[] = [];
321
+ const emitted = new Set<string>();
322
+ for (const unit of units) {
323
+ const environment = unit.info.thread.environment;
324
+ const id = environment?.isWorktree === true ? environment.id : null;
325
+ if (id === null || (counts.get(id) ?? 0) < 2) {
326
+ rows.push(...unit.rows);
327
+ continue;
328
+ }
329
+ if (emitted.has(id)) continue;
330
+ emitted.add(id);
331
+ const members = units.filter((candidate) => candidate.info.thread.environment?.id === id);
332
+ const collapsed = context.collapsedEnvironments.has(id);
333
+ const flags = new Set<Flag>();
334
+ for (const member of members) for (const flag of member.flags) flags.add(flag);
335
+ rows.push({
336
+ type: "environment",
337
+ key: `environment:${id}:${unit.info.thread.id}`,
338
+ environmentId: id,
339
+ label: environmentLabel(unit.info.thread),
340
+ environmentProviderId: environment?.providerId ?? null,
341
+ projectId: unit.info.thread.projectId,
342
+ sectionId: unit.info.thread.sectionId,
343
+ threadIds: members.map((member) => member.info.thread.id),
344
+ collapsed,
345
+ flag: collapsed ? mostUrgent(flags) : null,
346
+ depth,
347
+ rails: [],
348
+ });
349
+ if (!collapsed) {
350
+ for (const member of members) {
351
+ for (const row of member.rows) rows.push({ ...row, depth: row.depth + 1 });
352
+ }
353
+ }
354
+ }
355
+ return rows;
356
+ }
357
+
358
+ /** Distinct child harnesses other than the parent's, at most three. */
359
+ function childProviders(parent: ThreadInfo, descendants: readonly ThreadInfo[]): string[] {
360
+ const ids: string[] = [];
361
+ for (const info of descendants) {
362
+ const id = info.thread.providerId;
363
+ if (info.thread.isHidden || id === parent.thread.providerId || ids.includes(id)) continue;
364
+ ids.push(id);
365
+ if (ids.length === 3) break;
366
+ }
367
+ return ids;
368
+ }
369
+
370
+ /** The chip of a parent: its children's count, most urgent flag and harnesses. */
371
+ function chipOf(context: Context, info: ThreadInfo, expanded: boolean): Chip | null {
372
+ const subtree = subtreeOf(context, info.thread.id);
373
+ const hasChildren =
374
+ subtree.visibleCount > 0 || subtree.descendants.some((descendant) => descendant.thread.isHidden && descendant.attention.size > 0);
375
+ if (!hasChildren) return null;
376
+ return {
377
+ count: subtree.visibleCount,
378
+ flag: mostUrgent(subtree.flags),
379
+ expanded,
380
+ providerIds: childProviders(info, subtree.descendants),
381
+ };
382
+ }
383
+
384
+ /** What a thread and everything under it carry, for an environment folder's glyph. */
385
+ function rollupFlags(context: Context, info: ThreadInfo): Set<Flag> {
386
+ const flags = new Set<Flag>([...info.attention, ...subtreeOf(context, info.thread.id).flags]);
387
+ if (info.flags.has("working")) flags.add("working");
388
+ return flags;
389
+ }
390
+
391
+ /** The rows under `parent` in folded nesting: each shown child, then its own level. */
392
+ function foldedLevel(context: Context, family: Family, parent: ThreadInfo, depth: number): Row[] {
393
+ const { shown, older } = foldedChildren(context, parent, depth);
394
+ const units: Unit[] = shown.map((info) => ({
395
+ info,
396
+ flags: rollupFlags(context, info),
397
+ rows: [
398
+ threadRow(context, info, family.root, {
399
+ depth,
400
+ nested: depth > 1,
401
+ // Expanded only when the user expanded it: an auto-reveal shows a
402
+ // chevron that still expands, and a "N more child threads" row.
403
+ chip: chipOf(context, info, context.expandedChildren.has(info.thread.id)),
404
+ }),
405
+ ...foldedLevel(context, family, info, depth + 1),
406
+ ],
407
+ }));
408
+ const rows = clusterEnvironments(context, units, depth);
409
+ if (older !== null) rows.push(older);
410
+ // An opened chip that the filter empties says so, instead of showing an open chevron over nothing.
411
+ if (context.filter === "attention" && shown.length === 0 && context.expandedChildren.has(parent.thread.id)) {
412
+ rows.push({ type: "empty", key: `empty:${parent.thread.id}`, scopeId: parent.thread.id, depth, rails: [] });
413
+ }
414
+ return rows;
415
+ }
416
+
417
+ function foldedFamilyRows(context: Context, family: Family): Row[] {
418
+ const root = family.root;
419
+ const chip = chipOf(context, root, context.expandedChildren.has(root.thread.id));
420
+ return [threadRow(context, root, root, { depth: 0, nested: false, chip }), ...foldedLevel(context, family, root, 1)];
421
+ }
422
+
423
+ /** Tree nesting: bb's tree, with chips on collapsed parents. */
424
+ function treeRows(context: Context, family: Family, info: ThreadInfo, depth: number): Unit {
425
+ const id = info.thread.id;
426
+ const root = family.root;
427
+ const childIds = (context.forest.children.get(id) ?? []).filter((childId) => {
428
+ const child = context.forest.infos.get(childId)!;
429
+ return !child.thread.isHidden || child.attention.size > 0;
430
+ });
431
+ const subtree = subtreeOf(context, id);
432
+ let children = childIds.map((childId) => context.forest.infos.get(childId)!);
433
+ if (context.filter === "attention") {
434
+ children = children.filter((child) => {
435
+ const test = (candidate: ThreadInfo) => matchesAttention(candidate) || candidate.isActive;
436
+ return test(child) || subtreeOf(context, child.thread.id).descendants.some(test);
437
+ });
438
+ }
439
+ const open =
440
+ context.filter === "attention" ||
441
+ !context.collapsedChildren.has(id) ||
442
+ context.revealPath.has(id);
443
+ const chip: Chip | null =
444
+ childIds.length > 0
445
+ ? // Drawn closed when the filter leaves nothing under it.
446
+ { count: subtree.visibleCount, flag: mostUrgent(subtree.flags), expanded: open && children.length > 0, providerIds: childProviders(info, subtree.descendants) }
447
+ : null;
448
+ const ownRow = threadRow(context, info, root, {
449
+ depth,
450
+ nested: false,
451
+ chip,
452
+ stickyLevel: open && children.length > 0 && depth < 4 ? depth : null,
453
+ });
454
+ const ownFlags = rollupFlags(context, info);
455
+ if (!open || children.length === 0) return { info, rows: [ownRow], flags: ownFlags };
456
+ const keyed = children.map((child) => ({
457
+ child,
458
+ key: { thread: child.thread, familyAttention: subtreeAttention(context, child.thread.id) },
459
+ }));
460
+ keyed.sort((a, b) => context.compare(a.key, b.key));
461
+ const units = keyed.map(({ child }) => treeRows(context, family, child, depth + 1));
462
+ return { info, rows: [ownRow, ...clusterEnvironments(context, units, depth + 1)], flags: ownFlags };
463
+ }
464
+
465
+ function familyUnit(context: Context, family: Family): Unit {
466
+ if (context.prefs.nesting === "tree") return treeRows(context, family, family.root, 0);
467
+ return { info: family.root, rows: foldedFamilyRows(context, family), flags: family.flags };
468
+ }
469
+
470
+ /** needs-you, then failed, then offline, then unread, then the rest. */
471
+ export function urgency(family: Family): number {
472
+ const flags = family.attentionFlags;
473
+ if (flags.has("needs-you")) return 0;
474
+ if (flags.has("unread-failed") || flags.has("queue-failed")) return 1;
475
+ if (flags.has("offline")) return 2;
476
+ if (flags.has("unread")) return 3;
477
+ return 4;
478
+ }
479
+
480
+ function buildGroup(
481
+ context: Context,
482
+ descriptor: GroupDescriptor,
483
+ families: Family[],
484
+ hidden: boolean,
485
+ ): GroupView | null {
486
+ const counters = countFamilies(families);
487
+ const userCollapsed = isGroupCollapsed(descriptor, context.prefs);
488
+ const hasTarget = families.some(
489
+ (family) =>
490
+ context.targets.has(family.root.thread.id) ||
491
+ family.descendants.some((info) => context.targets.has(info.thread.id)),
492
+ );
493
+ const collapsed = userCollapsed && !hasTarget;
494
+ const isPinned = descriptor.id === PINNED_GROUP_ID;
495
+
496
+ let kept = families;
497
+ if (context.filter === "attention") {
498
+ kept = families.filter((family) => family.attentionFlags.size > 0 || family.containsActive);
499
+ if (kept.length === 0) return null;
500
+ }
501
+
502
+ const sorted = [...kept];
503
+ if (context.filter === "attention") {
504
+ // Needs attention reads most urgent first.
505
+ sorted.sort(
506
+ (a, b) =>
507
+ urgency(a) - urgency(b) ||
508
+ context.compare(
509
+ { thread: a.root.thread, familyAttention: a.attention },
510
+ { thread: b.root.thread, familyAttention: b.attention },
511
+ ),
512
+ );
513
+ } else if (isPinned) {
514
+ sorted.sort((a, b) => comparePinned(a.root.thread, b.root.thread));
515
+ } else {
516
+ sorted.sort((a, b) =>
517
+ context.compare(
518
+ { thread: a.root.thread, familyAttention: a.attention },
519
+ { thread: b.root.thread, familyAttention: b.attention },
520
+ ),
521
+ );
522
+ }
523
+
524
+ let visible = sorted;
525
+ let older: OlderRow | null = null;
526
+ const foldable = !isPinned && context.filter === "all" && context.prefs.foldOlder;
527
+ if (foldable) {
528
+ // The fold reads `settled`, as if no thread were open. The open family
529
+ // joins afterwards when it sits behind the fold, and takes no other row's place.
530
+ const quietActive = sorted.filter((family) => family.settled && !family.root.thread.isArchived);
531
+ const keepQuiet = new Set(quietActive.slice(0, KEEP_QUIET));
532
+ const foldedFamilies = quietActive.filter((family) => !keepQuiet.has(family) && !family.containsActive);
533
+ if (foldedFamilies.length > 0) {
534
+ const opened =
535
+ context.expandedOlder.has(descriptor.id) ||
536
+ foldedFamilies.some(
537
+ (family) =>
538
+ context.targets.has(family.root.thread.id) ||
539
+ family.descendants.some((info) => context.targets.has(info.thread.id)),
540
+ );
541
+ const folded = new Set(foldedFamilies);
542
+ if (!opened) visible = sorted.filter((family) => !folded.has(family));
543
+ older = {
544
+ type: "older",
545
+ key: `older:group:${descriptor.id}`,
546
+ scopeId: descriptor.id,
547
+ scope: "group",
548
+ count: foldedFamilies.length,
549
+ expanded: opened,
550
+ depth: 0,
551
+ rails: [],
552
+ };
553
+ }
554
+ }
555
+
556
+ const units = visible.map((family) => familyUnit(context, family));
557
+ const clustered = clusterEnvironments(context, units, 0);
558
+ const rails = railsFor(clustered.map((row) => row.depth));
559
+ const rows = clustered.map((row, index) => ({ ...row, rails: rails[index]! }));
560
+ if (older !== null) rows.push(older);
561
+ return {
562
+ descriptor,
563
+ counters,
564
+ userCollapsed,
565
+ collapsed,
566
+ hidden,
567
+ rows,
568
+ rootIds: families.map((family) => family.root.thread.id),
569
+ };
570
+ }
571
+
572
+ export function buildListView(inputs: ViewInputs): ListView {
573
+ const { forest, prefs } = inputs;
574
+ const revealIds = new Set<string>();
575
+ const revealPath = new Set<string>();
576
+ for (const [id, kind] of inputs.targets) {
577
+ if (kind !== "reveal" || !forest.infos.has(id)) continue;
578
+ revealIds.add(id);
579
+ for (const ancestor of ancestorsOf(id, forest.infos)) revealPath.add(ancestor);
580
+ }
581
+ const activePath = new Set<string>();
582
+ if (inputs.activeThreadId !== null && forest.infos.has(inputs.activeThreadId)) {
583
+ activePath.add(inputs.activeThreadId);
584
+ for (const ancestor of ancestorsOf(inputs.activeThreadId, forest.infos)) activePath.add(ancestor);
585
+ }
586
+ const context: Context = {
587
+ ...inputs,
588
+ compare: makeComparator({
589
+ field: prefs.chronologicalSort,
590
+ direction: prefs.sortDirection,
591
+ workingFirst: prefs.workingFirst,
592
+ }),
593
+ expandedChildren: new Set(prefs.expandedChildren),
594
+ collapsedChildren: new Set(prefs.collapsedChildren),
595
+ expandedOlder: new Set(prefs.expandedOlder),
596
+ collapsedEnvironments: new Set(prefs.collapsedEnvironments),
597
+ revealIds,
598
+ revealPath,
599
+ activePath,
600
+ projectNames: new Map(inputs.projects.map((project) => [project.id, project.name])),
601
+ sectionNames: new Map(inputs.sections.map((section) => [section.id, section.name])),
602
+ subtreeAttention: new Map(),
603
+ };
604
+
605
+ const byGroup = new Map<string, Family[]>();
606
+ for (const family of forest.families) {
607
+ const id = groupIdForRoot(family.root.thread, {
608
+ mode: prefs.organizationMode,
609
+ projects: inputs.projects,
610
+ });
611
+ const list = byGroup.get(id) ?? [];
612
+ list.push(family);
613
+ byGroup.set(id, list);
614
+ }
615
+
616
+ const alphabetical = effectiveSortField(prefs.chronologicalSort) === "alpha";
617
+ const entities = entityGroups(
618
+ { mode: prefs.organizationMode, projects: inputs.projects, sections: inputs.sections, threads: inputs.threads },
619
+ alphabetical,
620
+ );
621
+ const descriptors = new Map<string, GroupDescriptor>(entities.map((group) => [group.id, group]));
622
+ descriptors.set(PINNED_GROUP_ID, builtinGroup(PINNED_GROUP_ID, inputs.projects));
623
+ descriptors.set(THREADS_GROUP_ID, builtinGroup(THREADS_GROUP_ID, inputs.projects));
624
+ const order = resolveGroupOrder(
625
+ prefs.organizationMode,
626
+ prefs[ORDER_PREFERENCE[prefs.organizationMode]],
627
+ entities.map((group) => group.id),
628
+ );
629
+ const hiddenIds = new Set(prefs.hiddenGroups);
630
+
631
+ const groups: GroupView[] = [];
632
+ const more: GroupView[] = [];
633
+ let moreCounters = EMPTY_COUNTERS;
634
+ for (const id of order) {
635
+ const descriptor = descriptors.get(id);
636
+ if (descriptor === undefined) continue;
637
+ const families = byGroup.get(id) ?? [];
638
+ // Pinned and the loose bucket appear only when they hold threads,
639
+ // except the loose bucket in custom-section mode, as in bb.
640
+ if (families.length === 0) {
641
+ if (id === PINNED_GROUP_ID) continue;
642
+ if (id === THREADS_GROUP_ID && prefs.organizationMode !== "chronological") continue;
643
+ }
644
+ const hidden = id !== PINNED_GROUP_ID && hiddenIds.has(id);
645
+ const view = buildGroup(context, descriptor, families, hidden);
646
+ if (view === null) continue;
647
+ if (hidden) {
648
+ more.push(view);
649
+ moreCounters = addCounters(moreCounters, view.counters);
650
+ } else {
651
+ groups.push(view);
652
+ }
653
+ }
654
+
655
+ const hosts = new Set<string>();
656
+ for (const thread of inputs.threads) if (thread.host !== null) hosts.add(thread.host.id);
657
+ let attentionCount = 0;
658
+ for (const family of forest.families) {
659
+ for (const info of [family.root, ...family.descendants]) {
660
+ if (!info.thread.isArchived && matchesAttention(info)) attentionCount += 1;
661
+ }
662
+ }
663
+ return { groups, more, moreCounters, order, multiHost: hosts.size > 1, attentionCount };
664
+ }
665
+
666
+ /** Every thread row in visual order, for keyboard and windowing. */
667
+ export function threadRowsOf(rows: readonly Row[]): ThreadRow[] {
668
+ return rows.filter((row): row is ThreadRow => row.type === "thread");
669
+ }
@@ -0,0 +1,27 @@
1
+ // Row windowing. Pure helpers; the observer lives in a hook.
2
+
3
+ export interface NavTarget {
4
+ threadId: string;
5
+ projectId: string;
6
+ }
7
+
8
+ /**
9
+ * The value of `data-sidebar-windowed-nav` for an off-screen chunk:
10
+ * `threadId:projectId` pairs in visual order, space-separated. This is an
11
+ * undocumented host contract in bb 0.43.4; a test pins it.
12
+ */
13
+ export function windowedNavValue(targets: readonly NavTarget[]): string {
14
+ return targets.map((target) => `${target.threadId}:${target.projectId}`).join(" ");
15
+ }
16
+
17
+ /** Rows per windowed chunk. */
18
+ export const CHUNK_SIZE = 25;
19
+
20
+ /** Splits rows into fixed-size chunks, keeping their order. */
21
+ export function chunk<T>(items: readonly T[], size = CHUNK_SIZE): T[][] {
22
+ const chunks: T[][] = [];
23
+ for (let index = 0; index < items.length; index += size) {
24
+ chunks.push(items.slice(index, index + size));
25
+ }
26
+ return chunks;
27
+ }