@gperezmz/bb-plugin-thread-glance 0.1.3 → 0.2.1

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 (38) hide show
  1. package/README.md +2 -2
  2. package/dist/app.css +1 -1
  3. package/dist/app.js +32 -32
  4. package/dist/app.meta.json +1 -1
  5. package/dist/server.js +8 -8
  6. package/dist/server.js.map +3 -3
  7. package/dist/server.meta.json +1 -1
  8. package/features/thread-list/components/FoldRows.tsx +14 -13
  9. package/features/thread-list/components/GroupSection.tsx +118 -69
  10. package/features/thread-list/components/Rails.tsx +2 -3
  11. package/features/thread-list/components/ThreadList.tsx +21 -16
  12. package/features/thread-list/components/ThreadRowView.tsx +12 -9
  13. package/features/thread-list/components/Toolbar.tsx +129 -187
  14. package/features/thread-list/components/controller.ts +1 -2
  15. package/features/thread-list/components/glyphs.tsx +1 -1
  16. package/features/thread-list/data/useClientPreferences.ts +1 -1
  17. package/features/thread-list/data/useNeedsYouHold.ts +19 -0
  18. package/features/thread-list/model/counters.ts +13 -19
  19. package/features/thread-list/model/details.ts +2 -2
  20. package/features/thread-list/model/expansion.ts +10 -10
  21. package/features/thread-list/model/families.ts +50 -48
  22. package/features/thread-list/model/labels.ts +2 -1
  23. package/features/thread-list/model/layout.ts +6 -11
  24. package/features/thread-list/model/needs-you.ts +133 -0
  25. package/features/thread-list/model/notes.ts +1 -1
  26. package/features/thread-list/model/settings.ts +54 -0
  27. package/features/thread-list/model/share.ts +7 -1
  28. package/features/thread-list/model/sort.ts +14 -6
  29. package/features/thread-list/model/state.ts +11 -11
  30. package/features/thread-list/model/time.ts +1 -1
  31. package/features/thread-list/model/toggles.ts +2 -7
  32. package/features/thread-list/model/view.ts +139 -143
  33. package/features/thread-list/testing/fixtures.ts +19 -10
  34. package/package.json +1 -1
  35. package/server/cli.ts +2 -2
  36. package/shared/preferences.ts +10 -23
  37. package/skills/thread-glance/SKILL.md +9 -10
  38. package/features/thread-list/model/attention.ts +0 -89
