@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
@@ -271,27 +271,28 @@ export function hiddenThreadFlags(flags: ReadonlySet<Flag>): Set<Flag> {
271
271
  return kept;
272
272
  }
273
273
 
274
+ /** Flags a hidden thread adds to the children chip above it: what asks for you or failed, and offline. */
275
+ const HIDDEN_CHIP_FLAGS: ReadonlySet<Flag> = new Set<Flag>(["waits-on-you", "unread-failed", "queue-failed", "offline"]);
276
+
277
+ /**
278
+ * The flags a thread adds to the children chip of the threads above it. A
279
+ * hidden thread adds only waits-on-you, failed, queued message failed and
280
+ * offline; which trees need attention reads `hiddenThreadFlags` instead,
281
+ * so this leaves it unchanged.
282
+ */
283
+ export function chipFlagsOf(flags: ReadonlySet<Flag>, isHidden: boolean): Set<Flag> {
284
+ return isHidden ? new Set([...flags].filter((flag) => HIDDEN_CHIP_FLAGS.has(flag))) : new Set(flags);
285
+ }
286
+
274
287
  /** The most urgent flag, or null. */
275
288
  export function mostUrgent(flags: ReadonlySet<Flag>): Flag | null {
276
289
  return FLAG_ORDER.find((flag) => flags.has(flag)) ?? null;
277
290
  }
278
291
 
