@gperezmz/bb-plugin-thread-glance 0.2.3 → 0.3.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 (55) hide show
  1. package/README.md +1 -1
  2. package/app.tsx +2 -1
  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 +8 -50
  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/{Toolbar.tsx → SettingsPanel.tsx} +26 -57
  15. package/features/thread-list/components/ThreadDetails.tsx +3 -3
  16. package/features/thread-list/components/ThreadList.tsx +116 -84
  17. package/features/thread-list/components/ThreadRowView.tsx +70 -78
  18. package/features/thread-list/components/controller.ts +5 -10
  19. package/features/thread-list/components/glyphs.tsx +39 -14
  20. package/features/thread-list/data/same-window.ts +34 -0
  21. package/features/thread-list/data/useAutoExpand.ts +1 -1
  22. package/features/thread-list/data/useClientPreferences.ts +15 -7
  23. package/features/thread-list/data/useDefaultBranches.ts +35 -0
  24. package/features/thread-list/data/usePreferences.ts +16 -7
  25. package/features/thread-list/data/usePullRequestAnswers.ts +36 -0
  26. package/features/thread-list/data/useSystemFacts.ts +52 -0
  27. package/features/thread-list/icons.ts +1 -1
  28. package/features/thread-list/model/attention.ts +2 -66
  29. package/features/thread-list/model/branches.ts +27 -0
  30. package/features/thread-list/model/counters.ts +5 -5
  31. package/features/thread-list/model/details.ts +6 -6
  32. package/features/thread-list/model/expansion.ts +5 -5
  33. package/features/thread-list/model/groups.ts +2 -2
  34. package/features/thread-list/model/labels.ts +28 -17
  35. package/features/thread-list/model/layout.ts +5 -2
  36. package/features/thread-list/model/menu.ts +1 -0
  37. package/features/thread-list/model/move.ts +1 -1
  38. package/features/thread-list/model/notes.ts +2 -28
  39. package/features/thread-list/model/settings.ts +2 -18
  40. package/features/thread-list/model/settled.ts +122 -0
  41. package/features/thread-list/model/share.ts +1 -7
  42. package/features/thread-list/model/sort.ts +5 -16
  43. package/features/thread-list/model/state.ts +25 -9
  44. package/features/thread-list/model/toggles.ts +35 -24
  45. package/features/thread-list/model/{families.ts → trees.ts} +40 -28
  46. package/features/thread-list/model/view.ts +200 -274
  47. package/features/thread-list/testing/fixtures.ts +41 -12
  48. package/package.json +1 -1
  49. package/server/cli.ts +1 -1
  50. package/server/import.ts +36 -6
  51. package/server/preference-store.ts +2 -1
  52. package/server.ts +1 -1
  53. package/shared/preferences.ts +38 -27
  54. package/skills/thread-glance/SKILL.md +11 -12
  55. 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,23 @@ 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 muted count of a parent's direct children, and its chevron. */
33
33
  export interface Chip {
34
+ /** Direct children opening it shows, hidden ones left out. */
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[];
40
38
  }
41
39
 
