@gperezmz/bb-plugin-thread-glance 0.4.1 → 0.4.2

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.
@@ -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,
@@ -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. */
@@ -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.4.2",
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