@gperezmz/bb-plugin-thread-glance 0.4.1 → 0.5.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 (35) hide show
  1. package/dist/app.css +1 -1
  2. package/dist/app.js +30 -30
  3. package/dist/app.meta.json +1 -1
  4. package/dist/server.js +10 -10
  5. package/dist/server.js.map +3 -3
  6. package/dist/server.meta.json +1 -1
  7. package/features/thread-list/components/FoldRows.tsx +3 -3
  8. package/features/thread-list/components/GroupSection.tsx +10 -7
  9. package/features/thread-list/components/ListHeader.tsx +9 -4
  10. package/features/thread-list/components/ProviderBadge.tsx +1 -1
  11. package/features/thread-list/components/PullRequestBadge.tsx +1 -1
  12. package/features/thread-list/components/ThreadList.tsx +34 -13
  13. package/features/thread-list/components/ThreadRowView.tsx +9 -10
  14. package/features/thread-list/components/controller.ts +1 -1
  15. package/features/thread-list/components/glyphs.tsx +7 -4
  16. package/features/thread-list/components/input-modality.ts +51 -0
  17. package/features/thread-list/components/row-card.ts +1 -1
  18. package/features/thread-list/data/useIdleSince.ts +27 -0
  19. package/features/thread-list/data/useNow.ts +4 -3
  20. package/features/thread-list/data/useStamps.ts +19 -4
  21. package/features/thread-list/model/attention.ts +71 -7
  22. package/features/thread-list/model/expansion.ts +1 -1
  23. package/features/thread-list/model/settled.ts +1 -1
  24. package/features/thread-list/model/state.ts +18 -5
  25. package/features/thread-list/model/toggles.ts +3 -3
  26. package/features/thread-list/model/trees.ts +46 -9
  27. package/features/thread-list/model/view.ts +11 -5
  28. package/features/thread-list/testing/browser.css +7 -0
  29. package/features/thread-list/testing/fixtures.ts +10 -0
  30. package/package.json +1 -1
  31. package/server/stamps.ts +21 -3
  32. package/server.ts +5 -0
  33. package/shared/contract.ts +8 -0
  34. package/shared/signals.ts +2 -0
  35. package/skills/thread-glance/SKILL.md +1 -1
@@ -3,7 +3,7 @@
3
3
  // builds. Pure.
4
4
  import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
5
5
  import type { ChildAttention } from "@/shared/preferences";
6
- import { type Flag, type StateKind, type ThreadState } from "./state";
6
+ import { computeState, type Flag, type StateKind, type ThreadState } from "./state";
7
7
 
8
8
  /** The flags that count for a root thread. Working is not one. */
