@gperezmz/bb-plugin-thread-glance 0.2.4 → 0.4.0

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 (54) hide show
  1. package/README.md +1 -1
  2. package/app.tsx +3 -11
  3. package/dist/app.css +1 -1
  4. package/dist/app.js +31 -31
  5. package/dist/app.meta.json +1 -1
  6. package/dist/server.js +10 -10
  7. package/dist/server.js.map +3 -3
  8. package/dist/server.meta.json +1 -1
  9. package/features/thread-list/components/FoldRows.tsx +21 -30
  10. package/features/thread-list/components/GroupSection.tsx +7 -45
  11. package/features/thread-list/components/ListHeader.tsx +79 -0
  12. package/features/thread-list/components/ProviderBadge.tsx +0 -1
  13. package/features/thread-list/components/PullRequestProbes.tsx +27 -0
  14. package/features/thread-list/components/SettingsPanel.tsx +20 -34
  15. package/features/thread-list/components/ThreadDetails.tsx +3 -3
  16. package/features/thread-list/components/ThreadList.tsx +117 -83
  17. package/features/thread-list/components/ThreadRowView.tsx +203 -180
  18. package/features/thread-list/components/controller.ts +5 -10
  19. package/features/thread-list/components/glyphs.tsx +10 -16
  20. package/features/thread-list/data/useAutoExpand.ts +1 -1
  21. package/features/thread-list/data/useDefaultBranches.ts +35 -0
  22. package/features/thread-list/data/usePullRequestAnswers.ts +36 -0
  23. package/features/thread-list/data/useSystemFacts.ts +52 -0
  24. package/features/thread-list/icons.ts +1 -0
  25. package/features/thread-list/index.ts +0 -1
  26. package/features/thread-list/model/attention.ts +2 -66
  27. package/features/thread-list/model/branches.ts +27 -0
  28. package/features/thread-list/model/counters.ts +5 -5
  29. package/features/thread-list/model/details.ts +6 -6
  30. package/features/thread-list/model/expansion.ts +5 -5
  31. package/features/thread-list/model/groups.ts +2 -2
  32. package/features/thread-list/model/labels.ts +43 -18
  33. package/features/thread-list/model/menu.ts +1 -0
  34. package/features/thread-list/model/move.ts +1 -1
  35. package/features/thread-list/model/notes.ts +2 -28
  36. package/features/thread-list/model/settings.ts +1 -17
  37. package/features/thread-list/model/settled.ts +122 -0
  38. package/features/thread-list/model/share.ts +1 -7
  39. package/features/thread-list/model/sort.ts +5 -16
  40. package/features/thread-list/model/state.ts +17 -16
  41. package/features/thread-list/model/time.ts +7 -0
  42. package/features/thread-list/model/toggles.ts +35 -24
  43. package/features/thread-list/model/{families.ts → trees.ts} +40 -28
  44. package/features/thread-list/model/view.ts +202 -274
  45. package/features/thread-list/testing/browser.css +13 -0
  46. package/features/thread-list/testing/fixtures.ts +41 -12
  47. package/package.json +5 -1
  48. package/server/cli.ts +1 -1
  49. package/server/import.ts +36 -6
  50. package/server/preference-store.ts +2 -1
  51. package/server.ts +1 -1
  52. package/shared/preferences.ts +38 -27
  53. package/skills/thread-glance/SKILL.md +11 -12
  54. package/features/thread-list/data/useAttentionHold.ts +0 -19
@@ -6,7 +6,7 @@ import type {
6
6
  PluginSidebarThread,
7
7
  } from "@get-bb/plugin-sdk/app";
8
8
  import type { Preferences } from "@/shared/preferences";
