@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,104 @@
1
+ // Drag and drop outcomes. Pure: components report what was dropped
2
+ // where, and this decides what it means.
3
+ import type { OrganizationMode } from "@/shared/preferences";
4
+
5
+ export interface DraggedThread {
6
+ threadId: string;
7
+ parentThreadId: string | null;
8
+ sectionId: string | null;
9
+ pinned: boolean;
10
+ }
11
+
12
+ export type DropTarget =
13
+ | { kind: "thread"; threadId: string; zone: "before" | "middle" | "after"; inPinned: boolean }
14
+ | { kind: "group"; groupId: string };
15
+
16
+ export type DropAction =
17
+ | { type: "nest"; threadId: string; parentThreadId: string; unpinFirst: boolean }
18
+ | { type: "move"; threadId: string; sectionId: string | null; detach: boolean }
19
+ | { type: "detach"; threadId: string }
20
+ | { type: "pin"; threadId: string }
21
+ | { type: "unpin"; threadId: string }
22
+ | { type: "reorder-pinned"; threadId: string; previousThreadId: string | null; nextThreadId: string | null }
23
+ | { type: "blocked" }
24
+ | { type: "unchanged" };
25
+
26
+ export interface DropContext {
27
+ mode: OrganizationMode;
28
+ /** Raw `parentThreadId` for every loaded thread. */
29
+ parentOf: (threadId: string) => string | null;
30
+ /** Pinned roots in Pinned's display order. */
31
+ pinnedOrder: readonly string[];
32
+ }
33
+
34
+ /** True when `candidate` is `ancestorId` or one of its descendants. */
35
+ export function isInSubtree(
36
+ candidate: string,
37
+ ancestorId: string,
38
+ parentOf: (threadId: string) => string | null,
39
+ ): boolean {
40
+ const seen = new Set<string>();
41
+ let current: string | null = candidate;
42
+ while (current !== null && !seen.has(current)) {
43
+ if (current === ancestorId) return true;
44
+ seen.add(current);
45
+ current = parentOf(current);
46
+ }
47
+ return false;
48
+ }
49
+
50
+ function dropOnGroup(source: DraggedThread, groupId: string, context: DropContext): DropAction {
51
+ if (groupId === "pinned") {
52
+ return source.pinned ? { type: "unchanged" } : { type: "pin", threadId: source.threadId };
53
+ }
54
+ if (source.pinned) return { type: "unpin", threadId: source.threadId };
55
+ if (context.mode === "chronological" && (groupId === "threads" || groupId.startsWith("section:"))) {
56
+ const sectionId = groupId === "threads" ? null : groupId.slice("section:".length);
57
+ if (sectionId === source.sectionId && source.parentThreadId === null) return { type: "unchanged" };
58
+ return {
59
+ type: "move",
60
+ threadId: source.threadId,
61
+ sectionId,
62
+ detach: source.parentThreadId !== null,
63
+ };
64
+ }
65
+ if (source.parentThreadId !== null) return { type: "detach", threadId: source.threadId };
66
+ return { type: "unchanged" };
67
+ }
68
+
69
+ /** What dropping a thread on a target does, with bb's guards. */
70
+ export function resolveDrop(
71
+ source: DraggedThread,
72
+ target: DropTarget,
73
+ context: DropContext & { groupOfThread: (threadId: string) => string },
74
+ ): DropAction {
75
+ if (target.kind === "group") return dropOnGroup(source, target.groupId, context);
76
+ if (target.threadId === source.threadId) return { type: "unchanged" };
77
+ if (target.zone === "middle") {
78
+ if (isInSubtree(target.threadId, source.threadId, context.parentOf)) return { type: "blocked" };
79
+ if (source.parentThreadId === target.threadId) return { type: "unchanged" };
80
+ return {
81
+ type: "nest",
82
+ threadId: source.threadId,
83
+ parentThreadId: target.threadId,
84
+ unpinFirst: source.pinned,
85
+ };
86
+ }
87
+ if (target.inPinned && context.pinnedOrder.includes(target.threadId)) {
88
+ if (!source.pinned) return { type: "pin", threadId: source.threadId };
89
+ const order = context.pinnedOrder.filter((id) => id !== source.threadId);
90
+ const index = order.indexOf(target.threadId);
91
+ const insertAt = target.zone === "before" ? index : index + 1;
92
+ const previousThreadId = order[insertAt - 1] ?? null;
93
+ const nextThreadId = order[insertAt] ?? null;
94
+ const current = context.pinnedOrder.indexOf(source.threadId);
95
+ if (
96
+ (context.pinnedOrder[current - 1] ?? null) === previousThreadId &&
97
+ (context.pinnedOrder[current + 1] ?? null) === nextThreadId
98
+ ) {
99
+ return { type: "unchanged" };
100
+ }
101
+ return { type: "reorder-pinned", threadId: source.threadId, previousThreadId, nextThreadId };
102
+ }
103
+ return dropOnGroup(source, context.groupOfThread(target.threadId), context);
104
+ }
@@ -0,0 +1,71 @@
1
+ // Auto-expansion: transitions found by diffing snapshots, and the
2
+ // transient targets they leave behind. Pure.
3
+ import { revealsOn } from "./attention";
4
+ import type { Forest } from "./families";
5
+
6
+ /**
7
+ * `reveal` targets open their group, their `N older` fold and the parent
8
+ * chips on their path. `open` targets (a thread that newly became unread)
9
+ * open the group and the fold only, so finished children don't expand
10
+ * their parent's chip.
11
+ */
12
+ export type TargetKind = "reveal" | "open";
13
+ export type Targets = ReadonlyMap<string, TargetKind>;
14
+
15
+ export interface Snapshot {
16
+ activeThreadId: string | null;
17
+ unread: ReadonlySet<string>;
18
+ /** Threads that wait on you or failed, as Needs attention counts them. */
19
+ attention: ReadonlySet<string>;
20
+ }
21
+
22
+ export function snapshotOf(forest: Forest, activeThreadId: string | null): Snapshot {
23
+ const unread = new Set<string>();
24
+ const attention = new Set<string>();
25
+ for (const info of forest.infos.values()) {
26
+ if (info.thread.isArchived) continue;
27
+ // A finished child that Needs attention does not count never opens anything.
28
+ if (info.attention.has("unread") && !info.thread.isHidden) unread.add(info.thread.id);
29
+ if (revealsOn(info.attention, info.parentId === null)) attention.add(info.thread.id);
30
+ }
31
+ return { activeThreadId, unread, attention };
32
+ }
33
+
34
+ /**
35
+ * The targets a render adds. `previous` is null on the first
36
+ * render, which counts as a transition.
37
+ */
38
+ export function detectTransitions(previous: Snapshot | null, next: Snapshot): Map<string, TargetKind> {
39
+ const found = new Map<string, TargetKind>();
40
+ if (next.activeThreadId !== null && next.activeThreadId !== previous?.activeThreadId) {
41
+ found.set(next.activeThreadId, "reveal");
42
+ }
43
+ for (const id of next.attention) {
44
+ if (!previous?.attention.has(id)) found.set(id, "reveal");
45
+ }
46
+ for (const id of next.unread) {
47
+ if (id === next.activeThreadId || found.has(id)) continue;
48
+ if (!previous?.unread.has(id)) found.set(id, "open");
49
+ }
50
+ return found;
51
+ }
52
+
53
+ export function mergeTargets(current: Targets, added: ReadonlyMap<string, TargetKind>): Targets {
54
+ if (added.size === 0) return current;
55
+ const next = new Map(current);
56
+ for (const [id, kind] of added) {
57
+ if (next.get(id) !== "reveal") next.set(id, kind);
58
+ }
59
+ return next;
60
+ }
61
+
62
+ /** Drops targets for which `drop` returns true (a user's collapse). */
63
+ export function pruneTargets(current: Targets, drop: (id: string) => boolean): Targets {
64
+ let changed = false;
65
+ const next = new Map<string, TargetKind>();
66
+ for (const [id, kind] of current) {
67
+ if (drop(id)) changed = true;
68
+ else next.set(id, kind);
69
+ }
70
+ return changed ? next : current;
71
+ }
@@ -0,0 +1,297 @@
1
+ // Families: who attaches to whom, and what each family
2
+ // carries. Pure.
3
+ import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
4
+ import {
5
+ computeState,
6
+ hiddenThreadFlags,
7
+ isQuietThread,
8
+ isUnread,
9
+ threadFlags,
10
+ type Flag,
11
+ type ThreadContext,
12
+ type StateKind,
13
+ type ThreadState,
14
+ } from "./state";
15
+ import { attentionFlagsOf, isOrphanedFailure } from "./attention";
16
+ import { compareCreationAscending } from "./sort";
17
+ import { needsKindOf, rowNote, type RowNote } from "./notes";
18
+ import type { ThreadNotes } from "@/shared/contract";
19
+ import type { ChildAttention } from "@/shared/preferences";
20
+
21
+ /** States that keep a child out of its family's fold with `childAttention` `blocked`: working, setting up, background work. */
22
+ const RUNNING: ReadonlySet<StateKind> = new Set<StateKind>(["working", "background"]);
23
+
24
+ /** Everything the list knows about one thread. */
25
+ export interface ThreadInfo {
26
+ thread: PluginSidebarThread;
27
+ state: ThreadState;
28
+ unread: boolean;
29
+ /** Own flags; for hidden threads only needs-you and unread-failed. */
30
+ flags: ReadonlySet<Flag>;
31
+ /**
32
+ * The flags of this thread that count towards Needs attention, its
33
+ * counters and auto-reveal: a child counts less than a root.
34
+ */
35
+ attention: Set<Flag>;
36
+ /** Quiet test for the thread alone. */
37
+ quiet: boolean;
38
+ /**
39
+ * Nothing to see behind a family's fold. A root, or any child when
40
+ * `childAttention` is `everything`: the quiet test without the open
41
+ * thread's exemption. Otherwise a child is settled unless it runs or
42
+ * counts towards Needs attention, so finishing unread folds it; an
43
+ * archived child is always settled. The fold
44
+ * reads this, so opening a thread never changes which children stay shown.
45
+ */
46
+ settled: boolean;
47
+ isActive: boolean;
48
+ /** The visible ancestor it attaches to, or null for a root. */
49
+ parentId: string | null;
50
+ /** Why it needs you or failed, for the line under the row. */
51
+ note: RowNote | null;
52
+ }
53
+
54
+ /** What a thread's descendants add up to, for its chip and for folding. */
55
+ export interface Subtree {
56
+ /** Descendants, depth-first in creation order, hidden ones included. */
57
+ descendants: ThreadInfo[];
58
+ /** Union of the descendants' attention flags and working, archived ones left out. */
59
+ flags: ReadonlySet<Flag>;
60
+ /** Visible descendants: the chip's number. */
61
+ visibleCount: number;
62
+ /** Every visible descendant is settled, so the subtree adds nothing to see when folded. */
63
+ settled: boolean;
64
+ }
65
+
66
+ export interface Family {
67
+ root: ThreadInfo;
68
+ /** Descendants, depth-first in creation order, hidden ones included. */
69
+ descendants: ThreadInfo[];
70
+ /** Union of the descendants' attention flags and working (the chip rollup). */
71
+ descendantFlags: ReadonlySet<Flag>;
72
+ /** The same over root and descendants (rollup, folder rows). */
73
+ flags: ReadonlySet<Flag>;
74
+ /** Attention flags over root and descendants: what the filter and the urgency order read. */
75
+ attentionFlags: ReadonlySet<Flag>;
76
+ /** Visible descendants: the chip's number. */
77
+ visibleDescendantCount: number;
78
+ /** Largest latestAttentionAt over the family. */
79
+ attention: number;
80
+ /** Every thread quiet and the active thread not in it. */
81
+ quiet: boolean;
82
+ /**
83
+ * The quiet test without the open thread's exemption. A group's fold reads
84
+ * this, so opening a thread never changes which roots stay shown.
85
+ */
86
+ settled: boolean;
87
+ containsActive: boolean;
88
+ }
89
+
90
+ export interface Forest {
91
+ infos: ReadonlyMap<string, ThreadInfo>;
92
+ /** Visible child ids per parent id, creation order; hidden children included. */
93
+ children: ReadonlyMap<string, readonly string[]>;
94
+ families: readonly Family[];
95
+ familyOf: ReadonlyMap<string, Family>;
96
+ /** The rollup under every thread with a row, by thread id. */
97
+ subtrees: ReadonlyMap<string, Subtree>;
98
+ }
99
+
100
+ export interface ForestInputs extends ThreadContext {
101
+ threads: readonly PluginSidebarThread[];
102
+ draftIds: ReadonlySet<string>;
103
+ scheduled: Readonly<Record<string, number>>;
104
+ now: number;
105
+ /** Server notes per thread id; absent until loaded. */
106
+ notes?: Readonly<Record<string, ThreadNotes>>;
107
+ /** Which children count towards Needs attention; `blocked` when absent. */
108
+ childAttention?: ChildAttention;
109
+ }
110
+
111
+ function isPinned(thread: PluginSidebarThread): boolean {
112
+ return thread.pinnedAt !== null || thread.isPinned;
113
+ }
114
+
115
+ /**
116
+ * The visible loaded ancestor a thread attaches to, or null when it is
117
+ * a root. A pinned thread with no pinned ancestor is a root. Cycles end
118
+ * the walk and make the thread a root.
119
+ */
120
+ export function attachParent(
121
+ thread: PluginSidebarThread,
122
+ byId: ReadonlyMap<string, PluginSidebarThread>,
123
+ ): string | null {
124
+ const seen = new Set<string>([thread.id]);
125
+ let parentId = thread.parentThreadId;
126
+ let attach: string | null = null;
127
+ let pinnedAncestor = false;
128
+ while (parentId !== null) {
129
+ if (seen.has(parentId)) return null;
130
+ seen.add(parentId);
131
+ const parent = byId.get(parentId);
132
+ if (parent === undefined) break;
133
+ if (!parent.isHidden) {
134
+ attach ??= parent.id;
135
+ if (isPinned(parent)) pinnedAncestor = true;
136
+ }
137
+ parentId = parent.parentThreadId;
138
+ }
139
+ if (!thread.isHidden && isPinned(thread) && !pinnedAncestor) return null;
140
+ return attach;
141
+ }
142
+
143
+ export function buildForest(inputs: ForestInputs): Forest {
144
+ const byId = new Map(inputs.threads.map((thread) => [thread.id, thread]));
145
+ const infos = new Map<string, ThreadInfo>();
146
+ for (const thread of inputs.threads) {
147
+ const unread = isUnread(thread, inputs);
148
+ const state = computeState(thread, {
149
+ unread,
150
+ hasDraft: inputs.draftIds.has(thread.id),
151
+ scheduledAt: inputs.scheduled[thread.id] ?? null,
152
+ now: inputs.now,
153
+ needsKind: needsKindOf(inputs.notes?.[thread.id]),
154
+ });
155
+ const flags = threadFlags(thread, unread);
156
+ const isActive = thread.id === inputs.activeThreadId;
157
+ infos.set(thread.id, {
158
+ thread,
159
+ state,
160
+ unread,
161
+ flags: thread.isHidden ? hiddenThreadFlags(flags) : flags,
162
+ attention: new Set<Flag>(),
163
+ quiet: isQuietThread(state, unread, isActive),
164
+ settled: false,
165
+ isActive,
166
+ parentId: attachParent(thread, byId),
167
+ note: rowNote(thread, inputs.notes?.[thread.id]),
168
+ });
169
+ }
170
+
171
+ const mode = inputs.childAttention ?? "blocked";
172
+ for (const info of infos.values()) {
173
+ const manager = info.parentId === null ? undefined : infos.get(info.parentId);
174
+ info.attention = attentionFlagsOf(info.flags, {
175
+ isRoot: manager === undefined,
176
+ mode,
177
+ orphaned:
178
+ manager !== undefined &&
179
+ isOrphanedFailure(info.thread, info.flags, { ...manager, finishedAt: inputs.finishedAt[manager.thread.id] }),
180
+ });
181
+ info.settled =
182
+ manager === undefined || mode === "everything"
183
+ ? isQuietThread(info.state, info.unread, false)
184
+ : info.thread.isArchived || (!RUNNING.has(info.state.kind) && info.attention.size === 0);
185
+ }
186
+
187
+ const children = new Map<string, string[]>();
188
+ const roots: ThreadInfo[] = [];
189
+ for (const info of infos.values()) {
190
+ if (info.parentId === null) {
191
+ // A hidden thread with no visible ancestor has no row and no family.
192
+ if (!info.thread.isHidden) roots.push(info);
193
+ continue;
194
+ }
195
+ const list = children.get(info.parentId) ?? [];
196
+ list.push(info.thread.id);
197
+ children.set(info.parentId, list);
198
+ }
199
+ for (const list of children.values()) {
200
+ list.sort((a, b) => compareCreationAscending(byId.get(a)!, byId.get(b)!));
201
+ }
202
+
203
+ // Each thread's subtree, once: descendants depth-first, and what they add up to.
204
+ const subtrees = new Map<string, Subtree>();
205
+ const subtreeOf = (id: string, path: Set<string>): Subtree => {
206
+ const cached = subtrees.get(id);
207
+ if (cached !== undefined) return cached;
208
+ const descendants: ThreadInfo[] = [];
209
+ const flags = new Set<Flag>();
210
+ let visibleCount = 0;
211
+ let settled = true;
212
+ path.add(id);
213
+ for (const childId of children.get(id) ?? []) {
214
+ if (path.has(childId)) continue;
215
+ const child = infos.get(childId)!;
216
+ const below = subtreeOf(childId, path);
217
+ descendants.push(child, ...below.descendants);
218
+ if (!child.thread.isArchived) {
219
+ for (const flag of child.attention) flags.add(flag);
220
+ if (child.flags.has("working")) flags.add("working");
221
+ }
222
+ for (const flag of below.flags) flags.add(flag);
223
+ if (child.thread.isHidden ? child.attention.size > 0 : !child.settled) settled = false;
224
+ if (!child.thread.isHidden) visibleCount += 1;
225
+ visibleCount += below.visibleCount;
226
+ if (!below.settled) settled = false;
227
+ }
228
+ path.delete(id);
229
+ const subtree: Subtree = { descendants, flags, visibleCount, settled };
230
+ subtrees.set(id, subtree);
231
+ return subtree;
232
+ };
233
+ for (const id of infos.keys()) subtreeOf(id, new Set());
234
+
235
+ const families: Family[] = [];
236
+ const familyOf = new Map<string, Family>();
237
+ for (const root of roots) {
238
+ const { descendants, flags: descendantFlags, visibleCount: visibleDescendantCount } = subtrees.get(root.thread.id)!;
239
+ let attention = root.thread.latestAttentionAt;
240
+ let quiet = root.quiet;
241
+ let settled = isQuietThread(root.state, root.unread, false);
242
+ let containsActive = root.isActive;
243
+ const attentionFlags = new Set<Flag>();
244
+ for (const info of descendants) {
245
+ if (!info.thread.isArchived) for (const flag of info.attention) attentionFlags.add(flag);
246
+ attention = Math.max(attention, info.thread.latestAttentionAt);
247
+ if (info.isActive) containsActive = true;
248
+ if (info.thread.isHidden) {
249
+ if (info.attention.size > 0) quiet = settled = false;
250
+ } else {
251
+ if (!info.quiet) quiet = false;
252
+ if (!isQuietThread(info.state, info.unread, false)) settled = false;
253
+ }
254
+ }
255
+ const flags = new Set<Flag>(descendantFlags);
256
+ if (!root.thread.isArchived) {
257
+ for (const flag of root.attention) {
258
+ flags.add(flag);
259
+ attentionFlags.add(flag);
260
+ }
261
+ if (root.flags.has("working")) flags.add("working");
262
+ }
263
+ const family: Family = {
264
+ root,
265
+ descendants,
266
+ descendantFlags,
267
+ flags,
268
+ attentionFlags,
269
+ visibleDescendantCount,
270
+ attention,
271
+ quiet: quiet && !containsActive,
272
+ settled,
273
+ containsActive,
274
+ };
275
+ families.push(family);
276
+ familyOf.set(root.thread.id, family);
277
+ for (const info of descendants) familyOf.set(info.thread.id, family);
278
+ }
279
+ return { infos, children, families, familyOf, subtrees };
280
+ }
281
+
282
+ /** Ids from `id` up to (not including) `stopAt`, nearest first. */
283
+ export function ancestorsOf(
284
+ id: string,
285
+ infos: ReadonlyMap<string, ThreadInfo>,
286
+ stopAt: string | null = null,
287
+ ): string[] {
288
+ const path: string[] = [];
289
+ const seen = new Set<string>([id]);
290
+ let current = infos.get(id)?.parentId ?? null;
291
+ while (current !== null && current !== stopAt && !seen.has(current)) {
292
+ path.push(current);
293
+ seen.add(current);
294
+ current = infos.get(current)?.parentId ?? null;
295
+ }
296
+ return path;
297
+ }