@@ -23,12 +23,7 @@ function under(forest: Forest, parentId: string): (id: string) => boolean {
23
23
  export function toggleChip(row: ThreadRow, prefs: Preferences, forest: Forest): ToggleOutcome {
24
24
  const id = row.info.thread.id;
25
25
  const expanded = row.chip?.expanded ?? false;
26
- if (prefs.nesting === "tree") {
27
- return expanded
28
- ? { patch: { collapsedChildren: [...without(prefs.collapsedChildren, id), id] }, drop: under(forest, id) }
29
- : { patch: { collapsedChildren: without(prefs.collapsedChildren, id) }, drop: null };
30
- }
31
- // Folded: expanding shows every child; collapsing also drops auto-reveals.
26
+ // Expanding shows every child; collapsing also drops auto-reveals.
32
27
  return expanded
33
28
  ? { patch: { expandedChildren: without(prefs.expandedChildren, id) }, drop: under(forest, id) }
34
29
  : { patch: { expandedChildren: [...without(prefs.expandedChildren, id), id] }, drop: null };
@@ -46,7 +41,7 @@ export function toggleOlder(row: OlderRow, prefs: Preferences, group: GroupView
46
41
  if (row.scope === "family") return { patch, drop: under(forest, row.scopeId) };
47
42
  // Folding a group's older roots drops the targets that held it open.
48
43
  const quietRoots = new Set<string>();
49
- for (const family of forest.families) if (family.settled) quietRoots.add(family.root.thread.id);
44
+ for (const family of forest.families) if (family.quietIgnoringOpen) quietRoots.add(family.root.thread.id);
50
45
  const inGroup = new Set(group?.rootIds ?? []);
51
46
  return {
52
47
  patch,
@@ -20,21 +20,20 @@ import {
20
20
  } from "./groups";
21
21
  import { addCounters, countFamilies, EMPTY_COUNTERS, type Counters } from "./counters";
22
22
  import { comparePinned, effectiveSortField, makeComparator, type SortKey } from "./sort";
23
+ import { inNeedsYou } from "./needs-you";
23
24
  import { mostUrgent, type Flag } from "./state";
24
25
  import type { Targets } from "./expansion";
25
26
  import { railsFor, type Rail } from "./layout";
26
27
 
27
- /** How many quiet roots stay in a group. */
28
+ /** How many of a group's newest quiet roots stay out of its older fold. */
28
29
  export const KEEP_QUIET = 5;
29
30
  /** How many quiet children stay in an expanded family. */
30
31
  export const KEEP_QUIET_CHILDREN = 3;
31
32
 
32
- export type ListFilter = "all" | "attention";
33
-
34
33
  export interface Chip {
35
34
  count: number;
36
35
  flag: Flag | null;
37
- /** Every child shows: folded, the user expanded it; tree, it's open. */
36
+ /** The user opened the chip, so every child shows. */
38
37
  expanded: boolean;
39
38
  /** Harnesses among the visible children that differ from the parent's. */
40
39
  providerIds: string[];
@@ -44,9 +43,9 @@ export interface ThreadRow {
44
43
  type: "thread";
45
44
  key: string;
46
45
  info: ThreadInfo;
47
- /** Visual depth: 0 for roots; 1 for every child in folded nesting. */
46
+ /** Visual depth: 0 for roots, one more per level below. */
48
47
  depth: number;
49
- /** Grandchild or deeper in folded nesting: draws `↳`. */
48
+ /** Grandchild or deeper: draws `↳`. */
50
49
  nested: boolean;
51
50
  /** Title of the thread this one attaches to, for tooltips and labels. */
52
51
  parentTitle: string | null;
@@ -57,8 +56,8 @@ export interface ThreadRow {
57
56
  hiddenBadge: boolean;
58
57
  /** "In project X" when the thread is outside its family's group. */
59
58
  crossGroupLabel: string | null;
60
- /** Tree nesting: this parent row sticks at this level (0-3), or null. */
61
- stickyLevel: number | null;
59
+ /** A root in Needs you: its home group's name, drawn where the age goes. */
60
+ homeGroupLabel: string | null;
62
61
  projectId: string;
63
62
  }
64
63
 
@@ -90,19 +89,18 @@ export interface EnvironmentRow {
90
89
  rails: (Rail | null)[];
91
90
  }
92
91
 
93
- /** Under an opened chip that Needs attention leaves with nothing to show. */
94
- export interface EmptyRow {
95
- type: "empty";
92
+ /** Under a family in Needs you: how many of its child threads it leaves out ("+N more"). Not a control. */
93
+ export interface LeftOutRow {
94
+ type: "left-out";
96
95
  key: string;
97
- /** The parent whose chip is open. */
96
+ /** The family's root. */
98
97
  scopeId: string;
98
+ count: number;
99
99
  depth: number;
100
100
  rails: (Rail | null)[];
101
101
  }
102
102
 
103
- export const EMPTY_LEVEL_TEXT = "No child threads need attention";
104
-
105
- export type Row = ThreadRow | OlderRow | EnvironmentRow | EmptyRow;
103
+ export type Row = ThreadRow | OlderRow | EnvironmentRow | LeftOutRow;
106
104
 
107
105
  export interface GroupView {
108
106
  descriptor: GroupDescriptor;
@@ -113,11 +111,25 @@ export interface GroupView {
113
111
  collapsed: boolean;
114
112
  hidden: boolean;
115
113
  rows: Row[];
116
- /** Every family root bucketed in the group, before filtering and folding. */
114
+ /** Every family root bucketed in the group, those in Needs you and behind folds included. */
117
115
  rootIds: string[];
118
116
  }
119
117
 
118
+ /** The id Needs you's rows are drawn under; no group has it. */
119
+ export const NEEDS_YOU_GROUP_ID = "needs-you";
120
+
121
+ /** The Needs you section: every family with a thread that needs you, or held there while one of its threads is open. */
122
+ export interface NeedsYouView {
123
+ /** How many families the section holds, for its header. */
124
+ familyCount: number;
125
+ rows: Row[];
126
+ /** The home group of each thread drawn here, by thread id: a drop on its row acts there. */
127
+ homeGroupIds: Record<string, string>;
128
+ }
129
+
120
130
  export interface ListView {
131
+ /** Null when no family is in the section. */
132
+ needsYou: NeedsYouView | null;
121
133
  groups: GroupView[];
122
134
  /** Hidden groups, shown in the More popover. */
123
135
  more: GroupView[];
@@ -126,8 +138,6 @@ export interface ListView {
126
138
  order: string[];
127
139
  /** Threads span more than one host (the row's second line). */
128
140
  multiHost: boolean;
129
- /** Threads Needs attention would show for their own flags (the filter's badge). */
130
- attentionCount: number;
131
141
  }
132
142
 
133
143
  export interface ViewInputs {
@@ -136,15 +146,15 @@ export interface ViewInputs {
136
146
  projects: readonly PluginSidebarProject[];
137
147
  sections: readonly PluginSidebarSection[];
138
148
  prefs: Preferences;
139
- filter: ListFilter;
140
149
  activeThreadId: string | null;
150
+ /** The root of the family Needs you keeps while one of its threads is open. */
151
+ heldRootId: string | null;
141
152
  targets: Targets;
142
153
  }
143
154
 
144
155
  interface Context extends ViewInputs {
145
156
  compare: (a: SortKey, b: SortKey) => number;
146
157
  expandedChildren: ReadonlySet<string>;
147
- collapsedChildren: ReadonlySet<string>;
148
158
  expandedOlder: ReadonlySet<string>;
149
159
  collapsedEnvironments: ReadonlySet<string>;
150
160
  /** Parent ids on the path to a reveal target. */
@@ -154,7 +164,6 @@ interface Context extends ViewInputs {
154
164
  activePath: ReadonlySet<string>;
155
165
  projectNames: ReadonlyMap<string, string>;
156
166
  sectionNames: ReadonlyMap<string, string>;
157
- subtreeAttention: Map<string, number>;
158
167
  }
159
168
 
160
169
  function titleOf(context: Context, id: string | null): string | null {
@@ -185,7 +194,7 @@ function threadRow(
185
194
  context: Context,
186
195
  info: ThreadInfo,
187
196
  root: ThreadInfo,
188
- options: { depth: number; nested: boolean; chip: Chip | null; stickyLevel?: number | null },
197
+ options: { depth: number; nested: boolean; chip: Chip | null },
189
198
  ): ThreadRow {
190
199
  return {
191
200
  type: "thread",
@@ -198,20 +207,16 @@ function threadRow(
198
207
  rails: [],
199
208
  hiddenBadge: info.thread.isHidden,
200
209
  crossGroupLabel: crossGroupLabel(context, info, root),
201
- stickyLevel: options.stickyLevel ?? null,
210
+ homeGroupLabel: null,
202
211
  projectId: info.thread.projectId,
203
212
  };
204
213
  }
205
214
 
206
- function matchesAttention(info: ThreadInfo): boolean {
207
- return info.attention.size > 0;
208
- }
209
-
210
215
  /** The children of `parent` that get a row: hidden ones only when they need you or failed. */
211
216
  function eligibleChildren(context: Context, parentId: string): ThreadInfo[] {
212
217
  return (context.forest.children.get(parentId) ?? [])
213
218
  .map((id) => context.forest.infos.get(id)!)
214
- .filter((info) => !info.thread.isHidden || info.attention.size > 0);
219
+ .filter((info) => !info.thread.isHidden || info.needsYou.size > 0);
215
220
  }
216
221
 
217
222
  function subtreeOf(context: Context, id: string): Subtree {
@@ -219,7 +224,7 @@ function subtreeOf(context: Context, id: string): Subtree {
219
224
  }
220
225
 
221
226
  /**
222
- * The children of one parent that show in folded nesting. Each level
227
+ * The children of one parent that show under its chip. Each level
223
228
  * folds on its own: its direct children only, and a child's own children wait
224
229
  * for that child's chip. A child never shows without its parent, because the
225
230
  * parent's level is the only place it is drawn.
@@ -231,13 +236,6 @@ function foldedChildren(
231
236
  ): { shown: ThreadInfo[]; older: OlderRow | null } {
232
237
  const parentId = parent.thread.id;
233
238
  const kids = eligibleChildren(context, parentId);
234
- const holds = (kid: ThreadInfo, test: (info: ThreadInfo) => boolean) =>
235
- test(kid) || subtreeOf(context, kid.thread.id).descendants.some(test);
236
-
237
- if (context.filter === "attention") {
238
- const shown = kids.filter((kid) => holds(kid, (info) => matchesAttention(info) || info.isActive));
239
- return { shown, older: null };
240
- }
241
239
 
242
240
  const reveals = (kid: ThreadInfo) => context.revealIds.has(kid.thread.id) || context.revealPath.has(kid.thread.id);
243
241
  const olderRow = (scope: "family" | "reveal", count: number, expanded: boolean): OlderRow | null =>
@@ -261,10 +259,10 @@ function foldedChildren(
261
259
  return { shown, older: shown.length > 0 ? olderRow("reveal", left, false) : null };
262
260
  }
263
261
 
264
- // The stable set reads `settled` for the child and everything under it, and
262
+ // The stable set reads `quietIgnoringOpen` for the child and everything under it, and
265
263
  // ignores which thread is open and the reveal targets: they join afterwards
266
264
  // and take no other row's place.
267
- const quietTree = (kid: ThreadInfo) => kid.settled && subtreeOf(context, kid.thread.id).settled;
265
+ const quietTree = (kid: ThreadInfo) => kid.quietIgnoringOpen && subtreeOf(context, kid.thread.id).quietIgnoringOpen;
268
266
  const stable = new Set<string>();
269
267
  for (const kid of kids) if (kid.thread.isHidden || !quietTree(kid)) stable.add(kid.thread.id);
270
268
  const recentQuiet = kids
@@ -281,18 +279,6 @@ function foldedChildren(
281
279
  return { shown, older: olderRow("family", folded, olderExpanded) };
282
280
  }
283
281
 
284
- function subtreeAttention(context: Context, id: string): number {
285
- const cached = context.subtreeAttention.get(id);
286
- if (cached !== undefined) return cached;
287
- context.subtreeAttention.set(id, context.forest.infos.get(id)?.thread.latestAttentionAt ?? 0);
288
- let max = context.forest.infos.get(id)?.thread.latestAttentionAt ?? 0;
289
- for (const child of context.forest.children.get(id) ?? []) {
290
- max = Math.max(max, subtreeAttention(context, child));
291
- }
292
- context.subtreeAttention.set(id, max);
293
- return max;
294
- }
295
-
296
282
  function environmentLabel(thread: PluginSidebarThread): string {
297
283
  const environment = thread.environment;
298
284
  if (environment === null) return "Environment";
@@ -371,7 +357,7 @@ function childProviders(parent: ThreadInfo, descendants: readonly ThreadInfo[]):
371
357
  function chipOf(context: Context, info: ThreadInfo, expanded: boolean): Chip | null {
372
358
  const subtree = subtreeOf(context, info.thread.id);
373
359
  const hasChildren =
374
- subtree.visibleCount > 0 || subtree.descendants.some((descendant) => descendant.thread.isHidden && descendant.attention.size > 0);
360
+ subtree.visibleCount > 0 || subtree.descendants.some((descendant) => descendant.thread.isHidden && descendant.needsYou.size > 0);
375
361
  if (!hasChildren) return null;
376
362
  return {
377
363
  count: subtree.visibleCount,
@@ -383,12 +369,12 @@ function chipOf(context: Context, info: ThreadInfo, expanded: boolean): Chip | n
383
369
 
384
370
  /** What a thread and everything under it carry, for an environment folder's glyph. */
385
371
  function rollupFlags(context: Context, info: ThreadInfo): Set<Flag> {
386
- const flags = new Set<Flag>([...info.attention, ...subtreeOf(context, info.thread.id).flags]);
372
+ const flags = new Set<Flag>([...info.needsYou, ...subtreeOf(context, info.thread.id).flags]);
387
373
  if (info.flags.has("working")) flags.add("working");
388
374
  return flags;
389
375
  }
390
376
 
391
- /** The rows under `parent` in folded nesting: each shown child, then its own level. */
377
+ /** The rows under `parent`: each shown child, then its own level. */
392
378
  function foldedLevel(context: Context, family: Family, parent: ThreadInfo, depth: number): Row[] {
393
379
  const { shown, older } = foldedChildren(context, parent, depth);
394
380
  const units: Unit[] = shown.map((info) => ({
@@ -407,10 +393,6 @@ function foldedLevel(context: Context, family: Family, parent: ThreadInfo, depth
407
393
  }));
408
394
  const rows = clusterEnvironments(context, units, depth);
409
395
  if (older !== null) rows.push(older);
410
- // An opened chip that the filter empties says so, instead of showing an open chevron over nothing.
411
- if (context.filter === "attention" && shown.length === 0 && context.expandedChildren.has(parent.thread.id)) {
412
- rows.push({ type: "empty", key: `empty:${parent.thread.id}`, scopeId: parent.thread.id, depth, rails: [] });
413
- }
414
396
  return rows;
415
397
  }
416
398
 
@@ -420,72 +402,98 @@ function foldedFamilyRows(context: Context, family: Family): Row[] {
420
402
  return [threadRow(context, root, root, { depth: 0, nested: false, chip }), ...foldedLevel(context, family, root, 1)];
421
403
  }
422
404
 
423
- /** Tree nesting: bb's tree, with chips on collapsed parents. */
424
- function treeRows(context: Context, family: Family, info: ThreadInfo, depth: number): Unit {
425
- const id = info.thread.id;
426
- const root = family.root;
427
- const childIds = (context.forest.children.get(id) ?? []).filter((childId) => {
428
- const child = context.forest.infos.get(childId)!;
429
- return !child.thread.isHidden || child.attention.size > 0;
430
- });
431
- const subtree = subtreeOf(context, id);
432
- let children = childIds.map((childId) => context.forest.infos.get(childId)!);
433
- if (context.filter === "attention") {
434
- children = children.filter((child) => {
435
- const test = (candidate: ThreadInfo) => matchesAttention(candidate) || candidate.isActive;
436
- return test(child) || subtreeOf(context, child.thread.id).descendants.some(test);
437
- });
438
- }
439
- const open =
440
- context.filter === "attention" ||
441
- !context.collapsedChildren.has(id) ||
442
- context.revealPath.has(id);
443
- const chip: Chip | null =
444
- childIds.length > 0
445
- ? // Drawn closed when the filter leaves nothing under it.
446
- { count: subtree.visibleCount, flag: mostUrgent(subtree.flags), expanded: open && children.length > 0, providerIds: childProviders(info, subtree.descendants) }
447
- : null;
448
- const ownRow = threadRow(context, info, root, {
449
- depth,
450
- nested: false,
451
- chip,
452
- stickyLevel: open && children.length > 0 && depth < 4 ? depth : null,
453
- });
454
- const ownFlags = rollupFlags(context, info);
455
- if (!open || children.length === 0) return { info, rows: [ownRow], flags: ownFlags };
456
- const keyed = children.map((child) => ({
457
- child,
458
- key: { thread: child.thread, familyAttention: subtreeAttention(context, child.thread.id) },
459
- }));
460
- keyed.sort((a, b) => context.compare(a.key, b.key));
461
- const units = keyed.map(({ child }) => treeRows(context, family, child, depth + 1));
462
- return { info, rows: [ownRow, ...clusterEnvironments(context, units, depth + 1)], flags: ownFlags };
463
- }
464
-
465
405
  function familyUnit(context: Context, family: Family): Unit {
466
- if (context.prefs.nesting === "tree") return treeRows(context, family, family.root, 0);
467
406
  return { info: family.root, rows: foldedFamilyRows(context, family), flags: family.flags };
468
407
  }
469
408
 
470
- /** needs-you, then failed, then offline, then unread, then the rest. */
409
+ /** Waiting on you, then failed, then offline, then unread, then the rest. */
471
410
  export function urgency(family: Family): number {
472
- const flags = family.attentionFlags;
473
- if (flags.has("needs-you")) return 0;
411
+ const flags = family.needsYouFlags;
412
+ if (flags.has("waits-on-you")) return 0;
474
413
  if (flags.has("unread-failed") || flags.has("queue-failed")) return 1;
475
414
  if (flags.has("offline")) return 2;
476
415
  if (flags.has("unread")) return 3;
477
416
  return 4;
478
417
  }
479
418
 
419
+ /**
420
+ * One family's rows in Needs you: the path from the root down to each thread
421
+ * that needs you or is open, then one "+N more" line for the child threads
422
+ * left out. No row draws a chip, since the section never opens one.
423
+ */
424
+ function needsYouFamilyRows(context: Context, family: Family, homeGroupLabel: string): Row[] {
425
+ const root = family.root;
426
+ const onPath = new Set<string>([root.thread.id]);
427
+ for (const info of family.descendants) {
428
+ const needsYou = !info.thread.isArchived && info.needsYou.size > 0;
429
+ if (!needsYou && !info.isActive) continue;
430
+ onPath.add(info.thread.id);
431
+ for (const ancestor of ancestorsOf(info.thread.id, context.forest.infos, root.thread.id)) onPath.add(ancestor);
432
+ }
433
+ const rows: Row[] = [{ ...threadRow(context, root, root, { depth: 0, nested: false, chip: null }), homeGroupLabel }];
434
+ const walk = (parentId: string, depth: number) => {
435
+ for (const id of context.forest.children.get(parentId) ?? []) {
436
+ if (!onPath.has(id)) continue;
437
+ rows.push(threadRow(context, context.forest.infos.get(id)!, root, { depth, nested: depth > 1, chip: null }));
438
+ walk(id, depth + 1);
439
+ }
440
+ };
441
+ walk(root.thread.id, 1);
442
+ const left = family.descendants.filter((info) => !info.thread.isHidden && !onPath.has(info.thread.id)).length;
443
+ if (left > 0) rows.push({ type: "left-out", key: `left-out:${root.thread.id}`, scopeId: root.thread.id, count: left, depth: 1, rails: [] });
444
+ return rows;
445
+ }
446
+
447
+ function buildNeedsYou(
448
+ context: Context,
449
+ families: readonly Family[],
450
+ homeGroupOf: (family: Family) => GroupDescriptor,
451
+ ): NeedsYouView | null {
452
+ if (families.length === 0) return null;
453
+ // Most urgent first, then the chosen field in its natural direction: the
454
+ // sort direction orders the groups only.
455
+ const compare = makeComparator({
456
+ field: context.prefs.chronologicalSort,
457
+ direction: "default",
458
+ workingFirst: context.prefs.workingFirst,
459
+ });
460
+ const sorted = [...families].sort(
461
+ (a, b) =>
462
+ urgency(a) - urgency(b) ||
463
+ compare(
464
+ { thread: a.root.thread, familyAttention: a.latestAttentionAt },
465
+ { thread: b.root.thread, familyAttention: b.latestAttentionAt },
466
+ ),
467
+ );
468
+ const homeGroupIds: Record<string, string> = {};
469
+ const rows = sorted.flatMap((family) => {
470
+ const home = homeGroupOf(family);
471
+ const familyRows = needsYouFamilyRows(context, family, home.label);
472
+ for (const row of familyRows) if (row.type === "thread") homeGroupIds[row.info.thread.id] = home.id;
473
+ return familyRows;
474
+ });
475
+ const rails = railsFor(rows.map((row) => row.depth));
476
+ return {
477
+ familyCount: sorted.length,
478
+ rows: rows.map((row, index) => ({ ...row, rails: rails[index]! })),
479
+ homeGroupIds,
480
+ };
481
+ }
482
+
483
+ /**
484
+ * One group. `families` is every family bucketed in it, for its counters and
485
+ * `rootIds`; `drawn` leaves out those in Needs you.
486
+ */
480
487
  function buildGroup(
481
488
  context: Context,
482
489
  descriptor: GroupDescriptor,
483
490
  families: Family[],
491
+ drawn: Family[],
484
492
  hidden: boolean,
485
- ): GroupView | null {
493
+ ): GroupView {
486
494
  const counters = countFamilies(families);
487
495
  const userCollapsed = isGroupCollapsed(descriptor, context.prefs);
488
- const hasTarget = families.some(
496
+ const hasTarget = drawn.some(
489
497
  (family) =>
490
498
  context.targets.has(family.root.thread.id) ||
491
499
  family.descendants.some((info) => context.targets.has(info.thread.id)),
@@ -493,42 +501,30 @@ function buildGroup(
493
501
  const collapsed = userCollapsed && !hasTarget;
494
502
  const isPinned = descriptor.id === PINNED_GROUP_ID;
495
503
 
496
- let kept = families;
497
- if (context.filter === "attention") {
498
- kept = families.filter((family) => family.attentionFlags.size > 0 || family.containsActive);
499
- if (kept.length === 0) return null;
500
- }
501
-
502
- const sorted = [...kept];
503
- if (context.filter === "attention") {
504
- // Needs attention reads most urgent first.
505
- sorted.sort(
506
- (a, b) =>
507
- urgency(a) - urgency(b) ||
508
- context.compare(
509
- { thread: a.root.thread, familyAttention: a.attention },
510
- { thread: b.root.thread, familyAttention: b.attention },
511
- ),
512
- );
513
- } else if (isPinned) {
504
+ const sorted = [...drawn];
505
+ if (isPinned) {
514
506
  sorted.sort((a, b) => comparePinned(a.root.thread, b.root.thread));
515
507
  } else {
516
508
  sorted.sort((a, b) =>
517
509
  context.compare(
518
- { thread: a.root.thread, familyAttention: a.attention },
519
- { thread: b.root.thread, familyAttention: b.attention },
510
+ { thread: a.root.thread, familyAttention: a.latestAttentionAt },
511
+ { thread: b.root.thread, familyAttention: b.latestAttentionAt },
520
512
  ),
521
513
  );
522
514
  }
523
515
 
524
516
  let visible = sorted;
525
517
  let older: OlderRow | null = null;
526
- const foldable = !isPinned && context.filter === "all" && context.prefs.foldOlder;
518
+ const foldable = !isPinned && context.prefs.foldOlder;
527
519
  if (foldable) {
528
- // The fold reads `settled`, as if no thread were open. The open family
520
+ // The fold reads `quietIgnoringOpen`, as if no thread were open. The open family
529
521
  // joins afterwards when it sits behind the fold, and takes no other row's place.
530
- const quietActive = sorted.filter((family) => family.settled && !family.root.thread.isArchived);
531
- const keepQuiet = new Set(quietActive.slice(0, KEEP_QUIET));
522
+ const quietActive = sorted.filter((family) => family.quietIgnoringOpen && !family.root.thread.isArchived);
523
+ // The newest quiet roots stay whatever the order: by creation under
524
+ // Created, by latest activity otherwise.
525
+ const byCreation = effectiveSortField(context.prefs.chronologicalSort) === "created";
526
+ const age = (family: Family) => (byCreation ? family.root.thread.createdAt : family.latestAttentionAt);
527
+ const keepQuiet = new Set([...quietActive].sort((a, b) => age(b) - age(a)).slice(0, KEEP_QUIET));
532
528
  const foldedFamilies = quietActive.filter((family) => !keepQuiet.has(family) && !family.containsActive);
533
529
  if (foldedFamilies.length > 0) {
534
530
  const opened =
@@ -591,7 +587,6 @@ export function buildListView(inputs: ViewInputs): ListView {
591
587
  workingFirst: prefs.workingFirst,
592
588
  }),
593
589
  expandedChildren: new Set(prefs.expandedChildren),
594
- collapsedChildren: new Set(prefs.collapsedChildren),
595
590
  expandedOlder: new Set(prefs.expandedOlder),
596
591
  collapsedEnvironments: new Set(prefs.collapsedEnvironments),
597
592
  revealIds,
@@ -599,10 +594,12 @@ export function buildListView(inputs: ViewInputs): ListView {
599
594
  activePath,
600
595
  projectNames: new Map(inputs.projects.map((project) => [project.id, project.name])),
601
596
  sectionNames: new Map(inputs.sections.map((section) => [section.id, section.name])),
602
- subtreeAttention: new Map(),
603
597
  };
604
598
 
605
599
  const byGroup = new Map<string, Family[]>();
600
+ const homeOf = new Map<Family, string>();
601
+ const section: Family[] = [];
602
+ const openRootId = inputs.activeThreadId === null ? null : (forest.familyOf.get(inputs.activeThreadId)?.root.thread.id ?? null);
606
603
  for (const family of forest.families) {
607
604
  const id = groupIdForRoot(family.root.thread, {
608
605
  mode: prefs.organizationMode,
@@ -611,7 +608,10 @@ export function buildListView(inputs: ViewInputs): ListView {
611
608
  const list = byGroup.get(id) ?? [];
612
609
  list.push(family);
613
610
  byGroup.set(id, list);
611
+ homeOf.set(family, id);
612
+ if (inNeedsYou(family, inputs.heldRootId, openRootId)) section.push(family);
614
613
  }
614
+ const inSection = new Set(section);
615
615
 
616
616
  const alphabetical = effectiveSortField(prefs.chronologicalSort) === "alpha";
617
617
  const entities = entityGroups(
@@ -635,15 +635,15 @@ export function buildListView(inputs: ViewInputs): ListView {
635
635
  const descriptor = descriptors.get(id);
636
636
  if (descriptor === undefined) continue;
637
637
  const families = byGroup.get(id) ?? [];
638
- // Pinned and the loose bucket appear only when they hold threads,
639
- // except the loose bucket in custom-section mode, as in bb.
640
- if (families.length === 0) {
638
+ const drawn = families.filter((family) => !inSection.has(family));
639
+ // Pinned and the loose bucket appear only when they hold threads outside
640
+ // Needs you, except the loose bucket in custom-section mode, as in bb.
641
+ if (drawn.length === 0) {
641
642
  if (id === PINNED_GROUP_ID) continue;
642
643
  if (id === THREADS_GROUP_ID && prefs.organizationMode !== "chronological") continue;
643
644
  }
644
645
  const hidden = id !== PINNED_GROUP_ID && hiddenIds.has(id);
645
- const view = buildGroup(context, descriptor, families, hidden);
646
- if (view === null) continue;
646
+ const view = buildGroup(context, descriptor, families, drawn, hidden);
647
647
  if (hidden) {
648
648
  more.push(view);
649
649
  moreCounters = addCounters(moreCounters, view.counters);
@@ -654,13 +654,9 @@ export function buildListView(inputs: ViewInputs): ListView {
654
654
 
655
655
  const hosts = new Set<string>();
656
656
  for (const thread of inputs.threads) if (thread.host !== null) hosts.add(thread.host.id);
657
- let attentionCount = 0;
658
- for (const family of forest.families) {
659
- for (const info of [family.root, ...family.descendants]) {
660
- if (!info.thread.isArchived && matchesAttention(info)) attentionCount += 1;
661
- }
662
- }
663
- return { groups, more, moreCounters, order, multiHost: hosts.size > 1, attentionCount };
657
+ // Every root's group id has a descriptor: entity groups include unknown sections and every host.
658
+ const needsYou = buildNeedsYou(context, section, (family) => descriptors.get(homeOf.get(family)!)!);
659
+ return { needsYou, groups, more, moreCounters, order, multiHost: hosts.size > 1 };
664
660
  }
665
661
 
666
662
  /** Every thread row in visual order, for keyboard and windowing. */
@@ -6,7 +6,7 @@ import type {
6
6
  } from "@get-bb/plugin-sdk/app";
7
7
  import { defaultPreferences, type Preferences } from "@/shared/preferences";
8
8
  import { buildForest, type Forest } from "../model/families";
9
- import { buildListView, type ListFilter, type ListView } from "../model/view";
9
+ import { buildListView, type ListView, type Row } from "../model/view";
10
10
  import type { Targets } from "../model/expansion";
11
11
 
12
12
  export const T0 = 1_780_000_000_000;
@@ -107,8 +107,8 @@ export interface Scenario {
107
107
  projects?: PluginSidebarProject[];
108
108
  sections?: PluginSidebarSection[];
109
109
  prefs?: Partial<Preferences>;
110
- filter?: ListFilter;
111
110
  activeThreadId?: string | null;
111
+ heldRootId?: string | null;
112
112
  targets?: Targets;
113
113
  finishedAt?: Record<string, number>;
114
114
  seenAt?: Record<string, number>;
@@ -140,23 +140,32 @@ export function viewOf(scenario: Scenario): ListView {
140
140
  projects: scenario.projects ?? PROJECTS,
141
141
  sections: scenario.sections ?? [],
142
142
  prefs: { ...defaultPreferences(), ...scenario.prefs },
143
- filter: scenario.filter ?? "all",
144
143
  activeThreadId: scenario.activeThreadId ?? null,
144
+ heldRootId: scenario.heldRootId ?? null,
145
145
  targets: scenario.targets ?? new Map(),
146
146
  });
147
147
  }
148
148
 
149
- /** Thread ids in the group's rows, in order; older rows as `older:N`. */
150
- export function rowIds(view: ListView, groupId: string): string[] {
151
- const group = [...view.groups, ...view.more].find((candidate) => candidate.descriptor.id === groupId);
152
- if (group === undefined) throw new Error(`no group ${groupId}`);
153
- return group.rows.map((row) =>
149
+ function idsOf(rows: readonly Row[]): string[] {
150
+ return rows.map((row) =>
154
151
  row.type === "thread"
155
152
  ? row.info.thread.id
156
153
  : row.type === "older"
157
154
  ? `older:${row.count}`
158
- : row.type === "empty"
159
- ? `empty:${row.scopeId}`
155
+ : row.type === "left-out"
156
+ ? `+${row.count}`
160
157
  : `env:${row.environmentId}`,
161
158
  );
162
159
  }
160
+
161
+ /** Thread ids in the group's rows, in order; older rows as `older:N`. */
162
+ export function rowIds(view: ListView, groupId: string): string[] {
163
+ const group = [...view.groups, ...view.more].find((candidate) => candidate.descriptor.id === groupId);
164
+ if (group === undefined) throw new Error(`no group ${groupId}`);
165
+ return idsOf(group.rows);
166
+ }
167
+
168
+ /** Thread ids in Needs you, in order; "+N more" lines as `+N`. Empty when the section is absent. */
169
+ export function needsYouIds(view: ListView): string[] {
170
+ return idsOf(view.needsYou?.rows ?? []);
171
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gperezmz/bb-plugin-thread-glance",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bb": ">=0.43",
package/server/cli.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from "../shared/preferences";
10
10
  import { PreferenceValidationError, type PreferenceStore } from "./preference-store";
11
11
 
12
- /** Parses a CLI value as JSON; a bare word such as `tree` is read as a string. */
12
+ /** Parses a CLI value as JSON; a bare word such as `machine` is read as a string. */
13
13
  export function parseCliValue(raw: string): unknown {
14
14
  try {
15
15
  return JSON.parse(raw) as unknown;
@@ -38,7 +38,7 @@ export function createCli(store: PreferenceStore): PluginCliRegistration {
38
38
  name: "thread-glance",
39
39
  summary: "Inspect and change the Thread Glance sidebar's layout preferences",
40
40
  description:
41
- "Organization, sort, group order, hidden and collapsed groups, child nesting and folding for the Thread Glance sidebar. Values are JSON; a bare word is read as a string.",
41
+ "Organization, sort, group order, hidden and collapsed groups, child-thread folding and which child threads Needs you counts, for the Thread Glance sidebar. Values are JSON; a bare word is read as a string.",
42
42
  commands: {
43
43
  "prefs list": cliCommand({
44
44
  summary: "List every preference and its current value",