279
- /** The colour a child chip takes: only what asks for you, fails, or works. */
280
- export type ChipTone = "attention" | "destructive" | "working" | "neutral";
281
-
282
- /** The tone of a chip whose most urgent rolled-up flag is `flag`. */
283
- export function chipTone(flag: Flag | null): ChipTone {
284
- switch (flag) {
285
- case "waits-on-you":
286
- return "attention";
287
- case "unread-failed":
288
- case "queue-failed":
289
- return "destructive";
290
- case "working":
291
- return "working";
292
- default:
293
- return "neutral";
294
- }
292
+ /** The tone a children chip takes for a state: its glyph's, and the unread dot's accent for unread. */
293
+ export function chipTone(flag: Flag): Tone {
294
+ const tone = FLAG_GLYPHS[flag].tone;
295
+ return tone === "none" ? "working" : tone;
295
296
  }
296
297
 
297
298
  export const FLAG_GLYPHS: Readonly<Record<Flag, Glyph & { label: string }>> = {
@@ -105,3 +105,10 @@ export function stateSince(
105
105
  if (at === undefined) return null;
106
106
  return formatDuration(now - at);
107
107
  }
108
+
109
+ /**
110
+ * The widest texts `trailingTime` gives up to 99 weeks, one per unit. The
111
+ * trailing slot is as wide as the widest of them, so times line up down the
112
+ * list; an older row widens its own.
113
+ */
114
+ export const TRAILING_SLOT_SIZERS = ["now", "<1m", "59m", "23h", "6d", "99w"] as const;
@@ -1,10 +1,10 @@
1
1
  // What a user's click on a chip, fold or header changes.
2
2
  // Pure: returns the preference patch and which auto-expand targets to drop.
3
3
  import type { Preferences } from "@/shared/preferences";
4
- import { ancestorsOf, type Family, type Forest } from "./families";
4
+ import { ancestorsOf, type ThreadTree, type Forest } from "./trees";
5
5
  import { isGroupCollapsed, toggleGroupCollapse } from "./groups";
6
6
  import { isDoneUnseen } from "./state";
7
- import type { GroupView, OlderRow, ThreadRow } from "./view";
7
+ import type { GroupView, OlderRow, SettledRow, ThreadRow } from "./view";
8
8
 
9
9
  export interface ToggleOutcome {
10
10
  patch: Partial<Preferences>;
@@ -29,36 +29,31 @@ export function toggleChip(row: ThreadRow, prefs: Preferences, forest: Forest):
29
29
  : openChildren(id, prefs);
30
30
  }
31
31
 
32
- /** Opens a parent's children, as its chip does: "+N more" in Needs attention and an auto-reveal's fold. */
32
+ /** Opens a parent's children, as its chip does, from an auto-reveal's fold. */
33
33
  export function openChildren(parentId: string, prefs: Preferences): ToggleOutcome {
34
34
  return { patch: { expandedChildren: [...without(prefs.expandedChildren, parentId), parentId] }, drop: null };
35
35
  }
36
36
 
37
- export function toggleOlder(row: OlderRow, prefs: Preferences, group: GroupView | null, forest: Forest): ToggleOutcome {
37
+ /** Opens or closes an open tree's "N more child threads" fold. */
38
+ export function toggleOlder(row: OlderRow, prefs: Preferences, forest: Forest): ToggleOutcome {
38
39
  if (row.scope === "reveal") return openChildren(row.scopeId, prefs);
39
40
  if (!row.expanded) {
40
41
  return { patch: { expandedOlder: [...without(prefs.expandedOlder, row.scopeId), row.scopeId] }, drop: null };
41
42
  }
42
- const patch = { expandedOlder: without(prefs.expandedOlder, row.scopeId) };
43
- if (row.scope === "family") return { patch, drop: under(forest, row.scopeId) };
44
- // Folding a group's older roots drops the targets that held it open.
45
- const quietRoots = new Set<string>();
46
- for (const family of forest.families) if (family.quietIgnoringOpen) quietRoots.add(family.root.thread.id);
47
- const inGroup = new Set(group?.rootIds ?? []);
48
- return {
49
- patch,
50
- drop: (id) => {
51
- const family = forest.familyOf.get(id);
52
- return family !== undefined && quietRoots.has(family.root.thread.id) && inGroup.has(family.root.thread.id);
53
- },
54
- };
43
+ return { patch: { expandedOlder: without(prefs.expandedOlder, row.scopeId) }, drop: under(forest, row.scopeId) };
44
+ }
45
+
46
+ /** Opens or closes a group's settled fold. */
47
+ export function toggleSettled(row: SettledRow, prefs: Preferences): ToggleOutcome {
48
+ const rest = without(prefs.openSettledFolds, row.groupId);
49
+ return { patch: { openSettledFolds: row.expanded ? rest : [...rest, row.groupId] }, drop: null };
55
50
  }
56
51
 
57
52
  export function toggleGroup(group: GroupView, prefs: Preferences, forest: Forest): ToggleOutcome {
58
53
  const inGroup = new Set(group.rootIds);
59
54
  const dropGroup = (id: string) => {
60
- const family = forest.familyOf.get(id);
61
- return family !== undefined && inGroup.has(family.root.thread.id);
55
+ const tree = forest.treeOf.get(id);
56
+ return tree !== undefined && inGroup.has(tree.root.thread.id);
62
57
  };
63
58
  if (group.collapsed) {
64
59
  return {
@@ -78,19 +73,35 @@ export interface MarkAllRead {
78
73
  seen: string[];
79
74
  }
80
75
 
81
- /** Every unread thread in the families, descendants included. */
76
+ /** Every unread thread in the trees, descendants included: Mark all read, and a root's Mark read for its tree. */
82
77
  export function markAllReadPlan(
83
- families: readonly Family[],
78
+ trees: readonly ThreadTree[],
84
79
  context: { activeThreadId: string | null; finishedAt: Readonly<Record<string, number>>; seenAt: Readonly<Record<string, number>> },
85
80
  ): MarkAllRead {
86
81
  const read: string[] = [];
87
82
  const seen: string[] = [];
88
- for (const family of families) {
89
- for (const info of [family.root, ...family.descendants]) {
90
- if (!info.unread || info.thread.isArchived) continue;
83
+ for (const tree of trees) {
84
+ for (const info of [tree.root, ...tree.descendants]) {
85
+ if (!info.unread) continue;
91
86
  read.push(info.thread.id);
92
87
  if (isDoneUnseen(info.thread, context)) seen.push(info.thread.id);
93
88
  }
94
89
  }
95
90
  return { read, seen };
96
91
  }
92
+
93
+ /**
94
+ * What one row's Mark read marks: on a tree's root, every unread thread in
95
+ * the tree; on any other row, the thread alone.
96
+ */
97
+ export function markReadPlanFor(
98
+ threadId: string,
99
+ forest: Pick<Forest, "infos" | "treeOf">,
100
+ context: { activeThreadId: string | null; finishedAt: Readonly<Record<string, number>>; seenAt: Readonly<Record<string, number>> },
101
+ ): MarkAllRead {
102
+ const tree = forest.treeOf.get(threadId);
103
+ if (tree !== undefined && tree.root.thread.id === threadId) return markAllReadPlan([tree], context);
104
+ const thread = forest.infos.get(threadId)?.thread;
105
+ if (thread === undefined) return { read: [threadId], seen: [] };
106
+ return { read: [threadId], seen: isDoneUnseen(thread, context) ? [threadId] : [] };
107
+ }
@@ -1,8 +1,9 @@
1
- // Families: who attaches to whom, and what each family
1
+ // Thread trees: who attaches to whom, and what each tree
2
2
  // carries. Pure.
3
3
  import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
4
4
  import {
5
5
  computeState,
6
+ chipFlagsOf,
6
7
  hiddenThreadFlags,
7
8
  isQuietThread,
8
9
  isUnread,
@@ -14,11 +15,11 @@ import {
14
15
  } from "./state";
15
16
  import { attentionFlagsOf, isOrphanedFailure } from "./attention";
16
17
  import { compareCreationAscending } from "./sort";
17
- import { attentionNote, needsKindOf, rowNote, type RowNote } from "./notes";
18
+ import { needsKindOf, rowNote, type RowNote } from "./notes";
18
19
  import type { ThreadNotes } from "@/shared/contract";
19
20
  import type { ChildAttention } from "@/shared/preferences";
20
21
 
21
- /** States that keep a child out of its family's older fold with `childAttention` `blocked`: working, setting up, background work. */
22
+ /** States that keep a child out of an open tree's "N more child threads" fold with `childAttention` `blocked`: working, setting up, background work. */
22
23
  const RUNNING: ReadonlySet<StateKind> = new Set<StateKind>(["working", "background"]);
23
24
 
24
25
  /** Everything the list knows about one thread. */
@@ -28,16 +29,19 @@ export interface ThreadInfo {
28
29
  unread: boolean;
29
30
  /** Own flags; for hidden threads only waits-on-you and unread-failed. */
30
31
  flags: ReadonlySet<Flag>;
32
+ /** What it adds to the children chip of the threads above it (see `chipFlagsOf`). */
33
+ chipFlags: ReadonlySet<Flag>;
31
34
  /**
32
- * The flags of this thread that make it need attention, for Needs attention, the
33
- * counters and auto-reveal: a child counts less than a root.
35
+ * The flags of this thread that make it need attention, for collapsed
36
+ * groups, the counters, the need-you filter and auto-reveal: a child counts
37
+ * less than a root.
34
38
  */
35
39
  attentionFlags: Set<Flag>;
36
40
  /** Quiet test for the thread alone. */
37
41
  quiet: boolean;
38
42
  /**
39
43
  * A quiet thread, as if no thread were open: nothing to see behind a
40
- * family's fold. A root, or any child when `childAttention` is
44
+ * tree's fold. A root, or any child when `childAttention` is
41
45
  * `everything`, takes the quiet test without the open thread's exemption.
42
46
  * Otherwise a child is quiet unless it runs or needs attention, so finishing
43
47
  * unread folds it; an archived child is always quiet. The fold reads this,
@@ -49,23 +53,25 @@ export interface ThreadInfo {
49
53
  parentId: string | null;
50
54
  /** Why it waits on you or failed, for the line under the row. */
51
55
  note: RowNote | null;
52
- /** Why it needs attention, for the line under its row in Needs attention. */
53
- attentionNote: RowNote | null;
54
56
  }
55
57
 
56
- /** What a thread's descendants add up to, for its chip and for folding. */
58
+ /** What a thread's descendants add up to, for its chip and folding. */
57
59
  export interface Subtree {
58
60
  /** Descendants, depth-first in creation order, hidden ones included. */
59
61
  descendants: ThreadInfo[];
60
62
  /** Union of the descendants' Needs attention flags and working, archived ones left out. */
61
63
  flags: ReadonlySet<Flag>;
62
- /** Visible descendants: the chip's number. */
64
+ /** Union of the descendants' `chipFlags`, archived ones left out: the chip's state. */
65
+ chipFlags: ReadonlySet<Flag>;
66
+ /** Visible descendants at any depth. */
63
67
  visibleCount: number;
68
+ /** Visible direct children: the chip's number. */
69
+ childCount: number;
64
70
  /** Every visible descendant is quiet, as if no thread were open, so the subtree adds nothing to see when folded. */
65
71
  quietIgnoringOpen: boolean;
66
72
  }
67
73
 
68
- export interface Family {
74
+ export interface ThreadTree {
69
75
  root: ThreadInfo;
70
76
  /** Descendants, depth-first in creation order, hidden ones included. */
71
77
  descendants: ThreadInfo[];
@@ -73,11 +79,11 @@ export interface Family {
73
79
  descendantFlags: ReadonlySet<Flag>;
74
80
  /** The same over root and descendants (rollup, folder rows). */
75
81
  flags: ReadonlySet<Flag>;
76
- /** Needs attention flags over root and descendants: what the section and its order read. */
82
+ /** Needs attention flags over root and descendants: the tree needs attention when any is set. */
77
83
  attentionFlags: ReadonlySet<Flag>;
78
84
  /** Visible descendants: the chip's number. */
79
85
  visibleDescendantCount: number;
80
- /** Largest latestAttentionAt over the family. */
86
+ /** Largest latestAttentionAt over the tree. */
81
87
  latestAttentionAt: number;
82
88
  /** Every thread quiet and the active thread not in it. */
83
89
  quiet: boolean;
@@ -93,8 +99,8 @@ export interface Forest {
93
99
  infos: ReadonlyMap<string, ThreadInfo>;
94
100
  /** Visible child ids per parent id, creation order; hidden children included. */
95
101
  children: ReadonlyMap<string, readonly string[]>;
96
- families: readonly Family[];
97
- familyOf: ReadonlyMap<string, Family>;
102
+ trees: readonly ThreadTree[];
103
+ treeOf: ReadonlyMap<string, ThreadTree>;
98
104
  /** The rollup under every thread with a row, by thread id. */
99
105
  subtrees: ReadonlyMap<string, Subtree>;
100
106
  }
@@ -161,13 +167,13 @@ export function buildForest(inputs: ForestInputs): Forest {
161
167
  state,
162
168
  unread,
163
169
  flags: thread.isHidden ? hiddenThreadFlags(flags) : flags,
170
+ chipFlags: chipFlagsOf(flags, thread.isHidden),
164
171
  attentionFlags: new Set<Flag>(),
165
172
  quiet: isQuietThread(state, unread, isActive),
166
173
  quietIgnoringOpen: false,
167
174
  isActive,
168
175
  parentId: attachParent(thread, byId),
169
176
  note: rowNote(thread, inputs.notes?.[thread.id]),
170
- attentionNote: null,
171
177
  });
172
178
  }
173
179
 
@@ -181,7 +187,6 @@ export function buildForest(inputs: ForestInputs): Forest {
181
187
  parent !== undefined &&
182
188
  isOrphanedFailure(info.thread, info.flags, { ...parent, finishedAt: inputs.finishedAt[parent.thread.id] }),
183
189
  });
184
- info.attentionNote = attentionNote(info.thread, inputs.notes?.[info.thread.id], info.attentionFlags);
185
190
  info.quietIgnoringOpen =
186
191
  parent === undefined || mode === "everything"
187
192
  ? isQuietThread(info.state, info.unread, false)
@@ -192,7 +197,7 @@ export function buildForest(inputs: ForestInputs): Forest {
192
197
  const roots: ThreadInfo[] = [];
193
198
  for (const info of infos.values()) {
194
199
  if (info.parentId === null) {
195
- // A hidden thread with no visible ancestor has no row and no family.
200
+ // A hidden thread with no visible ancestor has no row and no tree.
196
201
  if (!info.thread.isHidden) roots.push(info);
197
202
  continue;
198
203
  }
@@ -211,7 +216,9 @@ export function buildForest(inputs: ForestInputs): Forest {
211
216
  if (cached !== undefined) return cached;
212
217
  const descendants: ThreadInfo[] = [];
213
218
  const flags = new Set<Flag>();
219
+ const chipFlags = new Set<Flag>();
214
220
  let visibleCount = 0;
221
+ let childCount = 0;
215
222
  let quietIgnoringOpen = true;
216
223
  path.add(id);
217
224
  for (const childId of children.get(id) ?? []) {
@@ -222,22 +229,27 @@ export function buildForest(inputs: ForestInputs): Forest {
222
229
  if (!child.thread.isArchived) {
223
230
  for (const flag of child.attentionFlags) flags.add(flag);
224
231
  if (child.flags.has("working")) flags.add("working");
232
+ for (const flag of child.chipFlags) chipFlags.add(flag);
225
233
  }
226
234
  for (const flag of below.flags) flags.add(flag);
235
+ for (const flag of below.chipFlags) chipFlags.add(flag);
227
236
  if (child.thread.isHidden ? child.attentionFlags.size > 0 : !child.quietIgnoringOpen) quietIgnoringOpen = false;
228
- if (!child.thread.isHidden) visibleCount += 1;
237
+ if (!child.thread.isHidden) {
238
+ visibleCount += 1;
239
+ childCount += 1;
240
+ }
229
241
  visibleCount += below.visibleCount;
230
242
  if (!below.quietIgnoringOpen) quietIgnoringOpen = false;
231
243
  }
232
244
  path.delete(id);
233
- const subtree: Subtree = { descendants, flags, visibleCount, quietIgnoringOpen };
245
+ const subtree: Subtree = { descendants, flags, chipFlags, visibleCount, childCount, quietIgnoringOpen };
234
246
  subtrees.set(id, subtree);
235
247
  return subtree;
236
248
  };
237
249
  for (const id of infos.keys()) subtreeOf(id, new Set());
238
250
 
239
- const families: Family[] = [];
240
- const familyOf = new Map<string, Family>();
251
+ const trees: ThreadTree[] = [];
252
+ const treeOf = new Map<string, ThreadTree>();
241
253
  for (const root of roots) {
242
254
  const { descendants, flags: descendantFlags, visibleCount: visibleDescendantCount } = subtrees.get(root.thread.id)!;
243
255
  let latestAttentionAt = root.thread.latestAttentionAt;
@@ -254,7 +266,7 @@ export function buildForest(inputs: ForestInputs): Forest {
254
266
  } else {
255
267
  if (!info.quiet) quiet = false;
256
268
  // A child reads as its own fold does, so with `blocked` an unread child
257
- // does not keep an old family out of the group's fold.
269
+ // does not keep an old tree out of the group's fold.
258
270
  if (!info.quietIgnoringOpen) quietIgnoringOpen = false;
259
271
  }
260
272
  }
@@ -266,7 +278,7 @@ export function buildForest(inputs: ForestInputs): Forest {
266
278
  }
267
279
  if (root.flags.has("working")) flags.add("working");
268
280
  }
269
- const family: Family = {
281
+ const tree: ThreadTree = {
270
282
  root,
271
283
  descendants,
272
284
  descendantFlags,
@@ -278,11 +290,11 @@ export function buildForest(inputs: ForestInputs): Forest {
278
290
  quietIgnoringOpen,
279
291
  containsActive,
280
292
  };
281
- families.push(family);
282
- familyOf.set(root.thread.id, family);
283
- for (const info of descendants) familyOf.set(info.thread.id, family);
293
+ trees.push(tree);
294
+ treeOf.set(root.thread.id, tree);
295
+ for (const info of descendants) treeOf.set(info.thread.id, tree);
284
296
  }
285
- return { infos, children, families, familyOf, subtrees };
297
+ return { infos, children, trees, treeOf, subtrees };
286
298
  }
287
299
 
288
300
  /** Ids from `id` up to (not including) `stopAt`, nearest first. */