9
- import { ancestorsOf, type Family, type Forest, type Subtree, type ThreadInfo } from "./families";
9
+ import { ancestorsOf, type ThreadTree, type Forest, type Subtree, type ThreadInfo } from "./trees";
10
10
  import {
11
11
  builtinGroup,
12
12
  entityGroups,
@@ -18,25 +18,29 @@ import {
18
18
  THREADS_GROUP_ID,
19
19
  type GroupDescriptor,
20
20
  } from "./groups";
21
- import { addCounters, countFamilies, EMPTY_COUNTERS, type Counters } from "./counters";
21
+ import { addCounters, countTrees, EMPTY_COUNTERS, type Counters } from "./counters";
22
22
  import { comparePinned, effectiveSortField, makeComparator, type SortKey } from "./sort";
23
- import { inAttention, isAttended, type SectionFamily } from "./attention";
23
+ import { isSettledTree, type SettleInputs } from "./settled";
24
+ import { isOffDefaultBranch } from "./branches";
24
25
  import { mostUrgent, type Flag } from "./state";
25
26
  import type { Targets } from "./expansion";
26
27
  import type { RowNote } from "./notes";
27
28
 
28
- /** How many of a group's newest quiet roots stay out of its older fold. */
29
- export const KEEP_QUIET = 5;
30
- /** How many quiet children stay in an expanded family. */
29
+ /** How many quiet children stay in an expanded tree. */
31
30
  export const KEEP_QUIET_CHILDREN = 3;
32
31
 
32
+ /** The children chip: the count of a parent's direct children and its chevron, after the state of its descendants. */
33
33
  export interface Chip {
34
+ /** Direct children opening it shows, hidden ones left out; 0 draws no number. */
34
35
  count: number;
35
- flag: Flag | null;
36
36
  /** The user opened the chip, so every child shows. */
37
37
  expanded: boolean;
38
- /** Harnesses among the visible children that differ from the parent's. */
39
- providerIds: string[];
38
+ /**
39
+ * The most urgent state among the descendants at any depth, open or
40
+ * collapsed, or null for none: its glyph leads the chip and colours it, and
41
+ * keeps that colour through dimming and hover.
42
+ */
43
+ flag: Flag | null;
40
44
  }
41
45
 
42
46
  export interface ThreadRow {
@@ -50,32 +54,46 @@ export interface ThreadRow {
50
54
  /** Title of the thread this one attaches to, for tooltips and labels. */
51
55
  parentTitle: string | null;
52
56
  chip: Chip | null;
53
- /** The title is bold: the thread is unread and its family is not attended. */
54
- bold: boolean;
55
57
  /**
56
- * The line under the title. In Needs attention, a row that itself needs
57
- * attention says why, and an attended family's rows have none; any other
58
- * row says why it waits on you or failed.
58
+ * The row draws its harness icon: a root whose harness is not bb's
59
+ * default, a child whose harness is not its parent thread's.
59
60
  */
61
+ harness: boolean;
62
+ /** The title is bold: the thread is unread. */
63
+ bold: boolean;
64
+ /** A root whose tree holds an unread thread: it offers Mark read for the whole tree. */
65
+ treeUnread: boolean;
66
+ /** The line under the title: why it waits on you or failed, in both densities. */
60
67
  note: RowNote | null;
68
+ /**
69
+ * In Comfortable density, a row with no note whose branch is known not to
70
+ * be its project's default draws that branch on its second line.
71
+ */
72
+ branchLine: string | null;
73
+ /**
74
+ * Where the pull request badge goes: after the branch on the second line,
75
+ * on the title line (a root off its default branch, as before), or nowhere.
76
+ */
77
+ pullRequest: "second-line" | "title" | null;
78
+ /** The machine's name, beside the age, for a thread off bb's primary machine. */
79
+ machine: string | null;
61
80
  /** The title, and the chip with it, step back: see `isDimmed`. */
62
81
  dimmed: boolean;
63
82
  /** A hidden thread shown because it needs attention or failed. */
64
83
  hiddenBadge: boolean;
65
- /** "In project X" when the thread is outside its family's group. */
84
+ /** "In project X" when the thread is outside its tree's group. */
66
85
  crossGroupLabel: string | null;
67
- /** A root in Needs attention: its home group's name, drawn where the age goes. */
68
- homeGroupLabel: string | null;
69
86
  projectId: string;
70
87
  }
71
88
 
89
+ /** The fold inside an open tree: "N more child threads". */
72
90
  export interface OlderRow {
73
91
  type: "older";
74
92
  key: string;
75
- /** A group id, or a parent thread id for a family's fold. */
93
+ /** The parent thread's id. */
76
94
  scopeId: string;
77
- /** `reveal`: an auto-reveal left `count` children hidden ("+N more"). */
78
- scope: "group" | "family" | "reveal";
95
+ /** `reveal`: an auto-reveal left `count` children hidden. */
96
+ scope: "tree" | "reveal";
79
97
  count: number;
80
98
  expanded: boolean;
81
99
  depth: number;
@@ -95,54 +113,46 @@ export interface EnvironmentRow {
95
113
  depth: number;
96
114
  }
97
115
 
98
- /** Under a closed family in Needs attention: how many of its child threads it leaves out ("+N more"). Opens the family. */
99
- export interface LeftOutRow {
100
- type: "left-out";
116
+ /** A group's settled fold: the faint "Settled (N)" divider at its end. */
117
+ export interface SettledRow {
118
+ type: "settled";
101
119
  key: string;
102
- /** The family's root. */
103
- scopeId: string;
120
+ groupId: string;
121
+ /** Settled trees in the group, the open one drawn above the divider included. */
104
122
  count: number;
105
- depth: number;
123
+ expanded: boolean;
106
124
  }
107
125
 
108
- export type Row = ThreadRow | OlderRow | EnvironmentRow | LeftOutRow;
126
+ /** The rows a tree and its folders draw. */
127
+ export type TreeRow = ThreadRow | OlderRow | EnvironmentRow;
128
+
129
+ export type Row = TreeRow | SettledRow;
109
130
 
110
131
  export interface GroupView {
111
132
  descriptor: GroupDescriptor;
112
133
  counters: Counters;
113
134
  /** What the user stored. */
114
135
  userCollapsed: boolean;
115
- /** What is drawn: user collapse, unless a target opened it. */
136
+ /**
137
+ * What is drawn: user collapse, unless opening a thread in the group
138
+ * opened it. A collapsed group still draws its trees that need attention.
139
+ */
116
140
  collapsed: boolean;
117
141
  hidden: boolean;
118
142
  rows: Row[];
119
- /** Every family root bucketed in the group, those in Needs attention and behind folds included. */
143
+ /** Every tree root bucketed in the group, those behind folds included. */
120
144
  rootIds: string[];
121
145
  }
122
146
 
123
- /** The id Needs attention's rows are drawn under; no group has it. */
124
- export const ATTENTION_GROUP_ID = "attention";
125
-
126
- /** The Needs attention section: every family with a thread that needs attention, or held there while one of its threads is open. */
127
- export interface AttentionView {
128
- /** How many families the section holds, attended ones left out, for its header. */
129
- familyCount: number;
130
- rows: Row[];
131
- /** The home group of each thread drawn here, by thread id: a drop on its row acts there. */
132
- homeGroupIds: Record<string, string>;
133
- }
134
-
135
147
  export interface ListView {
136
- /** Null when no family is in the section. */
137
- attention: AttentionView | null;
138
148
  groups: GroupView[];
139
149
  /** Hidden groups, shown in the More popover. */
140
150
  more: GroupView[];
141
151
  moreCounters: Counters;
142
152
  /** Resolved top-level order for the mode, for header drag. */
143
153
  order: string[];
144
- /** Threads span more than one host (the row's second line). */
145
- multiHost: boolean;
154
+ /** Thread trees that need attention, in every group, hidden ones included: the need-you filter's N. */
155
+ needYouCount: number;
146
156
  }
147
157
 
148
158
  export interface ViewInputs {
@@ -152,16 +162,28 @@ export interface ViewInputs {
152
162
  sections: readonly PluginSidebarSection[];
153
163
  prefs: Preferences;
154
164
  activeThreadId: string | null;
155
- /** The family Needs attention keeps while one of its threads is open, as it was when opened: the section orders it by this. */
156
- held: SectionFamily | null;
157
165
  targets: Targets;
166
+ settle: SettleInputs;
167
+ /** bb's default harness; null while unknown, when no root draws one. */
168
+ defaultProviderId: string | null;
169
+ /** bb's primary machine; null while unknown, when no row names its machine. */
170
+ primaryHostId: string | null;
171
+ /** Comfortable density: rows may take a branch line. */
172
+ comfortable: boolean;
173
+ /** A project's default branch: undefined while looked up, null when not found. */
174
+ defaultBranchOf(thread: PluginSidebarThread): string | null | undefined;
175
+ /**
176
+ * The need-you filter is on: every group, hidden ones included, draws only
177
+ * its trees that need attention, and a group with none is left out.
178
+ */
179
+ needYouOnly: boolean;
158
180
  }
159
181
 
160
182
  interface Context extends ViewInputs {
161
- heldRootId: string | null;
162
183
  compare: (a: SortKey, b: SortKey) => number;
163
184
  expandedChildren: ReadonlySet<string>;
164
185
  expandedOlder: ReadonlySet<string>;
186
+ openSettledFolds: ReadonlySet<string>;
165
187
  collapsedEnvironments: ReadonlySet<string>;
166
188
  /** Parent ids on the path to a reveal target. */
167
189
  revealPath: ReadonlySet<string>;
@@ -170,10 +192,6 @@ interface Context extends ViewInputs {
170
192
  activePath: ReadonlySet<string>;
171
193
  projectNames: ReadonlyMap<string, string>;
172
194
  sectionNames: ReadonlyMap<string, string>;
173
- /** The rows are drawn in Needs attention. */
174
- drawnInAttention: boolean;
175
- /** The rows are an attended family's: they draw as in its home group, with no bold title or why line. */
176
- attended: boolean;
177
195
  }
178
196
 
179
197
  function titleOf(context: Context, id: string | null): string | null {
@@ -203,21 +221,45 @@ function crossGroupLabel(context: Context, info: ThreadInfo, root: ThreadInfo):
203
221
  /**
204
222
  * Whether a row's title steps back: a quiet thread does, at any depth, so
205
223
  * brightness shows state and depth is left to the indent and smaller text.
206
- * A root with children stays bright until its whole family is quiet.
224
+ * A root with children stays bright until its whole tree is quiet.
207
225
  */
208
226
  function isDimmed(context: Context, info: ThreadInfo, chip: Chip | null): boolean {
209
- if (info.parentId === null && chip !== null) return context.forest.familyOf.get(info.thread.id)?.quiet ?? info.quiet;
227
+ if (info.parentId === null && chip !== null) return context.forest.treeOf.get(info.thread.id)?.quiet ?? info.quiet;
210
228
  return info.quiet;
211
229
  }
212
230
 
231
+
232
+ /** A root draws its harness when it differs from bb's default; a child, when it differs from its parent thread's. */
233
+ function drawsHarness(context: Context, info: ThreadInfo): boolean {
234
+ const providerId = info.thread.providerId;
235
+ if (info.parentId === null) return context.defaultProviderId !== null && providerId !== context.defaultProviderId;
236
+ return providerId !== context.forest.infos.get(info.parentId)?.thread.providerId;
237
+ }
238
+
239
+ /** The row's second line and pull request badge, as the density and the branch allow. */
240
+ function lines(context: Context, info: ThreadInfo, depth: number): Pick<ThreadRow, "branchLine" | "pullRequest"> {
241
+ const branch = info.thread.environment?.branchName ?? null;
242
+ const offDefault = isOffDefaultBranch(branch, context.defaultBranchOf(info.thread));
243
+ if (context.comfortable && info.note === null && offDefault) return { branchLine: branch, pullRequest: "second-line" };
244
+ return { branchLine: null, pullRequest: depth === 0 && offDefault ? "title" : null };
245
+ }
246
+
247
+ /** The machine a row names: one other than bb's primary, unless the list is grouped by machine. */
248
+ function machineOf(context: Context, info: ThreadInfo): string | null {
249
+ const host = info.thread.host;
250
+ if (context.prefs.organizationMode === "machine" || context.primaryHostId === null || host === null) return null;
251
+ return host.id === context.primaryHostId ? null : host.name || "Unknown machine";
252
+ }
253
+
213
254
  function threadRow(
214
255
  context: Context,
215
256
  info: ThreadInfo,
216
257
  root: ThreadInfo,
217
258
  options: { depth: number; nested: boolean; chip: Chip | null },
218
259
  ): ThreadRow {
219
- const needsAttention = !info.thread.isArchived && info.attentionFlags.size > 0;
220
260
  return {
261
+ ...lines(context, info, options.depth),
262
+ machine: machineOf(context, info),
221
263
  type: "thread",
222
264
  key: `thread:${info.thread.id}`,
223
265
  info,
@@ -225,12 +267,13 @@ function threadRow(
225
267
  nested: options.nested,
226
268
  parentTitle: titleOf(context, info.parentId),
227
269
  chip: options.chip,
228
- bold: info.unread && !context.attended,
229
- note: context.attended ? null : context.drawnInAttention && needsAttention ? info.attentionNote : info.note,
270
+ harness: drawsHarness(context, info),
271
+ bold: info.unread,
272
+ treeUnread: info === root && [root, ...(context.forest.treeOf.get(root.thread.id)?.descendants ?? [])].some((info) => info.unread),
273
+ note: info.note,
230
274
  dimmed: isDimmed(context, info, options.chip),
231
275
  hiddenBadge: info.thread.isHidden,
232
276
  crossGroupLabel: crossGroupLabel(context, info, root),
233
- homeGroupLabel: null,
234
277
  projectId: info.thread.projectId,
235
278
  };
236
279
  }
@@ -261,7 +304,7 @@ function foldedChildren(
261
304
  const kids = eligibleChildren(context, parentId);
262
305
 
263
306
  const reveals = (kid: ThreadInfo) => context.revealIds.has(kid.thread.id) || context.revealPath.has(kid.thread.id);
264
- const olderRow = (scope: "family" | "reveal", count: number, expanded: boolean): OlderRow | null =>
307
+ const olderRow = (scope: "tree" | "reveal", count: number, expanded: boolean): OlderRow | null =>
265
308
  count > 0
266
309
  ? {
267
310
  type: "older",
@@ -298,7 +341,7 @@ function foldedChildren(
298
341
  // Open, the fold still counts what it holds, so its row can close it again.
299
342
  const folded = kids.filter((kid) => !kept(kid)).length;
300
343
  const shown = olderExpanded ? kids : kids.filter(kept);
301
- return { shown, older: olderRow("family", folded, olderExpanded) };
344
+ return { shown, older: olderRow("tree", folded, olderExpanded) };
302
345
  }
303
346
 
304
347
  function environmentLabel(thread: PluginSidebarThread): string {
@@ -310,13 +353,13 @@ function environmentLabel(thread: PluginSidebarThread): string {
310
353
  return "Environment";
311
354
  }
312
355
 
313
- type Unit = { info: ThreadInfo; rows: Row[]; flags: ReadonlySet<Flag> };
356
+ type Unit = { info: ThreadInfo; rows: TreeRow[]; flags: ReadonlySet<Flag> };
314
357
 
315
358
  /**
316
359
  * Folds sibling units that share a worktree environment (2 or more) into a
317
360
  * folder row. Off unless the environment grouping preference is on.
318
361
  */
319
- function clusterEnvironments(context: Context, units: Unit[], depth: number): Row[] {
362
+ function clusterEnvironments(context: Context, units: Unit[], depth: number): TreeRow[] {
320
363
  if (!context.prefs.environmentGrouping) return units.flatMap((unit) => unit.rows);
321
364
  const counts = new Map<string, number>();
322
365
  for (const unit of units) {
@@ -325,7 +368,7 @@ function clusterEnvironments(context: Context, units: Unit[], depth: number): Ro
325
368
  counts.set(environment.id, (counts.get(environment.id) ?? 0) + 1);
326
369
  }
327
370
  }
328
- const rows: Row[] = [];
371
+ const rows: TreeRow[] = [];
329
372
  const emitted = new Set<string>();
330
373
  for (const unit of units) {
331
374
  const environment = unit.info.thread.environment;
@@ -362,30 +405,13 @@ function clusterEnvironments(context: Context, units: Unit[], depth: number): Ro
362
405
  return rows;
363
406
  }
364
407
 
365
- /** Distinct child harnesses other than the parent's, at most three. */
366
- function childProviders(parent: ThreadInfo, descendants: readonly ThreadInfo[]): string[] {
367
- const ids: string[] = [];
368
- for (const info of descendants) {
369
- const id = info.thread.providerId;
370
- if (info.thread.isHidden || id === parent.thread.providerId || ids.includes(id)) continue;
371
- ids.push(id);
372
- if (ids.length === 3) break;
373
- }
374
- return ids;
375
- }
376
-
377
- /** The chip of a parent: its children's count, most urgent flag and harnesses. */
408
+ /** The children chip of a parent, or null when it would show nothing. */
378
409
  function chipOf(context: Context, info: ThreadInfo, expanded: boolean): Chip | null {
379
- const subtree = subtreeOf(context, info.thread.id);
380
- const hasChildren =
381
- subtree.visibleCount > 0 || subtree.descendants.some((descendant) => descendant.thread.isHidden && descendant.attentionFlags.size > 0);
382
- if (!hasChildren) return null;
383
- return {
384
- count: subtree.visibleCount,
385
- flag: mostUrgent(subtree.flags),
386
- expanded,
387
- providerIds: childProviders(info, subtree.descendants),
388
- };
410
+ const { childCount: count, chipFlags } = subtreeOf(context, info.thread.id);
411
+ const flag = mostUrgent(chipFlags);
412
+ // A hidden child's failure that needs no attention has no row, yet the chip still shows it.
413
+ if (count === 0 && flag === null && eligibleChildren(context, info.thread.id).length === 0) return null;
414
+ return { count, expanded, flag };
389
415
  }
390
416
 
391
417
  /** What a thread and everything under it carry, for an environment folder's glyph. */
@@ -396,20 +422,20 @@ function rollupFlags(context: Context, info: ThreadInfo): Set<Flag> {
396
422
  }
397
423
 
398
424
  /** The rows under `parent`: each shown child, then its own level. */
399
- function foldedLevel(context: Context, family: Family, parent: ThreadInfo, depth: number): Row[] {
425
+ function foldedLevel(context: Context, tree: ThreadTree, parent: ThreadInfo, depth: number): TreeRow[] {
400
426
  const { shown, older } = foldedChildren(context, parent, depth);
401
427
  const units: Unit[] = shown.map((info) => ({
402
428
  info,
403
429
  flags: rollupFlags(context, info),
404
430
  rows: [
405
- threadRow(context, info, family.root, {
431
+ threadRow(context, info, tree.root, {
406
432
  depth,
407
433
  nested: depth > 1,
408
434
  // Expanded only when the user expanded it: an auto-reveal shows a
409
435
  // chevron that still expands, and a "N more child threads" row.
410
436
  chip: chipOf(context, info, context.expandedChildren.has(info.thread.id)),
411
437
  }),
412
- ...foldedLevel(context, family, info, depth + 1),
438
+ ...foldedLevel(context, tree, info, depth + 1),
413
439
  ],
414
440
  }));
415
441
  const rows = clusterEnvironments(context, units, depth);
@@ -417,180 +443,69 @@ function foldedLevel(context: Context, family: Family, parent: ThreadInfo, depth
417
443
  return rows;
418
444
  }
419
445
 
420
- function foldedFamilyRows(context: Context, family: Family): Row[] {
421
- const root = family.root;
446
+ function foldedTreeRows(context: Context, tree: ThreadTree): TreeRow[] {
447
+ const root = tree.root;
422
448
  const chip = chipOf(context, root, context.expandedChildren.has(root.thread.id));
423
- return [threadRow(context, root, root, { depth: 0, nested: false, chip }), ...foldedLevel(context, family, root, 1)];
449
+ return [threadRow(context, root, root, { depth: 0, nested: false, chip }), ...foldedLevel(context, tree, root, 1)];
424
450
  }
425
451
 
426
- function familyUnit(context: Context, family: Family): Unit {
427
- return { info: family.root, rows: foldedFamilyRows(context, family), flags: family.flags };
452
+ function treeUnit(context: Context, tree: ThreadTree): Unit {
453
+ return { info: tree.root, rows: foldedTreeRows(context, tree), flags: tree.flags };
428
454
  }
429
455
 
430
- /** Waiting on you, then failed, then offline, then unread, then the rest. */
431
- export function urgency(family: Pick<Family, "attentionFlags">): number {
432
- const flags = family.attentionFlags;
433
- if (flags.has("waits-on-you")) return 0;
434
- if (flags.has("unread-failed") || flags.has("queue-failed")) return 1;
435
- if (flags.has("offline")) return 2;
436
- if (flags.has("unread")) return 3;
437
- return 4;
456
+ /** Whether a thread in the tree needs attention: it stays drawn when its group is collapsed. */
457
+ export function needsAttention(tree: Pick<ThreadTree, "attentionFlags">): boolean {
458
+ return tree.attentionFlags.size > 0;
438
459
  }
439
460
 
440
- /**
441
- * One family's rows in Needs attention. It arrives with the path from the root
442
- * down to each thread that needs attention or is open. Closed, the path is all
443
- * it draws, with one "+N more" line for the child threads left out; opened
444
- * with the root's chip, it draws as in its home group and keeps the path.
445
- */
446
- function attentionFamilyRows(groupContext: Context, family: Family, homeGroupLabel: string): Row[] {
447
- const context: Context = {
448
- ...groupContext,
449
- drawnInAttention: true,
450
- attended: isAttended(family, groupContext.heldRootId),
451
- };
452
- const root = family.root;
453
- const onPath = new Set<string>([root.thread.id]);
454
- for (const info of family.descendants) {
455
- const attention = !info.thread.isArchived && info.attentionFlags.size > 0;
456
- if (!attention && !info.isActive) continue;
457
- onPath.add(info.thread.id);
458
- for (const ancestor of ancestorsOf(info.thread.id, context.forest.infos, root.thread.id)) onPath.add(ancestor);
459
- }
460
- const expanded = context.expandedChildren.has(root.thread.id);
461
- if (expanded) {
462
- // The path is kept the way a reveal target's is.
463
- const kept: Context = { ...context, revealIds: new Set([...context.revealIds, ...onPath]) };
464
- const [rootRow, ...rest] = foldedFamilyRows(kept, family);
465
- return [{ ...(rootRow as ThreadRow), homeGroupLabel }, ...rest];
466
- }
467
- const chip = chipOf(context, root, false);
468
- const rows: Row[] = [{ ...threadRow(context, root, root, { depth: 0, nested: false, chip }), homeGroupLabel }];
469
- const walk = (parentId: string, depth: number) => {
470
- for (const id of context.forest.children.get(parentId) ?? []) {
471
- if (!onPath.has(id)) continue;
472
- rows.push(threadRow(context, context.forest.infos.get(id)!, root, { depth, nested: depth > 1, chip: null }));
473
- walk(id, depth + 1);
474
- }
475
- };
476
- walk(root.thread.id, 1);
477
- const left = family.descendants.filter((info) => !info.thread.isHidden && !onPath.has(info.thread.id)).length;
478
- if (left > 0) rows.push({ type: "left-out", key: `left-out:${root.thread.id}`, scopeId: root.thread.id, count: left, depth: 1 });
479
- return rows;
461
+ /** The need-you filter's N: thread trees that need attention, in every group. */
462
+ export function countNeedYou(forest: Pick<Forest, "trees">): number {
463
+ return forest.trees.filter(needsAttention).length;
480
464
  }
481
465
 
482
- function buildAttention(
483
- context: Context,
484
- families: readonly Family[],
485
- homeGroupOf: (family: Family) => GroupDescriptor,
486
- ): AttentionView | null {
487
- if (families.length === 0) return null;
488
- // Most urgent first, then the chosen field in its natural direction: the
489
- // sort direction orders the groups only. The held family sorts as it was
490
- // when opened, so nothing inside it moves it.
491
- const compare = makeComparator({
492
- field: context.prefs.chronologicalSort,
493
- direction: "default",
494
- workingFirst: context.prefs.workingFirst,
495
- });
496
- const slotOf = (family: Family): SectionFamily =>
497
- context.held !== null && family.root.thread.id === context.heldRootId ? context.held : family;
498
- const sorted = [...families].sort((familyA, familyB) => {
499
- const a = slotOf(familyA);
500
- const b = slotOf(familyB);
501
- return (
502
- urgency(a) - urgency(b) ||
503
- compare(
504
- { thread: a.root.thread, familyAttention: a.latestAttentionAt },
505
- { thread: b.root.thread, familyAttention: b.latestAttentionAt },
506
- )
507
- );
508
- });
509
- const homeGroupIds: Record<string, string> = {};
510
- const rows = sorted.flatMap((family) => {
511
- const home = homeGroupOf(family);
512
- const familyRows = attentionFamilyRows(context, family, home.label);
513
- for (const row of familyRows) if (row.type === "thread") homeGroupIds[row.info.thread.id] = home.id;
514
- return familyRows;
515
- });
516
- return {
517
- familyCount: sorted.filter((family) => !isAttended(family, context.heldRootId)).length,
518
- rows,
519
- homeGroupIds,
520
- };
466
+ /** The filter narrows the list only while something needs you: at 0 the full list is back. */
467
+ export function needYouActive(on: boolean, count: number): boolean {
468
+ return on && count > 0;
521
469
  }
522
470
 
523
471
  /**
524
- * One group. `families` is every family bucketed in it, for its counters and
525
- * `rootIds`; `drawn` leaves out those in Needs attention.
472
+ * One group. `trees` is every tree bucketed in it. Collapsed, it draws only
473
+ * its trees that need attention; opening a thread in it opens it, as an
474
+ * auto-reveal did.
526
475
  */
527
- function buildGroup(
528
- context: Context,
529
- descriptor: GroupDescriptor,
530
- families: Family[],
531
- drawn: Family[],
532
- hidden: boolean,
533
- ): GroupView {
534
- const counters = countFamilies(families);
476
+ function buildGroup(context: Context, descriptor: GroupDescriptor, trees: ThreadTree[], hidden: boolean): GroupView {
477
+ if (context.needYouOnly) return needYouGroup(context, descriptor, trees, hidden);
478
+ const counters = countTrees(trees);
535
479
  const userCollapsed = isGroupCollapsed(descriptor, context.prefs);
536
- const hasTarget = drawn.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 collapsed = userCollapsed && !hasTarget;
480
+ const activeId = context.activeThreadId;
481
+ const opened =
482
+ activeId !== null && context.targets.has(activeId) && trees.some((tree) => tree.containsActive);
483
+ const collapsed = userCollapsed && !opened;
542
484
  const isPinned = descriptor.id === PINNED_GROUP_ID;
543
485
 
544
- const sorted = [...drawn];
545
- if (isPinned) {
546
- sorted.sort((a, b) => comparePinned(a.root.thread, b.root.thread));
547
- } else {
548
- sorted.sort((a, b) =>
549
- context.compare(
550
- { thread: a.root.thread, familyAttention: a.latestAttentionAt },
551
- { thread: b.root.thread, familyAttention: b.latestAttentionAt },
552
- ),
553
- );
554
- }
486
+ const sorted = sortTrees(context, descriptor, trees);
555
487
 
556
- let visible = sorted;
557
- let older: OlderRow | null = null;
558
- const foldable = !isPinned && context.prefs.foldOlder;
559
- if (foldable) {
560
- // The fold reads `quietIgnoringOpen`, as if no thread were open. The open family
561
- // joins afterwards when it sits behind the fold, and takes no other row's place.
562
- const quietActive = sorted.filter((family) => family.quietIgnoringOpen && !family.root.thread.isArchived);
563
- // The newest quiet roots stay whatever the order: by creation under
564
- // Created, by latest activity otherwise.
565
- const byCreation = effectiveSortField(context.prefs.chronologicalSort) === "created";
566
- const age = (family: Family) => (byCreation ? family.root.thread.createdAt : family.latestAttentionAt);
567
- const keepQuiet = new Set([...quietActive].sort((a, b) => age(b) - age(a)).slice(0, KEEP_QUIET));
568
- const foldedFamilies = quietActive.filter((family) => !keepQuiet.has(family) && !family.containsActive);
569
- if (foldedFamilies.length > 0) {
570
- const opened =
571
- context.expandedOlder.has(descriptor.id) ||
572
- foldedFamilies.some(
573
- (family) =>
574
- context.targets.has(family.root.thread.id) ||
575
- family.descendants.some((info) => context.targets.has(info.thread.id)),
576
- );
577
- const folded = new Set(foldedFamilies);
578
- if (!opened) visible = sorted.filter((family) => !folded.has(family));
579
- older = {
580
- type: "older",
581
- key: `older:group:${descriptor.id}`,
582
- scopeId: descriptor.id,
583
- scope: "group",
584
- count: foldedFamilies.length,
585
- expanded: opened,
586
- depth: 0,
587
- };
488
+ let rows: Row[];
489
+ if (collapsed) {
490
+ rows = clusterEnvironments(context, sorted.filter(needsAttention).map((tree) => treeUnit(context, tree)), 0);
491
+ } else {
492
+ // Settled trees go behind the fold at the group's end. The open one, if
493
+ // settled, is drawn just above the divider, so opening it moves nothing else.
494
+ const settled = sorted.filter((tree) => isSettledTree(tree, context.settle));
495
+ const settledSet = new Set(settled);
496
+ const live = sorted.filter((tree) => !settledSet.has(tree));
497
+ const open = settled.find((tree) => tree.containsActive) ?? null;
498
+ rows = clusterEnvironments(context, live.map((tree) => treeUnit(context, tree)), 0);
499
+ if (settled.length > 0) {
500
+ if (open !== null) rows.push(...treeUnit(context, open).rows);
501
+ const expanded = context.openSettledFolds.has(descriptor.id);
502
+ rows.push({ type: "settled", key: `settled:${descriptor.id}`, groupId: descriptor.id, count: settled.length, expanded });
503
+ if (expanded) {
504
+ const folded = settled.filter((tree) => tree !== open).map((tree) => treeUnit(context, tree));
505
+ rows.push(...clusterEnvironments(context, folded, 0));
506
+ }
588
507
  }
589
508
  }
590
-
591
- const units = visible.map((family) => familyUnit(context, family));
592
- const rows = clusterEnvironments(context, units, 0);
593
- if (older !== null) rows.push(older);
594
509
  return {
595
510
  descriptor,
596
511
  counters,
@@ -598,7 +513,32 @@ function buildGroup(
598
513
  collapsed,
599
514
  hidden,
600
515
  rows,
601
- rootIds: families.map((family) => family.root.thread.id),
516
+ rootIds: trees.map((tree) => tree.root.thread.id),
517
+ };
518
+ }
519
+
520
+ /** A group's trees in its order: Pinned's own, else the chosen sort. */
521
+ function sortTrees(context: Context, descriptor: GroupDescriptor, trees: readonly ThreadTree[]): ThreadTree[] {
522
+ if (descriptor.id === PINNED_GROUP_ID) return [...trees].sort((a, b) => comparePinned(a.root.thread, b.root.thread));
523
+ return [...trees].sort((a, b) =>
524
+ context.compare(
525
+ { thread: a.root.thread, treeAttention: a.latestAttentionAt },
526
+ { thread: b.root.thread, treeAttention: b.latestAttentionAt },
527
+ ),
528
+ );
529
+ }
530
+
531
+ /** A group under the need-you filter: its header, and its trees that need attention, whatever its collapse. */
532
+ function needYouGroup(context: Context, descriptor: GroupDescriptor, trees: ThreadTree[], hidden: boolean): GroupView {
533
+ const sorted = sortTrees(context, descriptor, trees);
534
+ return {
535
+ descriptor,
536
+ counters: countTrees(trees),
537
+ userCollapsed: isGroupCollapsed(descriptor, context.prefs),
538
+ collapsed: false,
539
+ hidden,
540
+ rows: clusterEnvironments(context, sorted.filter(needsAttention).map((tree) => treeUnit(context, tree)), 0),
541
+ rootIds: trees.map((tree) => tree.root.thread.id),
602
542
  };
603
543
  }
604
544
 
@@ -618,40 +558,28 @@ export function buildListView(inputs: ViewInputs): ListView {
618
558
  }
619
559
  const context: Context = {
620
560
  ...inputs,
621
- heldRootId: inputs.held?.root.thread.id ?? null,
622
- compare: makeComparator({
623
- field: prefs.chronologicalSort,
624
- direction: prefs.sortDirection,
625
- workingFirst: prefs.workingFirst,
626
- }),
561
+ compare: makeComparator({ field: prefs.chronologicalSort, direction: prefs.sortDirection }),
627
562
  expandedChildren: new Set(prefs.expandedChildren),
628
563
  expandedOlder: new Set(prefs.expandedOlder),
564
+ openSettledFolds: new Set(prefs.openSettledFolds),
629
565
  collapsedEnvironments: new Set(prefs.collapsedEnvironments),
630
566
  revealIds,
631
567
  revealPath,
632
568
  activePath,
633
569
  projectNames: new Map(inputs.projects.map((project) => [project.id, project.name])),
634
570
  sectionNames: new Map(inputs.sections.map((section) => [section.id, section.name])),
635
- drawnInAttention: false,
636
- attended: false,
637
571
  };
638
572
 
639
- const byGroup = new Map<string, Family[]>();
640
- const homeOf = new Map<Family, string>();
641
- const section: Family[] = [];
642
- const openRootId = inputs.activeThreadId === null ? null : (forest.familyOf.get(inputs.activeThreadId)?.root.thread.id ?? null);
643
- for (const family of forest.families) {
644
- const id = groupIdForRoot(family.root.thread, {
573
+ const byGroup = new Map<string, ThreadTree[]>();
574
+ for (const tree of forest.trees) {
575
+ const id = groupIdForRoot(tree.root.thread, {
645
576
  mode: prefs.organizationMode,
646
577
  projects: inputs.projects,
647
578
  });
648
579
  const list = byGroup.get(id) ?? [];
649
- list.push(family);
580
+ list.push(tree);
650
581
  byGroup.set(id, list);
651
- homeOf.set(family, id);
652
- if (inAttention(family, context.heldRootId, openRootId)) section.push(family);
653
582
  }
654
- const inSection = new Set(section);
655
583
 
656
584
  const alphabetical = effectiveSortField(prefs.chronologicalSort) === "alpha";
657
585
  const entities = entityGroups(
@@ -674,16 +602,20 @@ export function buildListView(inputs: ViewInputs): ListView {
674
602
  for (const id of order) {
675
603
  const descriptor = descriptors.get(id);
676
604
  if (descriptor === undefined) continue;
677
- const families = byGroup.get(id) ?? [];
678
- const drawn = families.filter((family) => !inSection.has(family));
679
- // Pinned and the loose bucket appear only when they hold threads outside
680
- // Needs attention, except the loose bucket in custom-section mode, as in bb.
681
- if (drawn.length === 0) {
605
+ const trees = byGroup.get(id) ?? [];
606
+ // Pinned and the loose bucket appear only when they hold threads, except
607
+ // the loose bucket in custom-section mode, as in bb.
608
+ if (trees.length === 0) {
682
609
  if (id === PINNED_GROUP_ID) continue;
683
610
  if (id === THREADS_GROUP_ID && prefs.organizationMode !== "chronological") continue;
684
611
  }
685
612
  const hidden = id !== PINNED_GROUP_ID && hiddenIds.has(id);
686
- const view = buildGroup(context, descriptor, families, drawn, hidden);
613
+ if (context.needYouOnly) {
614
+ // Under the filter, a hidden group's trees come out of More, each under its own header.
615
+ if (trees.some(needsAttention)) groups.push(buildGroup(context, descriptor, trees, hidden));
616
+ continue;
617
+ }
618
+ const view = buildGroup(context, descriptor, trees, hidden);
687
619
  if (hidden) {
688
620
  more.push(view);
689
621
  moreCounters = addCounters(moreCounters, view.counters);
@@ -692,11 +624,7 @@ export function buildListView(inputs: ViewInputs): ListView {
692
624
  }
693
625
  }
694
626
 
695
- const hosts = new Set<string>();
696
- for (const thread of inputs.threads) if (thread.host !== null) hosts.add(thread.host.id);
697
- // Every root's group id has a descriptor: entity groups include unknown sections and every host.
698
- const attention = buildAttention(context, section, (family) => descriptors.get(homeOf.get(family)!)!);
699
- return { attention, groups, more, moreCounters, order, multiHost: hosts.size > 1 };
627
+ return { groups, more, moreCounters, order, needYouCount: countNeedYou(forest) };
700
628
  }
701
629
 
702
630
  /** Every thread row in visual order, for keyboard and windowing. */