9
9
  export const ROOT_ATTENTION: ReadonlySet<Flag> = new Set<Flag>([
@@ -17,11 +17,24 @@ export const ROOT_ATTENTION: ReadonlySet<Flag> = new Set<Flag>([
17
17
  /** States in which a parent thread is busy and may still deal with a failed child. */
18
18
  const BUSY: ReadonlySet<StateKind> = new Set<StateKind>(["working", "background", "queued", "scheduled"]);
19
19
 
20
+ /**
21
+ * How long a parent thread stays idle before a child's failure counts as
22
+ * orphaned. bb reports a child's turn to its parent after a 2 s batch delay,
23
+ * and the parent then has to start: a failure it is about to pick up never
24
+ * reaches the need-you filter.
25
+ */
26
+ export const ORPHAN_WAIT_MS = 5_000;
27
+
20
28
  export interface ParentThread {
21
29
  thread: Pick<PluginSidebarThread, "latestAttentionAt">;
22
30
  state: Pick<ThreadState, "kind">;
23
31
  /** When the plugin server last saw the parent thread finish a turn, if it did. */
24
32
  finishedAt?: number;
33
+ /**
34
+ * When a window last saw the parent thread go from busy to idle, if one did;
35
+ * null while that is not yet known, and no failure under it counts.
36
+ */
37
+ idleSince?: number | null;
25
38
  }
26
39
 
27
40
  /** A parent thread is idle when it is not working, setting up, running background work or holding a queued message. */
@@ -41,21 +54,72 @@ export function failureTime(
41
54
  }
42
55
 
43
56
  /**
44
- * An orphaned failure: the parent thread is idle and has not run or
45
- * finished since the child failed. A busy parent thread, or one that
57
+ * When a child's failure becomes an orphaned failure, or null while it cannot:
58
+ * the parent thread is idle and has not run or finished since the child
59
+ * failed, and has stayed idle for `ORPHAN_WAIT_MS` from the later of the
60
+ * failure and its last becoming idle. A busy parent thread, or one that
46
61
  * finished after the failure, is taken to know about it. The parent thread's
47
62
  * own clock is its last finish (`finishedAt`, or `latestAttentionAt` when it
48
63
  * failed): `updatedAt` also moves when someone merely opens or renames it.
49
64
  */
65
+ export function orphanedAt(
66
+ thread: Pick<PluginSidebarThread, "latestAttentionAt" | "updatedAt">,
67
+ flags: ReadonlySet<Flag>,
68
+ parent: ParentThread,
69
+ ): number | null {
70
+ if (!flags.has("unread-failed") && !flags.has("queue-failed")) return null;
71
+ if (!isParentIdle(parent) || parent.idleSince === null) return null;
72
+ const parentActiveAt = Math.max(parent.thread.latestAttentionAt, parent.finishedAt ?? 0);
73
+ const failedAt = failureTime(thread, flags);
74
+ if (parentActiveAt > failedAt) return null;
75
+ return Math.max(failedAt, parent.idleSince ?? 0) + ORPHAN_WAIT_MS;
76
+ }
77
+
78
+ /** Whether a child's failure is an orphaned failure at `now` (see `orphanedAt`). */
50
79
  export function isOrphanedFailure(
51
80
  thread: Pick<PluginSidebarThread, "latestAttentionAt" | "updatedAt">,
52
81
  flags: ReadonlySet<Flag>,
53
82
  parent: ParentThread,
83
+ now: number,
54
84
  ): boolean {
55
- if (!flags.has("unread-failed") && !flags.has("queue-failed")) return false;
56
- if (!isParentIdle(parent)) return false;
57
- const parentActiveAt = Math.max(parent.thread.latestAttentionAt, parent.finishedAt ?? 0);
58
- return parentActiveAt <= failureTime(thread, flags);
85
+ const at = orphanedAt(thread, flags, parent);
86
+ return at !== null && at <= now;
87
+ }
88
+
89
+ /** Which threads the list last saw busy, and when each last went from busy to idle. */
90
+ export interface IdleTracker {
91
+ busy: ReadonlySet<string>;
92
+ idleSince: Readonly<Record<string, number>>;
93
+ /** The threads this step saw go idle, for the server's `idleAt`. */
94
+ wentIdle: readonly string[];
95
+ }
96
+
97
+ function isBusyThread(thread: PluginSidebarThread): boolean {
98
+ const state = computeState(thread, { unread: false, hasDraft: false, scheduledAt: null, now: 0 });
99
+ return !isParentIdle({ thread, state });
100
+ }
101
+
102
+ /**
103
+ * The tracker after the list sees `threads` at `at`: a thread busy before and
104
+ * idle now went idle at `at`. A thread first seen idle has no time here; the
105
+ * server's `idleAt` holds what an earlier window saw.
106
+ */
107
+ export function trackIdle(previous: IdleTracker | null, threads: readonly PluginSidebarThread[], at: number): IdleTracker {
108
+ const busy = new Set<string>();
109
+ const idleSince: Record<string, number> = {};
110
+ const wentIdle: string[] = [];
111
+ for (const thread of threads) {
112
+ const id = thread.id;
113
+ if (isBusyThread(thread)) {
114
+ busy.add(id);
115
+ } else if (previous?.busy.has(id)) {
116
+ idleSince[id] = at;
117
+ wentIdle.push(id);
118
+ } else if (previous?.idleSince[id] !== undefined) {
119
+ idleSince[id] = previous.idleSince[id];
120
+ }
121
+ }
122
+ return { busy, idleSince, wentIdle };
59
123
  }
60
124
 
61
125
  /**
@@ -6,7 +6,7 @@ import type { Forest } from "./trees";
6
6
  /**
7
7
  * `reveal` targets open the parent chips on their path. `open` targets (a
8
8
  * thread that newly became unread) open no chip, so finished children don't
9
- * expand their parent's. The open thread's target, of either kind, also
9
+ * expand their parent's. The focused thread's target, of either kind, also
10
10
  * opens its collapsed group. No target opens a settled fold.
11
11
  */
12
12
  export type TargetKind = "reveal" | "open";
@@ -42,7 +42,7 @@ export function lastActivityAt(
42
42
  }
43
43
 
44
44
  /**
45
- * A settled thread: quiet as if no thread were open, not needing attention,
45
+ * A settled thread: quiet as if no thread were focused, not needing attention,
46
46
  * not pinned, and its own last activity is older than the Settle after
47
47
  * period. Hidden and archived threads take the same test.
48
48
  */
@@ -33,8 +33,8 @@ export const FLAG_ORDER: readonly Flag[] = [
33
33
 
34
34
  /**
35
35
  * `working` is bb's timeline accent, so a running thread stands out in both
36
- * themes; `background` keeps bb's faint tone for background
37
- * activity, so the two stay apart.
36
+ * themes; `background` draws background activity in the grey `muted` has,
37
+ * and its glyph shines, which keeps it apart from a draft.
38
38
  */
39
39
  export type Tone = "attention" | "destructive" | "working" | "background" | "muted" | "muted-strong" | "none";
40
40
 
@@ -109,17 +109,30 @@ export function isOffline(thread: Pick<PluginSidebarThread, "status" | "runtimeS
109
109
  /** Per-thread facts the host payload doesn't carry. */
110
110
  export interface ThreadContext {
111
111
  activeThreadId: string | null;
112
+ /**
113
+ * Threads shown in a visible pane of the window besides the active one:
114
+ * bb's split panes. Absent, only the active (focused) thread is open.
115
+ */
116
+ openThreadIds?: ReadonlySet<string>;
112
117
  /** Stamps, from the plugin server. */
113
118
  finishedAt: Readonly<Record<string, number>>;
114
119
  seenAt: Readonly<Record<string, number>>;
115
120
  }
116
121
 
122
+ /** Whether the thread is shown in a visible pane: the active one or another split pane. */
123
+ export function isOpenThread(thread: Pick<PluginSidebarThread, "id">, context: ThreadContext): boolean {
124
+ return thread.id === context.activeThreadId || (context.openThreadIds?.has(thread.id) ?? false);
125
+ }
126
+
117
127
  /**
118
128
  * bb's rule for every thread: unread when it has finished (idle or
119
129
  * error) since it was last read. Children are also unread when they finished
120
- * after you last looked at them (done-unseen).
130
+ * after you last looked at them (done-unseen). An open thread is never
131
+ * unread: bb marks it read moments after it finishes or fails, and counting
132
+ * it until then only flashes the need-you filter.
121
133
  */
122
134
  export function isUnread(thread: PluginSidebarThread, context: ThreadContext): boolean {
135
+ if (isOpenThread(thread, context)) return false;
123
136
  const status = normalizeStatus(thread);
124
137
  const lastRead = thread.lastReadAt ?? 0;
125
138
  if ((status === "idle" || status === "error") && lastRead < thread.latestAttentionAt) {
@@ -132,7 +145,7 @@ export function isUnread(thread: PluginSidebarThread, context: ThreadContext): b
132
145
  export function isDoneUnseen(thread: PluginSidebarThread, context: ThreadContext): boolean {
133
146
  if (thread.parentThreadId === null) return false;
134
147
  if (normalizeStatus(thread) !== "idle") return false;
135
- if (thread.id === context.activeThreadId) return false;
148
+ if (isOpenThread(thread, context)) return false;
136
149
  const finished = context.finishedAt[thread.id];
137
150
  if (finished === undefined) return false;
138
151
  return finished > Math.max(thread.lastReadAt ?? 0, context.seenAt[thread.id] ?? 0);
@@ -234,7 +247,7 @@ export function computeState(thread: PluginSidebarThread, inputs: StateInputs):
234
247
  }
235
248
  if (inputs.unread) return { kind: "unread", label: "Unread", glyph: glyph("dot", "none"), ...none };
236
249
  if (inputs.hasDraft) return { kind: "draft", label: "Unsubmitted draft", glyph: glyph("Edit", "muted"), ...none };
237
- return { kind: "idle", label: "Idle", glyph: glyph("ring", "muted"), ...none };
250
+ return { kind: "idle", label: "Idle", glyph: glyph("ring", "muted-strong"), ...none };
238
251
  }
239
252
 
240
253
  /**
@@ -3,7 +3,7 @@
3
3
  import type { Preferences } from "@/shared/preferences";
4
4
  import { ancestorsOf, type ThreadTree, type Forest } from "./trees";
5
5
  import { isGroupCollapsed, toggleGroupCollapse } from "./groups";
6
- import { isDoneUnseen } from "./state";
6
+ import { isDoneUnseen, type ThreadContext } from "./state";
7
7
  import type { GroupView, OlderRow, SettledRow, ThreadRow } from "./view";
8
8
 
9
9
  export interface ToggleOutcome {
@@ -76,7 +76,7 @@ export interface MarkAllRead {
76
76
  /** Every unread thread in the trees, descendants included: Mark all read, and a root's Mark read for its tree. */
77
77
  export function markAllReadPlan(
78
78
  trees: readonly ThreadTree[],
79
- context: { activeThreadId: string | null; finishedAt: Readonly<Record<string, number>>; seenAt: Readonly<Record<string, number>> },
79
+ context: ThreadContext,
80
80
  ): MarkAllRead {
81
81
  const read: string[] = [];
82
82
  const seen: string[] = [];
@@ -97,7 +97,7 @@ export function markAllReadPlan(
97
97
  export function markReadPlanFor(
98
98
  threadId: string,
99
99
  forest: Pick<Forest, "infos" | "treeOf">,
100
- context: { activeThreadId: string | null; finishedAt: Readonly<Record<string, number>>; seenAt: Readonly<Record<string, number>> },
100
+ context: ThreadContext,
101
101
  ): MarkAllRead {
102
102
  const tree = forest.treeOf.get(threadId);
103
103
  if (tree !== undefined && tree.root.thread.id === threadId) return markAllReadPlan([tree], context);
@@ -13,7 +13,7 @@ import {
13
13
  type StateKind,
14
14
  type ThreadState,
15
15
  } from "./state";
16
- import { attentionFlagsOf, isOrphanedFailure } from "./attention";
16
+ import { attentionFlagsOf, orphanedAt } from "./attention";
17
17
  import { compareCreationAscending } from "./sort";
18
18
  import { needsKindOf, rowNote, type RowNote } from "./notes";
19
19
  import type { ThreadNotes } from "@/shared/contract";
@@ -40,9 +40,9 @@ export interface ThreadInfo {
40
40
  /** Quiet test for the thread alone. */
41
41
  quiet: boolean;
42
42
  /**
43
- * A quiet thread, as if no thread were open: nothing to see behind a
43
+ * A quiet thread, as if no thread were focused: nothing to see behind a
44
44
  * tree's fold. A root, or any child when `childAttention` is
45
- * `everything`, takes the quiet test without the open thread's exemption.
45
+ * `everything`, takes the quiet test without the focused thread's exemption.
46
46
  * Otherwise a child is quiet unless it runs or needs attention, so finishing
47
47
  * unread folds it; an archived child is always quiet. The fold reads this,
48
48
  * so opening a thread never changes which children stay shown.
@@ -67,7 +67,7 @@ export interface Subtree {
67
67
  visibleCount: number;
68
68
  /** Visible direct children: the chip's number. */
69
69
  childCount: number;
70
- /** Every visible descendant is quiet, as if no thread were open, so the subtree adds nothing to see when folded. */
70
+ /** Every visible descendant is quiet, as if no thread were focused, so the subtree adds nothing to see when folded. */
71
71
  quietIgnoringOpen: boolean;
72
72
  }
73
73
 
@@ -88,7 +88,7 @@ export interface ThreadTree {
88
88
  /** Every thread quiet and the active thread not in it. */
89
89
  quiet: boolean;
90
90
  /**
91
- * The quiet test without the open thread's exemption. A group's fold reads
91
+ * The quiet test without the focused thread's exemption. A group's fold reads
92
92
  * this, so opening a thread never changes which roots stay shown.
93
93
  */
94
94
  quietIgnoringOpen: boolean;
@@ -103,6 +103,8 @@ export interface Forest {
103
103
  treeOf: ReadonlyMap<string, ThreadTree>;
104
104
  /** The rollup under every thread with a row, by thread id. */
105
105
  subtrees: ReadonlyMap<string, Subtree>;
106
+ /** The earliest moment after `now` a child's failure becomes an orphaned failure, or null for none. */
107
+ nextOrphanAt: number | null;
106
108
  }
107
109
 
108
110
  export interface ForestInputs extends ThreadContext {
@@ -114,6 +116,12 @@ export interface ForestInputs extends ThreadContext {
114
116
  notes?: Readonly<Record<string, ThreadNotes>>;
115
117
  /** Which children can need attention; `blocked` when absent. */
116
118
  childAttention?: ChildAttention;
119
+ /** When this list last saw each thread go from busy to idle (see `trackIdle`). */
120
+ idleSince?: Readonly<Record<string, number>>;
121
+ /** The server's `idleAt` stamps: when any window last saw each thread go idle. */
122
+ idleAt?: Readonly<Record<string, number>>;
123
+ /** False until the stamps have loaded: no failure counts as orphaned before then. */
124
+ stampsLoaded?: boolean;
117
125
  }
118
126
 
119
127
  function isPinned(thread: PluginSidebarThread): boolean {
@@ -148,6 +156,18 @@ export function attachParent(
148
156
  return attach;
149
157
  }
150
158
 
159
+ /**
160
+ * When a thread last went idle, as this list or any window saw it; null while
161
+ * the stamps that may hold it have not loaded.
162
+ */
163
+ function idleSinceOf(id: string, inputs: ForestInputs): number | null | undefined {
164
+ if (inputs.stampsLoaded === false) return null;
165
+ const seen = inputs.idleSince?.[id];
166
+ const stamped = inputs.idleAt?.[id];
167
+ if (seen === undefined) return stamped;
168
+ return stamped === undefined ? seen : Math.max(seen, stamped);
169
+ }
170
+
151
171
  export function buildForest(inputs: ForestInputs): Forest {
152
172
  const byId = new Map(inputs.threads.map((thread) => [thread.id, thread]));
153
173
  const infos = new Map<string, ThreadInfo>();
@@ -178,14 +198,22 @@ export function buildForest(inputs: ForestInputs): Forest {
178
198
  }
179
199
 
180
200
  const mode = inputs.childAttention ?? "blocked";
201
+ let nextOrphanAt: number | null = null;
181
202
  for (const info of infos.values()) {
182
203
  const parent = info.parentId === null ? undefined : infos.get(info.parentId);
204
+ const orphaned =
205
+ parent === undefined
206
+ ? null
207
+ : orphanedAt(info.thread, info.flags, {
208
+ ...parent,
209
+ finishedAt: inputs.finishedAt[parent.thread.id],
210
+ idleSince: idleSinceOf(parent.thread.id, inputs),
211
+ });
212
+ if (orphaned !== null && orphaned > inputs.now) nextOrphanAt = Math.min(nextOrphanAt ?? orphaned, orphaned);
183
213
  info.attentionFlags = attentionFlagsOf(info.flags, {
184
214
  isRoot: parent === undefined,
185
215
  mode,
186
- orphaned:
187
- parent !== undefined &&
188
- isOrphanedFailure(info.thread, info.flags, { ...parent, finishedAt: inputs.finishedAt[parent.thread.id] }),
216
+ orphaned: orphaned !== null && orphaned <= inputs.now,
189
217
  });
190
218
  info.quietIgnoringOpen =
191
219
  parent === undefined || mode === "everything"
@@ -294,7 +322,16 @@ export function buildForest(inputs: ForestInputs): Forest {
294
322
  treeOf.set(root.thread.id, tree);
295
323
  for (const info of descendants) treeOf.set(info.thread.id, tree);
296
324
  }
297
- return { infos, children, trees, treeOf, subtrees };
325
+ return { infos, children, trees, treeOf, subtrees, nextOrphanAt };
326
+ }
327
+
328
+ /**
329
+ * Whether any thread in the trees is unread, descendants, hidden and
330
+ * archived threads included: the threads Mark all read marks, so it is
331
+ * offered only when this holds.
332
+ */
333
+ export function anyUnread(trees: readonly ThreadTree[]): boolean {
334
+ return trees.some((tree) => tree.root.unread || tree.descendants.some((info) => info.unread));
298
335
  }
299
336
 
300
337
  /** Ids from `id` up to (not including) `stopAt`, nearest first. */
@@ -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 ThreadTree, type Forest, type Subtree, type ThreadInfo } from "./trees";
9
+ import { ancestorsOf, anyUnread, type ThreadTree, type Forest, type Subtree, type ThreadInfo } from "./trees";
10
10
  import {
11
11
  builtinGroup,
12
12
  entityGroups,
@@ -113,7 +113,7 @@ export interface EnvironmentRow {
113
113
  depth: number;
114
114
  }
115
115
 
116
- /** A group's settled fold: the faint "Settled (N)" divider at its end. */
116
+ /** A group's settled fold: the muted "Settled (N)" divider at its end. */
117
117
  export interface SettledRow {
118
118
  type: "settled";
119
119
  key: string;
@@ -142,6 +142,8 @@ export interface GroupView {
142
142
  rows: Row[];
143
143
  /** Every tree root bucketed in the group, those behind folds included. */
144
144
  rootIds: string[];
145
+ /** A thread in the group's trees is unread: its menu offers Mark all read. */
146
+ hasUnread: boolean;
145
147
  }
146
148
 
147
149
  export interface ListView {
@@ -153,6 +155,8 @@ export interface ListView {
153
155
  order: string[];
154
156
  /** Thread trees that need attention, in every group, hidden ones included: the need-you filter's N. */
155
157
  needYouCount: number;
158
+ /** A thread in the list is unread, hidden groups included: the list header offers Mark all read. */
159
+ hasUnread: boolean;
156
160
  }
157
161
 
158
162
  export interface ViewInputs {
@@ -188,7 +192,7 @@ interface Context extends ViewInputs {
188
192
  /** Parent ids on the path to a reveal target. */
189
193
  revealPath: ReadonlySet<string>;
190
194
  revealIds: ReadonlySet<string>;
191
- /** The open thread and its ancestors. */
195
+ /** The focused thread and its ancestors. */
192
196
  activePath: ReadonlySet<string>;
193
197
  projectNames: ReadonlyMap<string, string>;
194
198
  sectionNames: ReadonlyMap<string, string>;
@@ -325,7 +329,7 @@ function foldedChildren(
325
329
  }
326
330
 
327
331
  // The stable set reads `quietIgnoringOpen` for the child and everything under it, and
328
- // ignores which thread is open and the reveal targets: they join afterwards
332
+ // ignores which thread is focused and the reveal targets: they join afterwards
329
333
  // and take no other row's place.
330
334
  const quietTree = (kid: ThreadInfo) => kid.quietIgnoringOpen && subtreeOf(context, kid.thread.id).quietIgnoringOpen;
331
335
  const stable = new Set<string>();
@@ -514,6 +518,7 @@ function buildGroup(context: Context, descriptor: GroupDescriptor, trees: Thread
514
518
  hidden,
515
519
  rows,
516
520
  rootIds: trees.map((tree) => tree.root.thread.id),
521
+ hasUnread: anyUnread(trees),
517
522
  };
518
523
  }
519
524
 
@@ -539,6 +544,7 @@ function needYouGroup(context: Context, descriptor: GroupDescriptor, trees: Thre
539
544
  hidden,
540
545
  rows: clusterEnvironments(context, sorted.filter(needsAttention).map((tree) => treeUnit(context, tree)), 0),
541
546
  rootIds: trees.map((tree) => tree.root.thread.id),
547
+ hasUnread: anyUnread(trees),
542
548
  };
543
549
  }
544
550
 
@@ -624,7 +630,7 @@ export function buildListView(inputs: ViewInputs): ListView {
624
630
  }
625
631
  }
626
632
 
627
- return { groups, more, moreCounters, order, needYouCount: countNeedYou(forest) };
633
+ return { groups, more, moreCounters, order, needYouCount: countNeedYou(forest), hasUnread: anyUnread(forest.trees) };
628
634
  }
629
635
 
630
636
  /** Every thread row in visual order, for keyboard and windowing. */
@@ -11,3 +11,10 @@
11
11
  [data-icon] {
12
12
  display: inline-block;
13
13
  }
14
+
15
+ /* bb's colour utilities read its palette variables; a test sets the ones it needs. */
16
+ @theme inline {
17
+ --color-sidebar: var(--sidebar);
18
+ --color-foreground: var(--foreground);
19
+ --color-muted-foreground: var(--muted-foreground);
20
+ }
@@ -109,6 +109,8 @@ export interface Scenario {
109
109
  sections?: PluginSidebarSection[];
110
110
  prefs?: Partial<Preferences>;
111
111
  activeThreadId?: string | null;
112
+ /** Threads other split panes show. */
113
+ openThreadIds?: string[];
112
114
  targets?: Targets;
113
115
  finishedAt?: Record<string, number>;
114
116
  seenAt?: Record<string, number>;
@@ -117,6 +119,10 @@ export interface Scenario {
117
119
  notes?: Record<string, import("@/shared/contract").ThreadNotes>;
118
120
  now?: number;
119
121
  startedAt?: Record<string, number>;
122
+ /** When this list saw each thread go idle, and the server's idleAt stamps. */
123
+ idleSince?: Record<string, number>;
124
+ idleAt?: Record<string, number>;
125
+ stampsLoaded?: boolean;
120
126
  /** Project id → default branch; "main" for every project when absent. */
121
127
  defaultBranches?: Record<string, string | null>;
122
128
  /** bb's primary machine; "host_1", the fixtures' own, when absent. */
@@ -131,6 +137,7 @@ export function forestOf(scenario: Scenario): Forest {
131
137
  return buildForest({
132
138
  threads: scenario.threads,
133
139
  activeThreadId: scenario.activeThreadId ?? null,
140
+ openThreadIds: new Set(scenario.openThreadIds ?? []),
134
141
  finishedAt: scenario.finishedAt ?? {},
135
142
  seenAt: scenario.seenAt ?? {},
136
143
  scheduled: scenario.scheduled ?? {},
@@ -138,6 +145,9 @@ export function forestOf(scenario: Scenario): Forest {
138
145
  notes: scenario.notes ?? {},
139
146
  childAttention: scenario.prefs?.childAttention,
140
147
  now: scenario.now ?? T0 + 60_000,
148
+ idleSince: scenario.idleSince,
149
+ idleAt: scenario.idleAt,
150
+ stampsLoaded: scenario.stampsLoaded,
141
151
  });
142
152
  }
143
153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gperezmz/bb-plugin-thread-glance",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bb": ">=0.43",
package/server/stamps.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  // Per-thread timestamps: one kv row per thread, `stamp:<threadId>`,
2
- // holding whichever of the four kinds it has. Rows are read into memory once
2
+ // holding whichever of the kinds it has. Rows are read into memory once
3
3
  // and written through, so listing does not read every row per call.
4
4
  import type { BbPluginApi } from "@get-bb/plugin-sdk";
5
5
  import { CHANNELS, type StampKind, type StampSignal, type Stamps } from "../shared/contract";
6
6
  import { createSerialQueue } from "./serial";
7
7
 
8
8
  export const STAMP_KEY_PREFIX = "stamp:";
9
- export const STAMP_KINDS: readonly StampKind[] = ["startedAt", "finishedAt", "pendingAt", "seenAt"];
9
+ export const STAMP_KINDS: readonly StampKind[] = ["startedAt", "finishedAt", "pendingAt", "seenAt", "idleAt"];
10
10
 
11
11
  export type ThreadStamps = Partial<Record<StampKind, number>>;
12
12
 
@@ -30,6 +30,12 @@ export interface StampStore {
30
30
  list(): Promise<Stamps>;
31
31
  /** Sets `kind` to `at` for each thread and publishes one signal. */
32
32
  stamp(kind: StampKind, threadIds: readonly string[], at: number): Promise<void>;
33
+ /**
34
+ * Sets `kind` to `at` for each thread whose stored value is earlier or
35
+ * absent, and publishes one signal for those. A later value is kept, so
36
+ * several windows reporting one moment cannot move it back.
37
+ */
38
+ advance(kind: StampKind, threadIds: readonly string[], at: number): Promise<void>;
33
39
  /** Deletes `kind` for each thread and publishes one signal with `value: null`. */
34
40
  clear(kind: StampKind, threadIds: readonly string[]): Promise<void>;
35
41
  /** Deletes every stamp of each thread. Publishes nothing. */
@@ -89,7 +95,7 @@ export function createStampStore(
89
95
  return {
90
96
  list: () =>
91
97
  serial(async () => {
92
- const result: Stamps = { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {} };
98
+ const result: Stamps = { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {}, idleAt: {} };
93
99
  for (const [threadId, stamps] of await load()) {
94
100
  for (const kind of STAMP_KINDS) {
95
101
  const value = stamps[kind];
@@ -107,6 +113,18 @@ export function createStampStore(
107
113
  }
108
114
  publish({ kind, threadIds: [...threadIds], value: at });
109
115
  }),
116
+ advance: (kind, threadIds, at) =>
117
+ serial(async () => {
118
+ const rows = await load();
119
+ const moved: string[] = [];
120
+ for (const threadId of new Set(threadIds)) {
121
+ const current = rows.get(threadId);
122
+ if ((current?.[kind] ?? -Infinity) >= at) continue;
123
+ await save(rows, threadId, { ...current, [kind]: at });
124
+ moved.push(threadId);
125
+ }
126
+ if (moved.length > 0) publish({ kind, threadIds: moved, value: at });
127
+ }),
110
128
  clear: (kind, threadIds) =>
111
129
  serial(async () => {
112
130
  if (threadIds.length === 0) return;
package/server.ts CHANGED
@@ -51,6 +51,11 @@ export default function threadGlance(bb: BbPluginApi): void {
51
51
  await stamps.stamp("seenAt", threadIds, at);
52
52
  return { at };
53
53
  },
54
+ async markIdle({ threadIds }) {
55
+ const at = Date.now();
56
+ await stamps.advance("idleAt", threadIds, at);
57
+ return { at };
58
+ },
54
59
  async clearSeen({ threadIds }) {
55
60
  await stamps.clear("seenAt", threadIds);
56
61
  return { ok: true as const };
@@ -56,6 +56,14 @@ export const rpcContract = defineRpcContract({
56
56
  input: z.object({ threadIds: threadIdsSchema }).strict(),
57
57
  output: z.object({ ok: z.literal(true) }).strict(),
58
58
  },
59
+ /**
60
+ * Records `idleAt` for threads a window saw go from busy to idle. A thread
61
+ * keeps the later of its stored moment and this one.
62
+ */
63
+ markIdle: {
64
+ input: z.object({ threadIds: threadIdsSchema }).strict(),
65
+ output: z.object({ at: z.number() }).strict(),
66
+ },
59
67
  /** Notes per thread id (see `noteSchema`). */
60
68
  listNotes: {
61
69
  input: z.null(),
package/shared/signals.ts CHANGED
@@ -11,6 +11,8 @@ export const stampsSchema = z.object({
11
11
  finishedAt: stampMapSchema,
12
12
  pendingAt: stampMapSchema,
13
13
  seenAt: stampMapSchema,
14
+ /** When a window saw the thread go from busy to idle, the latest such moment. */
15
+ idleAt: stampMapSchema,
14
16
  });
15
17
  export type Stamps = z.infer<typeof stampsSchema>;
16
18
  export type StampKind = keyof Stamps;
@@ -56,7 +56,7 @@ and `bb environment list`; a thread id is a `thr_…` id.
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 | `[]` |
59
- | `childAttention` | Needs attention counts every child. Which child threads need attention (collapsed groups, the need-you filter, counters, auto-reveal) and stay out of a tree's "N more child threads" fold alongside running ones: `blocked` counts a child that waits on you, is offline, or has an orphaned failure (its parent thread idle since); `everything` also counts every failed or unread child | `blocked` |
59
+ | `childAttention` | Needs attention counts every child. Which child threads need attention (collapsed groups, the need-you filter, counters, auto-reveal) and stay out of a tree's "N more child threads" fold alongside running ones: `blocked` counts a child that waits on you, is offline, or has an orphaned failure (its parent thread idle for 5 seconds since it failed); `everything` also counts every failed or unread child | `blocked` |
60
60
  | `harnessIcon` | How rows that draw a harness logo draw it: `muted` (monochrome) or `colour` (the provider's tint). A row draws one only where its harness differs from bb's default (a root) or its parent thread's (a child) | `muted` |
61
61
 
62
62
  Row density and the need-you filter are kept per browser window, and the CLI