42
40
  export interface ThreadRow {
@@ -50,32 +48,51 @@ export interface ThreadRow {
50
48
  /** Title of the thread this one attaches to, for tooltips and labels. */
51
49
  parentTitle: string | null;
52
50
  chip: Chip | null;
53
- /** The title is bold: the thread is unread and its family is not attended. */
54
- bold: boolean;
55
51
  /**
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.
52
+ * The row draws its harness icon: a root whose harness is not bb's
53
+ * default, a child whose harness is not its parent thread's.
59
54
  */
55
+ harness: boolean;
56
+ /**
57
+ * The child dot on the status glyph: the most urgent state among the
58
+ * thread's descendants at any depth, or null for none.
59
+ */
60
+ childDot: Flag | null;
61
+ /** The title is bold: the thread is unread. */
62
+ bold: boolean;
63
+ /** A root whose tree holds an unread thread: it offers Mark read for the whole tree. */
64
+ treeUnread: boolean;
65
+ /** The line under the title: why it waits on you or failed, in both densities. */
60
66
  note: RowNote | null;
67
+ /**
68
+ * In Comfortable density, a row with no note whose branch is known not to
69
+ * be its project's default draws that branch on its second line.
70
+ */
71
+ branchLine: string | null;
72
+ /**
73
+ * Where the pull request badge goes: after the branch on the second line,
74
+ * on the title line (a root off its default branch, as before), or nowhere.
75
+ */
76
+ pullRequest: "second-line" | "title" | null;
77
+ /** The machine's name, beside the age, for a thread off bb's primary machine. */
78
+ machine: string | null;
61
79
  /** The title, and the chip with it, step back: see `isDimmed`. */
62
80
  dimmed: boolean;
63
81
  /** A hidden thread shown because it needs attention or failed. */
64
82
  hiddenBadge: boolean;
65
- /** "In project X" when the thread is outside its family's group. */
83
+ /** "In project X" when the thread is outside its tree's group. */
66
84
  crossGroupLabel: string | null;
67
- /** A root in Needs attention: its home group's name, drawn where the age goes. */
68
- homeGroupLabel: string | null;
69
85
  projectId: string;
70
86
  }
71
87
 
88
+ /** The fold inside an open tree: "N more child threads". */
72
89
  export interface OlderRow {
73
90
  type: "older";
74
91
  key: string;
75
- /** A group id, or a parent thread id for a family's fold. */
92
+ /** The parent thread's id. */
76
93
  scopeId: string;
77
- /** `reveal`: an auto-reveal left `count` children hidden ("+N more"). */
78
- scope: "group" | "family" | "reveal";
94
+ /** `reveal`: an auto-reveal left `count` children hidden. */
95
+ scope: "tree" | "reveal";
79
96
  count: number;
80
97
  expanded: boolean;
81
98
  depth: number;
@@ -95,54 +112,46 @@ export interface EnvironmentRow {
95
112
  depth: number;
96
113
  }
97
114
 
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";
115
+ /** A group's settled fold: the faint "Settled (N)" divider at its end. */
116
+ export interface SettledRow {
117
+ type: "settled";
101
118
  key: string;
102
- /** The family's root. */
103
- scopeId: string;
119
+ groupId: string;
120
+ /** Settled trees in the group, the open one drawn above the divider included. */
104
121
  count: number;
105
- depth: number;
122
+ expanded: boolean;
106
123
  }
107
124
 
108
- export type Row = ThreadRow | OlderRow | EnvironmentRow | LeftOutRow;
125
+ /** The rows a tree and its folders draw. */
126
+ export type TreeRow = ThreadRow | OlderRow | EnvironmentRow;
127
+
128
+ export type Row = TreeRow | SettledRow;
109
129
 
110
130
  export interface GroupView {
111
131
  descriptor: GroupDescriptor;
112
132
  counters: Counters;
113
133
  /** What the user stored. */
114
134
  userCollapsed: boolean;
115
- /** What is drawn: user collapse, unless a target opened it. */
135
+ /**
136
+ * What is drawn: user collapse, unless opening a thread in the group
137
+ * opened it. A collapsed group still draws its trees that need attention.
138
+ */
116
139
  collapsed: boolean;
117
140
  hidden: boolean;
118
141
  rows: Row[];
119
- /** Every family root bucketed in the group, those in Needs attention and behind folds included. */
142
+ /** Every tree root bucketed in the group, those behind folds included. */
120
143
  rootIds: string[];
121
144
  }
122
145
 
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
146
  export interface ListView {
136
- /** Null when no family is in the section. */
137
- attention: AttentionView | null;
138
147
  groups: GroupView[];
139
148
  /** Hidden groups, shown in the More popover. */
140
149
  more: GroupView[];
141
150
  moreCounters: Counters;
142
151
  /** Resolved top-level order for the mode, for header drag. */
143
152
  order: string[];
144
- /** Threads span more than one host (the row's second line). */
145
- multiHost: boolean;
153
+ /** Thread trees that need attention, in every group, hidden ones included: the need-you filter's N. */
154
+ needYouCount: number;
146
155
  }
147
156
 
148
157
  export interface ViewInputs {
@@ -152,16 +161,28 @@ export interface ViewInputs {
152
161
  sections: readonly PluginSidebarSection[];
153
162
  prefs: Preferences;
154
163
  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
164
  targets: Targets;
165
+ settle: SettleInputs;
166
+ /** bb's default harness; null while unknown, when no root draws one. */
167
+ defaultProviderId: string | null;
168
+ /** bb's primary machine; null while unknown, when no row names its machine. */
169
+ primaryHostId: string | null;
170
+ /** Comfortable density: rows may take a branch line. */
171
+ comfortable: boolean;
172
+ /** A project's default branch: undefined while looked up, null when not found. */
173
+ defaultBranchOf(thread: PluginSidebarThread): string | null | undefined;
174
+ /**
175
+ * The need-you filter is on: every group, hidden ones included, draws only
176
+ * its trees that need attention, and a group with none is left out.
177
+ */
178
+ needYouOnly: boolean;
158
179
  }
159
180
 
160
181
  interface Context extends ViewInputs {
161
- heldRootId: string | null;
162
182
  compare: (a: SortKey, b: SortKey) => number;
163
183
  expandedChildren: ReadonlySet<string>;
164
184
  expandedOlder: ReadonlySet<string>;
185
+ openSettledFolds: ReadonlySet<string>;
165
186
  collapsedEnvironments: ReadonlySet<string>;
166
187
  /** Parent ids on the path to a reveal target. */
167
188
  revealPath: ReadonlySet<string>;
@@ -170,10 +191,6 @@ interface Context extends ViewInputs {
170
191
  activePath: ReadonlySet<string>;
171
192
  projectNames: ReadonlyMap<string, string>;
172
193
  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
194
  }
178
195
 
179
196
  function titleOf(context: Context, id: string | null): string | null {
@@ -203,21 +220,45 @@ function crossGroupLabel(context: Context, info: ThreadInfo, root: ThreadInfo):
203
220
  /**
204
221
  * Whether a row's title steps back: a quiet thread does, at any depth, so
205
222
  * 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.
223
+ * A root with children stays bright until its whole tree is quiet.
207
224
  */
208
225
  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;
226
+ if (info.parentId === null && chip !== null) return context.forest.treeOf.get(info.thread.id)?.quiet ?? info.quiet;
210
227
  return info.quiet;
211
228
  }
212
229
 
230
+
231
+ /** A root draws its harness when it differs from bb's default; a child, when it differs from its parent thread's. */
232
+ function drawsHarness(context: Context, info: ThreadInfo): boolean {
233
+ const providerId = info.thread.providerId;
234
+ if (info.parentId === null) return context.defaultProviderId !== null && providerId !== context.defaultProviderId;
235
+ return providerId !== context.forest.infos.get(info.parentId)?.thread.providerId;
236
+ }
237
+
238
+ /** The row's second line and pull request badge, as the density and the branch allow. */
239
+ function lines(context: Context, info: ThreadInfo, depth: number): Pick<ThreadRow, "branchLine" | "pullRequest"> {
240
+ const branch = info.thread.environment?.branchName ?? null;
241
+ const offDefault = isOffDefaultBranch(branch, context.defaultBranchOf(info.thread));
242
+ if (context.comfortable && info.note === null && offDefault) return { branchLine: branch, pullRequest: "second-line" };
243
+ return { branchLine: null, pullRequest: depth === 0 && offDefault ? "title" : null };
244
+ }
245
+
246
+ /** The machine a row names: one other than bb's primary, unless the list is grouped by machine. */
247
+ function machineOf(context: Context, info: ThreadInfo): string | null {
248
+ const host = info.thread.host;
249
+ if (context.prefs.organizationMode === "machine" || context.primaryHostId === null || host === null) return null;
250
+ return host.id === context.primaryHostId ? null : host.name || "Unknown machine";
251
+ }
252
+
213
253
  function threadRow(
214
254
  context: Context,
215
255
  info: ThreadInfo,
216
256
  root: ThreadInfo,
217
257
  options: { depth: number; nested: boolean; chip: Chip | null },
218
258
  ): ThreadRow {
219
- const needsAttention = !info.thread.isArchived && info.attentionFlags.size > 0;
220
259
  return {
260
+ ...lines(context, info, options.depth),
261
+ machine: machineOf(context, info),
221
262
  type: "thread",
222
263
  key: `thread:${info.thread.id}`,
223
264
  info,
@@ -225,12 +266,14 @@ function threadRow(
225
266
  nested: options.nested,
226
267
  parentTitle: titleOf(context, info.parentId),
227
268
  chip: options.chip,
228
- bold: info.unread && !context.attended,
229
- note: context.attended ? null : context.drawnInAttention && needsAttention ? info.attentionNote : info.note,
269
+ harness: drawsHarness(context, info),
270
+ childDot: mostUrgent(subtreeOf(context, info.thread.id).dotFlags),
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,11 @@ 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 opening 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 count = subtreeOf(context, info.thread.id).childCount;
411
+ if (count === 0 && eligibleChildren(context, info.thread.id).length === 0) return null;
412
+ return { count, expanded };
389
413
  }
390
414
 
391
415
  /** What a thread and everything under it carry, for an environment folder's glyph. */
@@ -396,20 +420,20 @@ function rollupFlags(context: Context, info: ThreadInfo): Set<Flag> {
396
420
  }
397
421
 
398
422
  /** The rows under `parent`: each shown child, then its own level. */
399
- function foldedLevel(context: Context, family: Family, parent: ThreadInfo, depth: number): Row[] {
423
+ function foldedLevel(context: Context, tree: ThreadTree, parent: ThreadInfo, depth: number): TreeRow[] {
400
424
  const { shown, older } = foldedChildren(context, parent, depth);
401
425
  const units: Unit[] = shown.map((info) => ({
402
426
  info,
403
427
  flags: rollupFlags(context, info),
404
428
  rows: [
405
- threadRow(context, info, family.root, {
429
+ threadRow(context, info, tree.root, {
406
430
  depth,
407
431
  nested: depth > 1,
408
432
  // Expanded only when the user expanded it: an auto-reveal shows a
409
433
  // chevron that still expands, and a "N more child threads" row.
410
434
  chip: chipOf(context, info, context.expandedChildren.has(info.thread.id)),
411
435
  }),
412
- ...foldedLevel(context, family, info, depth + 1),
436
+ ...foldedLevel(context, tree, info, depth + 1),
413
437
  ],
414
438
  }));
415
439
  const rows = clusterEnvironments(context, units, depth);
@@ -417,180 +441,69 @@ function foldedLevel(context: Context, family: Family, parent: ThreadInfo, depth
417
441
  return rows;
418
442
  }
419
443
 
420
- function foldedFamilyRows(context: Context, family: Family): Row[] {
421
- const root = family.root;
444
+ function foldedTreeRows(context: Context, tree: ThreadTree): TreeRow[] {
445
+ const root = tree.root;
422
446
  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)];
447
+ return [threadRow(context, root, root, { depth: 0, nested: false, chip }), ...foldedLevel(context, tree, root, 1)];
424
448
  }
425
449
 
426
- function familyUnit(context: Context, family: Family): Unit {
427
- return { info: family.root, rows: foldedFamilyRows(context, family), flags: family.flags };
450
+ function treeUnit(context: Context, tree: ThreadTree): Unit {
451
+ return { info: tree.root, rows: foldedTreeRows(context, tree), flags: tree.flags };
428
452
  }
429
453
 
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;
454
+ /** Whether a thread in the tree needs attention: it stays drawn when its group is collapsed. */
455
+ export function needsAttention(tree: Pick<ThreadTree, "attentionFlags">): boolean {
456
+ return tree.attentionFlags.size > 0;
438
457
  }
439
458
 
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;
459
+ /** The need-you filter's N: thread trees that need attention, in every group. */
460
+ export function countNeedYou(forest: Pick<Forest, "trees">): number {
461
+ return forest.trees.filter(needsAttention).length;
480
462
  }
481
463
 
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
- };
464
+ /** The filter narrows the list only while something needs you: at 0 the full list is back. */
465
+ export function needYouActive(on: boolean, count: number): boolean {
466
+ return on && count > 0;
521
467
  }
522
468
 
523
469
  /**
524
- * One group. `families` is every family bucketed in it, for its counters and
525
- * `rootIds`; `drawn` leaves out those in Needs attention.
470
+ * One group. `trees` is every tree bucketed in it. Collapsed, it draws only
471
+ * its trees that need attention; opening a thread in it opens it, as an
472
+ * auto-reveal did.
526
473
  */
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);
474
+ function buildGroup(context: Context, descriptor: GroupDescriptor, trees: ThreadTree[], hidden: boolean): GroupView {
475
+ if (context.needYouOnly) return needYouGroup(context, descriptor, trees, hidden);
476
+ const counters = countTrees(trees);
535
477
  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;
478
+ const activeId = context.activeThreadId;
479
+ const opened =
480
+ activeId !== null && context.targets.has(activeId) && trees.some((tree) => tree.containsActive);
481
+ const collapsed = userCollapsed && !opened;
542
482
  const isPinned = descriptor.id === PINNED_GROUP_ID;
543
483
 
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
- }
484
+ const sorted = sortTrees(context, descriptor, trees);
555
485
 
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
- };
486
+ let rows: Row[];
487
+ if (collapsed) {
488
+ rows = clusterEnvironments(context, sorted.filter(needsAttention).map((tree) => treeUnit(context, tree)), 0);
489
+ } else {
490
+ // Settled trees go behind the fold at the group's end. The open one, if
491
+ // settled, is drawn just above the divider, so opening it moves nothing else.
492
+ const settled = sorted.filter((tree) => isSettledTree(tree, context.settle));
493
+ const settledSet = new Set(settled);
494
+ const live = sorted.filter((tree) => !settledSet.has(tree));
495
+ const open = settled.find((tree) => tree.containsActive) ?? null;
496
+ rows = clusterEnvironments(context, live.map((tree) => treeUnit(context, tree)), 0);
497
+ if (settled.length > 0) {
498
+ if (open !== null) rows.push(...treeUnit(context, open).rows);
499
+ const expanded = context.openSettledFolds.has(descriptor.id);
500
+ rows.push({ type: "settled", key: `settled:${descriptor.id}`, groupId: descriptor.id, count: settled.length, expanded });
501
+ if (expanded) {
502
+ const folded = settled.filter((tree) => tree !== open).map((tree) => treeUnit(context, tree));
503
+ rows.push(...clusterEnvironments(context, folded, 0));
504
+ }
588
505
  }
589
506
  }
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
507
  return {
595
508
  descriptor,
596
509
  counters,
@@ -598,7 +511,32 @@ function buildGroup(
598
511
  collapsed,
599
512
  hidden,
600
513
  rows,
601
- rootIds: families.map((family) => family.root.thread.id),
514
+ rootIds: trees.map((tree) => tree.root.thread.id),
515
+ };
516
+ }
517
+
518
+ /** A group's trees in its order: Pinned's own, else the chosen sort. */
519
+ function sortTrees(context: Context, descriptor: GroupDescriptor, trees: readonly ThreadTree[]): ThreadTree[] {
520
+ if (descriptor.id === PINNED_GROUP_ID) return [...trees].sort((a, b) => comparePinned(a.root.thread, b.root.thread));
521
+ return [...trees].sort((a, b) =>
522
+ context.compare(
523
+ { thread: a.root.thread, treeAttention: a.latestAttentionAt },
524
+ { thread: b.root.thread, treeAttention: b.latestAttentionAt },
525
+ ),
526
+ );
527
+ }
528
+
529
+ /** A group under the need-you filter: its header, and its trees that need attention, whatever its collapse. */
530
+ function needYouGroup(context: Context, descriptor: GroupDescriptor, trees: ThreadTree[], hidden: boolean): GroupView {
531
+ const sorted = sortTrees(context, descriptor, trees);
532
+ return {
533
+ descriptor,
534
+ counters: countTrees(trees),
535
+ userCollapsed: isGroupCollapsed(descriptor, context.prefs),
536
+ collapsed: false,
537
+ hidden,
538
+ rows: clusterEnvironments(context, sorted.filter(needsAttention).map((tree) => treeUnit(context, tree)), 0),
539
+ rootIds: trees.map((tree) => tree.root.thread.id),
602
540
  };
603
541
  }
604
542
 
@@ -618,40 +556,28 @@ export function buildListView(inputs: ViewInputs): ListView {
618
556
  }
619
557
  const context: Context = {
620
558
  ...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
- }),
559
+ compare: makeComparator({ field: prefs.chronologicalSort, direction: prefs.sortDirection }),
627
560
  expandedChildren: new Set(prefs.expandedChildren),
628
561
  expandedOlder: new Set(prefs.expandedOlder),
562
+ openSettledFolds: new Set(prefs.openSettledFolds),
629
563
  collapsedEnvironments: new Set(prefs.collapsedEnvironments),
630
564
  revealIds,
631
565
  revealPath,
632
566
  activePath,
633
567
  projectNames: new Map(inputs.projects.map((project) => [project.id, project.name])),
634
568
  sectionNames: new Map(inputs.sections.map((section) => [section.id, section.name])),
635
- drawnInAttention: false,
636
- attended: false,
637
569
  };
638
570
 
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, {
571
+ const byGroup = new Map<string, ThreadTree[]>();
572
+ for (const tree of forest.trees) {
573
+ const id = groupIdForRoot(tree.root.thread, {
645
574
  mode: prefs.organizationMode,
646
575
  projects: inputs.projects,
647
576
  });
648
577
  const list = byGroup.get(id) ?? [];
649
- list.push(family);
578
+ list.push(tree);
650
579
  byGroup.set(id, list);
651
- homeOf.set(family, id);
652
- if (inAttention(family, context.heldRootId, openRootId)) section.push(family);
653
580
  }
654
- const inSection = new Set(section);
655
581
 
656
582
  const alphabetical = effectiveSortField(prefs.chronologicalSort) === "alpha";
657
583
  const entities = entityGroups(
@@ -674,16 +600,20 @@ export function buildListView(inputs: ViewInputs): ListView {
674
600
  for (const id of order) {
675
601
  const descriptor = descriptors.get(id);
676
602
  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) {
603
+ const trees = byGroup.get(id) ?? [];
604
+ // Pinned and the loose bucket appear only when they hold threads, except
605
+ // the loose bucket in custom-section mode, as in bb.
606
+ if (trees.length === 0) {
682
607
  if (id === PINNED_GROUP_ID) continue;
683
608
  if (id === THREADS_GROUP_ID && prefs.organizationMode !== "chronological") continue;
684
609
  }
685
610
  const hidden = id !== PINNED_GROUP_ID && hiddenIds.has(id);
686
- const view = buildGroup(context, descriptor, families, drawn, hidden);
611
+ if (context.needYouOnly) {
612
+ // Under the filter, a hidden group's trees come out of More, each under its own header.
613
+ if (trees.some(needsAttention)) groups.push(buildGroup(context, descriptor, trees, hidden));
614
+ continue;
615
+ }
616
+ const view = buildGroup(context, descriptor, trees, hidden);
687
617
  if (hidden) {
688
618
  more.push(view);
689
619
  moreCounters = addCounters(moreCounters, view.counters);
@@ -692,11 +622,7 @@ export function buildListView(inputs: ViewInputs): ListView {
692
622
  }
693
623
  }
694
624
 
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 };
625
+ return { groups, more, moreCounters, order, needYouCount: countNeedYou(forest) };
700
626
  }
701
627
 
702
628
  /** Every thread row in visual order, for keyboard and windowing. */