@gperezmz/bb-plugin-thread-glance 0.3.0 → 0.4.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.
@@ -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;
@@ -3,7 +3,7 @@
3
3
  import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
4
4
  import {
5
5
  computeState,
6
- dotFlagsOf,
6
+ chipFlagsOf,
7
7
  hiddenThreadFlags,
8
8
  isQuietThread,
9
9
  isUnread,
@@ -29,8 +29,8 @@ export interface ThreadInfo {
29
29
  unread: boolean;
30
30
  /** Own flags; for hidden threads only waits-on-you and unread-failed. */
31
31
  flags: ReadonlySet<Flag>;
32
- /** What it adds to the child dot of the threads above it (see `dotFlagsOf`). */
33
- dotFlags: ReadonlySet<Flag>;
32
+ /** What it adds to the children chip of the threads above it (see `chipFlagsOf`). */
33
+ chipFlags: ReadonlySet<Flag>;
34
34
  /**
35
35
  * The flags of this thread that make it need attention, for collapsed
36
36
  * groups, the counters, the need-you filter and auto-reveal: a child counts
@@ -55,14 +55,14 @@ export interface ThreadInfo {
55
55
  note: RowNote | null;
56
56
  }
57
57
 
58
- /** What a thread's descendants add up to, for its chip, its child dot and folding. */
58
+ /** What a thread's descendants add up to, for its chip and folding. */
59
59
  export interface Subtree {
60
60
  /** Descendants, depth-first in creation order, hidden ones included. */
61
61
  descendants: ThreadInfo[];
62
62
  /** Union of the descendants' Needs attention flags and working, archived ones left out. */
63
63
  flags: ReadonlySet<Flag>;
64
- /** Union of the descendants' `dotFlags`, archived ones left out: the child dot. */
65
- dotFlags: ReadonlySet<Flag>;
64
+ /** Union of the descendants' `chipFlags`, archived ones left out: the chip's state. */
65
+ chipFlags: ReadonlySet<Flag>;
66
66
  /** Visible descendants at any depth. */
67
67
  visibleCount: number;
68
68
  /** Visible direct children: the chip's number. */
@@ -167,7 +167,7 @@ export function buildForest(inputs: ForestInputs): Forest {
167
167
  state,
168
168
  unread,
169
169
  flags: thread.isHidden ? hiddenThreadFlags(flags) : flags,
170
- dotFlags: dotFlagsOf(flags, thread.isHidden),
170
+ chipFlags: chipFlagsOf(flags, thread.isHidden),
171
171
  attentionFlags: new Set<Flag>(),
172
172
  quiet: isQuietThread(state, unread, isActive),
173
173
  quietIgnoringOpen: false,
@@ -216,7 +216,7 @@ export function buildForest(inputs: ForestInputs): Forest {
216
216
  if (cached !== undefined) return cached;
217
217
  const descendants: ThreadInfo[] = [];
218
218
  const flags = new Set<Flag>();
219
- const dotFlags = new Set<Flag>();
219
+ const chipFlags = new Set<Flag>();
220
220
  let visibleCount = 0;
221
221
  let childCount = 0;
222
222
  let quietIgnoringOpen = true;
@@ -229,10 +229,10 @@ export function buildForest(inputs: ForestInputs): Forest {
229
229
  if (!child.thread.isArchived) {
230
230
  for (const flag of child.attentionFlags) flags.add(flag);
231
231
  if (child.flags.has("working")) flags.add("working");
232
- for (const flag of child.dotFlags) dotFlags.add(flag);
232
+ for (const flag of child.chipFlags) chipFlags.add(flag);
233
233
  }
234
234
  for (const flag of below.flags) flags.add(flag);
235
- for (const flag of below.dotFlags) dotFlags.add(flag);
235
+ for (const flag of below.chipFlags) chipFlags.add(flag);
236
236
  if (child.thread.isHidden ? child.attentionFlags.size > 0 : !child.quietIgnoringOpen) quietIgnoringOpen = false;
237
237
  if (!child.thread.isHidden) {
238
238
  visibleCount += 1;
@@ -242,7 +242,7 @@ export function buildForest(inputs: ForestInputs): Forest {
242
242
  if (!below.quietIgnoringOpen) quietIgnoringOpen = false;
243
243
  }
244
244
  path.delete(id);
245
- const subtree: Subtree = { descendants, flags, dotFlags, visibleCount, childCount, quietIgnoringOpen };
245
+ const subtree: Subtree = { descendants, flags, chipFlags, visibleCount, childCount, quietIgnoringOpen };
246
246
  subtrees.set(id, subtree);
247
247
  return subtree;
248
248
  };
@@ -29,12 +29,18 @@ import type { RowNote } from "./notes";
29
29
  /** How many quiet children stay in an expanded tree. */
30
30
  export const KEEP_QUIET_CHILDREN = 3;
31
31
 
32
- /** The children chip: the muted count of a parent's direct children, and its chevron. */
32
+ /** The children chip: the count of a parent's direct children and its chevron, after the state of its descendants. */
33
33
  export interface Chip {
34
- /** Direct children opening it shows, hidden ones left out. */
34
+ /** Direct children opening it shows, hidden ones left out; 0 draws no number. */
35
35
  count: number;
36
36
  /** The user opened the chip, so every child shows. */
37
37
  expanded: boolean;
38
+ /**
39
+ * The most urgent state among the descendants at any depth, open or
40
+ * collapsed, or null for none: its glyph leads the chip and colours it, and
41
+ * keeps that colour through dimming and hover.
42
+ */
43
+ flag: Flag | null;
38
44
  }
39
45
 
40
46
  export interface ThreadRow {
@@ -53,11 +59,6 @@ export interface ThreadRow {
53
59
  * default, a child whose harness is not its parent thread's.
54
60
  */
55
61
  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
62
  /** The title is bold: the thread is unread. */
62
63
  bold: boolean;
63
64
  /** A root whose tree holds an unread thread: it offers Mark read for the whole tree. */
@@ -267,7 +268,6 @@ function threadRow(
267
268
  parentTitle: titleOf(context, info.parentId),
268
269
  chip: options.chip,
269
270
  harness: drawsHarness(context, info),
270
- childDot: mostUrgent(subtreeOf(context, info.thread.id).dotFlags),
271
271
  bold: info.unread,
272
272
  treeUnread: info === root && [root, ...(context.forest.treeOf.get(root.thread.id)?.descendants ?? [])].some((info) => info.unread),
273
273
  note: info.note,
@@ -405,11 +405,13 @@ function clusterEnvironments(context: Context, units: Unit[], depth: number): Tr
405
405
  return rows;
406
406
  }
407
407
 
408
- /** The children chip of a parent, or null when opening it would show nothing. */
408
+ /** The children chip of a parent, or null when it would show nothing. */
409
409
  function chipOf(context: Context, info: ThreadInfo, expanded: boolean): Chip | null {
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 };
410
+ const { childCount: count, chipFlags } = subtreeOf(context, info.thread.id);
411
+ const flag = mostUrgent(chipFlags);
412
+ // A hidden child's failure that needs no attention has no row, yet the chip still shows it.
413
+ if (count === 0 && flag === null && eligibleChildren(context, info.thread.id).length === 0) return null;
414
+ return { count, expanded, flag };
413
415
  }
414
416
 
415
417
  /** What a thread and everything under it carry, for an environment folder's glyph. */
@@ -0,0 +1,13 @@
1
+ @import "tailwindcss";
2
+
3
+ /* The tests measure where the row's boxes end up; fades would only delay it. */
4
+ *,
5
+ *::before,
6
+ *::after {
7
+ transition: none !important;
8
+ }
9
+
10
+ /* bb draws icons as sized SVGs; the test SDK's stand-in is a bare span. */
11
+ [data-icon] {
12
+ display: inline-block;
13
+ }
@@ -8,7 +8,7 @@ import { defaultPreferences, type Preferences } from "@/shared/preferences";
8
8
  import { buildForest, type Forest } from "../model/trees";
9
9
  import { buildListView, type ListView, type Row } from "../model/view";
10
10
  import type { Targets } from "../model/expansion";
11
- import { pullRequestFact, type PullRequestState, type SettleInputs } from "../model/settled";
11
+ import type { SettleInputs } from "../model/settled";
12
12
 
13
13
  export const T0 = 1_780_000_000_000;
14
14
 
@@ -125,8 +125,6 @@ export interface Scenario {
125
125
  needYouOnly?: boolean;
126
126
  /** bb's default harness; "claude-code", the fixtures' own, when absent. */
127
127
  defaultProviderId?: string | null;
128
- /** Pull request lookups that answered, by thread id. */
129
- pullRequests?: Record<string, PullRequestState | null>;
130
128
  }
131
129
 
132
130
  export function forestOf(scenario: Scenario): Forest {
@@ -165,15 +163,11 @@ export function viewOf(scenario: Scenario): ListView {
165
163
 
166
164
  /** The settle inputs a scenario stands for. */
167
165
  export function settleOf(scenario: Scenario): SettleInputs {
168
- const answers = new Map(Object.entries(scenario.pullRequests ?? {}));
169
- const defaultBranchOf = (projectId: string) =>
170
- scenario.defaultBranches === undefined ? "main" : scenario.defaultBranches[projectId];
171
166
  return {
172
167
  now: scenario.now ?? T0 + 60_000,
173
168
  settleAfter: scenario.prefs?.settleAfter ?? defaultPreferences().settleAfter,
174
169
  startedAt: scenario.startedAt ?? {},
175
170
  finishedAt: scenario.finishedAt ?? {},
176
- pullRequestOf: (thread) => pullRequestFact(thread, defaultBranchOf(thread.projectId), answers),
177
171
  };
178
172
  }
179
173
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gperezmz/bb-plugin-thread-glance",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bb": ">=0.43",
@@ -34,12 +34,14 @@
34
34
  "@radix-ui/react-popover": "^1.1.19",
35
35
  "@radix-ui/react-select": "^2.3.3",
36
36
  "@radix-ui/react-tooltip": "^1.2.12",
37
+ "@tailwindcss/vite": "^4.3.3",
37
38
  "@testing-library/dom": "^10.4.2",
38
39
  "@testing-library/react": "^16.3.3",
39
40
  "@types/better-sqlite3": "^7.6.12",
40
41
  "@types/node": "^22.0.0",
41
42
  "@types/react": "^19.0.0",
42
43
  "@types/react-dom": "^19.0.0",
44
+ "@vitest/browser-playwright": "5.0.1",
43
45
  "better-sqlite3": "^12.0.0",
44
46
  "class-variance-authority": "^0.7.1",
45
47
  "clsx": "^2.1.1",
@@ -47,10 +49,12 @@
47
49
  "esbuild": "^0.28.2",
48
50
  "hono": "^4.11.9",
49
51
  "jsdom": "^30.1.1",
52
+ "playwright": "^1.63.0",
50
53
  "react": "^19.3.0",
51
54
  "react-dom": "^19.3.0",
52
55
  "sonner": "^1.7.4",
53
56
  "tailwind-merge": "^3.4.0",
57
+ "tailwindcss": "^4.3.3",
54
58
  "typescript": "^7.0.2",
55
59
  "vaul": "^1.1.2",
56
60
  "vitest": "^5.0.1"
@@ -52,7 +52,7 @@ and `bb environment list`; a thread id is a `thr_…` id.
52
52
  | `collapsedThreadSections` | Custom section keys, `section:<id>` | `[]` |
53
53
  | `collapsedMachines` | Machine ids | `[]` |
54
54
  | `collapsedEnvironments` | Environment ids of collapsed folder rows | `[]` |
55
- | `settleAfter` | Settle after: how long a quiet thread goes without activity before its tree can settle into the group's "Settled (N)" fold: `12h`, `1d`, `3d`, `1w` or `never` (then only a merged or closed pull request settles it) | `1d` |
55
+ | `settleAfter` | Settle after: how long a quiet thread goes without activity before its tree can settle into the group's "Settled (N)" fold: `12h`, `1d`, `3d`, `1w` or `never` (then nothing settles) | `1d` |
56
56
  | `openSettledFolds` | Group ids whose settled fold is open | `[]` |
57
57
  | `expandedOlder` | Parent thread ids whose "N more child threads" row is open, once that tree's children chip is open | `[]` |
58
58
  | `expandedChildren` | Parent thread ids whose chip is open | `[]` |
@@ -1,27 +0,0 @@
1
- // Looks up the pull request of every thread settling has to know about and
2
- // reports each answer. Draws nothing: the host shares one lookup per
3
- // environment with the badges that ask for the same threads.
4
- import { memo, useEffect } from "react";
5
- import { experimental_useSidebarThreadPullRequest as usePullRequest } from "@get-bb/plugin-sdk/app";
6
- import type { PullRequestState } from "../model/settled";
7
-
8
- type Answer = (threadId: string, state: PullRequestState | null) => void;
9
-
10
- const Probe = memo(function Probe({ threadId, onAnswer }: { threadId: string; onAnswer: Answer }) {
11
- const { isLoading, pullRequest } = usePullRequest(threadId);
12
- const state = isLoading ? undefined : (pullRequest?.state ?? null);
13
- useEffect(() => {
14
- if (state !== undefined) onAnswer(threadId, state);
15
- }, [threadId, state, onAnswer]);
16
- return null;
17
- });
18
-
19
- export function PullRequestProbes({ threadIds, onAnswer }: { threadIds: readonly string[]; onAnswer: Answer }) {
20
- return (
21
- <>
22
- {threadIds.map((threadId) => (
23
- <Probe key={threadId} threadId={threadId} onAnswer={onAnswer} />
24
- ))}
25
- </>
26
- );
27
- }
@@ -1,36 +0,0 @@
1
- // The pull request lookups that answered, by thread id. Answers arriving in
2
- // one burst (a list's worth of lookups on load) land in one state update.
3
- //
4
- // Debt: this copies server state the host's per-row pull request hook owns,
5
- // because settling reads every thread's pull request at once and the SDK
6
- // offers no query over many threads. It clears when the SDK does.
7
- import { useCallback, useRef, useState } from "react";
8
- import type { PullRequestState } from "../model/settled";
9
-
10
- export type PullRequestAnswers = ReadonlyMap<string, PullRequestState | null>;
11
-
12
- export function usePullRequestAnswers(): [PullRequestAnswers, (threadId: string, state: PullRequestState | null) => void] {
13
- const [answers, setAnswers] = useState<PullRequestAnswers>(() => new Map());
14
- const pending = useRef(new Map<string, PullRequestState | null>());
15
- const scheduled = useRef(false);
16
- const onAnswer = useCallback((threadId: string, state: PullRequestState | null) => {
17
- pending.current.set(threadId, state);
18
- if (scheduled.current) return;
19
- scheduled.current = true;
20
- setTimeout(() => {
21
- scheduled.current = false;
22
- const batch = pending.current;
23
- pending.current = new Map();
24
- setAnswers((current) => {
25
- let next: Map<string, PullRequestState | null> | null = null;
26
- for (const [id, value] of batch) {
27
- if (current.has(id) && current.get(id) === value) continue;
28
- next ??= new Map(current);
29
- next.set(id, value);
30
- }
31
- return next ?? current;
32
- });
33
- }, 0);
34
- }, []);
35
- return [answers, onAnswer];
36
- }