@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
@@ -12,13 +12,13 @@ import {
12
12
  type StateKind,
13
13
  type ThreadState,
14
14
  } from "./state";
15
- import { attentionFlagsOf, isOrphanedFailure } from "./attention";
15
+ import { needsYouFlagsOf, isOrphanedFailure } from "./needs-you";
16
16
  import { compareCreationAscending } from "./sort";
17
17
  import { needsKindOf, rowNote, type RowNote } from "./notes";
18
18
  import type { ThreadNotes } from "@/shared/contract";
19
19
  import type { ChildAttention } from "@/shared/preferences";
20
20
 
21
- /** States that keep a child out of its family's fold with `childAttention` `blocked`: working, setting up, background work. */
21
+ /** States that keep a child out of its family's older fold with `childAttention` `blocked`: working, setting up, background work. */
22
22
  const RUNNING: ReadonlySet<StateKind> = new Set<StateKind>(["working", "background"]);
23
23
 
24
24
  /** Everything the list knows about one thread. */
@@ -26,24 +26,24 @@ export interface ThreadInfo {
26
26
  thread: PluginSidebarThread;
27
27
  state: ThreadState;
28
28
  unread: boolean;
29
- /** Own flags; for hidden threads only needs-you and unread-failed. */
29
+ /** Own flags; for hidden threads only waits-on-you and unread-failed. */
30
30
  flags: ReadonlySet<Flag>;
31
31
  /**
32
- * The flags of this thread that count towards Needs attention, its
32
+ * The flags of this thread that make it need you, for Needs you, the
33
33
  * counters and auto-reveal: a child counts less than a root.
34
34
  */
35
- attention: Set<Flag>;
35
+ needsYou: Set<Flag>;
36
36
  /** Quiet test for the thread alone. */
37
37
  quiet: boolean;
38
38
  /**
39
- * Nothing to see behind a family's fold. A root, or any child when
40
- * `childAttention` is `everything`: the quiet test without the open
41
- * thread's exemption. Otherwise a child is settled unless it runs or
42
- * counts towards Needs attention, so finishing unread folds it; an
43
- * archived child is always settled. The fold
44
- * reads this, so opening a thread never changes which children stay shown.
39
+ * 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
41
+ * `everything`, takes the quiet test without the open thread's exemption.
42
+ * Otherwise a child is quiet unless it runs or needs you, so finishing
43
+ * unread folds it; an archived child is always quiet. The fold reads this,
44
+ * so opening a thread never changes which children stay shown.
45
45
  */
46
- settled: boolean;
46
+ quietIgnoringOpen: boolean;
47
47
  isActive: boolean;
48
48
  /** The visible ancestor it attaches to, or null for a root. */
49
49
  parentId: string | null;
@@ -55,35 +55,35 @@ export interface ThreadInfo {
55
55
  export interface Subtree {
56
56
  /** Descendants, depth-first in creation order, hidden ones included. */
57
57
  descendants: ThreadInfo[];
58
- /** Union of the descendants' attention flags and working, archived ones left out. */
58
+ /** Union of the descendants' Needs you flags and working, archived ones left out. */
59
59
  flags: ReadonlySet<Flag>;
60
60
  /** Visible descendants: the chip's number. */
61
61
  visibleCount: number;
62
- /** Every visible descendant is settled, so the subtree adds nothing to see when folded. */
63
- settled: boolean;
62
+ /** Every visible descendant is quiet, as if no thread were open, so the subtree adds nothing to see when folded. */
63
+ quietIgnoringOpen: boolean;
64
64
  }
65
65
 
66
66
  export interface Family {
67
67
  root: ThreadInfo;
68
68
  /** Descendants, depth-first in creation order, hidden ones included. */
69
69
  descendants: ThreadInfo[];
70
- /** Union of the descendants' attention flags and working (the chip rollup). */
70
+ /** Union of the descendants' Needs you flags and working (the chip rollup). */
71
71
  descendantFlags: ReadonlySet<Flag>;
72
72
  /** The same over root and descendants (rollup, folder rows). */
73
73
  flags: ReadonlySet<Flag>;
74
- /** Attention flags over root and descendants: what the filter and the urgency order read. */
75
- attentionFlags: ReadonlySet<Flag>;
74
+ /** Needs you flags over root and descendants: what the section and its order read. */
75
+ needsYouFlags: ReadonlySet<Flag>;
76
76
  /** Visible descendants: the chip's number. */
77
77
  visibleDescendantCount: number;
78
78
  /** Largest latestAttentionAt over the family. */
79
- attention: number;
79
+ latestAttentionAt: number;
80
80
  /** Every thread quiet and the active thread not in it. */
81
81
  quiet: boolean;
82
82
  /**
83
83
  * The quiet test without the open thread's exemption. A group's fold reads
84
84
  * this, so opening a thread never changes which roots stay shown.
85
85
  */
86
- settled: boolean;
86
+ quietIgnoringOpen: boolean;
87
87
  containsActive: boolean;
88
88
  }
89
89
 
@@ -104,7 +104,7 @@ export interface ForestInputs extends ThreadContext {
104
104
  now: number;
105
105
  /** Server notes per thread id; absent until loaded. */
106
106
  notes?: Readonly<Record<string, ThreadNotes>>;
107
- /** Which children count towards Needs attention; `blocked` when absent. */
107
+ /** Which children can need you; `blocked` when absent. */
108
108
  childAttention?: ChildAttention;
109
109
  }
110
110
 
@@ -159,9 +159,9 @@ export function buildForest(inputs: ForestInputs): Forest {
159
159
  state,
160
160
  unread,
161
161
  flags: thread.isHidden ? hiddenThreadFlags(flags) : flags,
162
- attention: new Set<Flag>(),
162
+ needsYou: new Set<Flag>(),
163
163
  quiet: isQuietThread(state, unread, isActive),
164
- settled: false,
164
+ quietIgnoringOpen: false,
165
165
  isActive,
166
166
  parentId: attachParent(thread, byId),
167
167
  note: rowNote(thread, inputs.notes?.[thread.id]),
@@ -170,18 +170,18 @@ export function buildForest(inputs: ForestInputs): Forest {
170
170
 
171
171
  const mode = inputs.childAttention ?? "blocked";
172
172
  for (const info of infos.values()) {
173
- const manager = info.parentId === null ? undefined : infos.get(info.parentId);
174
- info.attention = attentionFlagsOf(info.flags, {
175
- isRoot: manager === undefined,
173
+ const parent = info.parentId === null ? undefined : infos.get(info.parentId);
174
+ info.needsYou = needsYouFlagsOf(info.flags, {
175
+ isRoot: parent === undefined,
176
176
  mode,
177
177
  orphaned:
178
- manager !== undefined &&
179
- isOrphanedFailure(info.thread, info.flags, { ...manager, finishedAt: inputs.finishedAt[manager.thread.id] }),
178
+ parent !== undefined &&
179
+ isOrphanedFailure(info.thread, info.flags, { ...parent, finishedAt: inputs.finishedAt[parent.thread.id] }),
180
180
  });
181
- info.settled =
182
- manager === undefined || mode === "everything"
181
+ info.quietIgnoringOpen =
182
+ parent === undefined || mode === "everything"
183
183
  ? isQuietThread(info.state, info.unread, false)
184
- : info.thread.isArchived || (!RUNNING.has(info.state.kind) && info.attention.size === 0);
184
+ : info.thread.isArchived || (!RUNNING.has(info.state.kind) && info.needsYou.size === 0);
185
185
  }
186
186
 
187
187
  const children = new Map<string, string[]>();
@@ -208,7 +208,7 @@ export function buildForest(inputs: ForestInputs): Forest {
208
208
  const descendants: ThreadInfo[] = [];
209
209
  const flags = new Set<Flag>();
210
210
  let visibleCount = 0;
211
- let settled = true;
211
+ let quietIgnoringOpen = true;
212
212
  path.add(id);
213
213
  for (const childId of children.get(id) ?? []) {
214
214
  if (path.has(childId)) continue;
@@ -216,17 +216,17 @@ export function buildForest(inputs: ForestInputs): Forest {
216
216
  const below = subtreeOf(childId, path);
217
217
  descendants.push(child, ...below.descendants);
218
218
  if (!child.thread.isArchived) {
219
- for (const flag of child.attention) flags.add(flag);
219
+ for (const flag of child.needsYou) flags.add(flag);
220
220
  if (child.flags.has("working")) flags.add("working");
221
221
  }
222
222
  for (const flag of below.flags) flags.add(flag);
223
- if (child.thread.isHidden ? child.attention.size > 0 : !child.settled) settled = false;
223
+ if (child.thread.isHidden ? child.needsYou.size > 0 : !child.quietIgnoringOpen) quietIgnoringOpen = false;
224
224
  if (!child.thread.isHidden) visibleCount += 1;
225
225
  visibleCount += below.visibleCount;
226
- if (!below.settled) settled = false;
226
+ if (!below.quietIgnoringOpen) quietIgnoringOpen = false;
227
227
  }
228
228
  path.delete(id);
229
- const subtree: Subtree = { descendants, flags, visibleCount, settled };
229
+ const subtree: Subtree = { descendants, flags, visibleCount, quietIgnoringOpen };
230
230
  subtrees.set(id, subtree);
231
231
  return subtree;
232
232
  };
@@ -236,27 +236,29 @@ export function buildForest(inputs: ForestInputs): Forest {
236
236
  const familyOf = new Map<string, Family>();
237
237
  for (const root of roots) {
238
238
  const { descendants, flags: descendantFlags, visibleCount: visibleDescendantCount } = subtrees.get(root.thread.id)!;
239
- let attention = root.thread.latestAttentionAt;
239
+ let latestAttentionAt = root.thread.latestAttentionAt;
240
240
  let quiet = root.quiet;
241
- let settled = isQuietThread(root.state, root.unread, false);
241
+ let quietIgnoringOpen = isQuietThread(root.state, root.unread, false);
242
242
  let containsActive = root.isActive;
243
- const attentionFlags = new Set<Flag>();
243
+ const needsYouFlags = new Set<Flag>();
244
244
  for (const info of descendants) {
245
- if (!info.thread.isArchived) for (const flag of info.attention) attentionFlags.add(flag);
246
- attention = Math.max(attention, info.thread.latestAttentionAt);
245
+ if (!info.thread.isArchived) for (const flag of info.needsYou) needsYouFlags.add(flag);
246
+ latestAttentionAt = Math.max(latestAttentionAt, info.thread.latestAttentionAt);
247
247
  if (info.isActive) containsActive = true;
248
248
  if (info.thread.isHidden) {
249
- if (info.attention.size > 0) quiet = settled = false;
249
+ if (info.needsYou.size > 0) quiet = quietIgnoringOpen = false;
250
250
  } else {
251
251
  if (!info.quiet) quiet = false;
252
- if (!isQuietThread(info.state, info.unread, false)) settled = false;
252
+ // A child reads as its own fold does, so with `blocked` an unread child
253
+ // does not keep an old family out of the group's fold.
254
+ if (!info.quietIgnoringOpen) quietIgnoringOpen = false;
253
255
  }
254
256
  }
255
257
  const flags = new Set<Flag>(descendantFlags);
256
258
  if (!root.thread.isArchived) {
257
- for (const flag of root.attention) {
259
+ for (const flag of root.needsYou) {
258
260
  flags.add(flag);
259
- attentionFlags.add(flag);
261
+ needsYouFlags.add(flag);
260
262
  }
261
263
  if (root.flags.has("working")) flags.add("working");
262
264
  }
@@ -265,11 +267,11 @@ export function buildForest(inputs: ForestInputs): Forest {
265
267
  descendants,
266
268
  descendantFlags,
267
269
  flags,
268
- attentionFlags,
270
+ needsYouFlags,
269
271
  visibleDescendantCount,
270
- attention,
272
+ latestAttentionAt,
271
273
  quiet: quiet && !containsActive,
272
- settled,
274
+ quietIgnoringOpen,
273
275
  containsActive,
274
276
  };
275
277
  families.push(family);
@@ -12,7 +12,7 @@ export function stateText(row: ThreadRow, pluginLabel: string | null): string {
12
12
  return base;
13
13
  }
14
14
 
15
- /** "Open Fix login — Working; Claude Code; child of Manager; unread". */
15
+ /** "Open Fix login — Working; Claude Code; child of Release; unread". */
16
16
  export function rowAriaLabel(
17
17
  row: ThreadRow,
18
18
  options: { providerName: string; pluginLabel: string | null; hasDraft: boolean },
@@ -20,6 +20,7 @@ export function rowAriaLabel(
20
20
  const parts = [stateText(row, options.pluginLabel), options.providerName];
21
21
  if (row.parentTitle !== null && row.depth > 0) parts.push(`child of ${row.parentTitle}`);
22
22
  if (row.crossGroupLabel !== null) parts.push(row.crossGroupLabel.toLowerCase());
23
+ if (row.homeGroupLabel !== null) parts.push(`in ${row.homeGroupLabel}`);
23
24
  if (row.hiddenBadge) parts.push("hidden thread");
24
25
  if (row.info.unread && row.info.state.kind !== "unread") parts.push("unread");
25
26
  if (options.hasDraft && row.info.state.kind !== "draft") parts.push("unsubmitted draft");
@@ -1,25 +1,20 @@
1
1
  // How far a row sits from the list's left edge. Pure, so a test can
2
2
  // call it without rendering.
3
- import type { Nesting } from "@/shared/preferences";
4
-
5
3
  /** Left padding of a root row, as in bb's list. */
6
4
  export const ROOT_INDENT = 8;
7
- /** Tree nesting keeps bb's step per level. */
8
- export const TREE_STEP = 24;
9
5
  /**
10
- * Folded nesting shows one flat level. A child's status slot starts this far
11
- * right of its parent's, so the child's title sits this far right of the
12
- * parent's title.
6
+ * A child's status slot starts this far right of its parent's, so the
7
+ * child's title sits this far right of the parent's title.
13
8
  */
14
9
  export const FOLDED_STEP = 12;
15
10
 
16
- export function rowIndent(depth: number, nesting: Nesting): number {
17
- return ROOT_INDENT + depth * (nesting === "tree" ? TREE_STEP : FOLDED_STEP);
11
+ export function rowIndent(depth: number): number {
12
+ return ROOT_INDENT + depth * FOLDED_STEP;
18
13
  }
19
14
 
20
15
  /** How far the rail sits from a row's left edge: the centre of the status slot at that level. */
21
- export function railLeft(level: number, nesting: Nesting): number {
22
- return rowIndent(level, nesting) + 8;
16
+ export function railLeft(level: number): number {
17
+ return rowIndent(level) + 8;
23
18
  }
24
19
 
25
20
  /**
@@ -0,0 +1,133 @@
1
+ // What needs you, thread by thread. The Needs you section, the header
2
+ // counters, the section's order and auto-reveal all read the set this
3
+ // builds. Pure.
4
+ import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
5
+ import type { ChildAttention } from "@/shared/preferences";
6
+ import type { Family } from "./families";
7
+ import { type Flag, type StateKind, type ThreadState } from "./state";
8
+
9
+ /** The flags that count for a root thread. Working is not one. */
10
+ export const ROOT_NEEDS_YOU: ReadonlySet<Flag> = new Set<Flag>([
11
+ "waits-on-you",
12
+ "unread-failed",
13
+ "queue-failed",
14
+ "offline",
15
+ "unread",
16
+ ]);
17
+
18
+ /** States in which a parent thread is busy and may still deal with a failed child. */
19
+ const BUSY: ReadonlySet<StateKind> = new Set<StateKind>(["working", "background", "queued", "scheduled"]);
20
+
21
+ export interface ParentThread {
22
+ thread: Pick<PluginSidebarThread, "latestAttentionAt">;
23
+ state: Pick<ThreadState, "kind">;
24
+ /** When the plugin server last saw the parent thread finish a turn, if it did. */
25
+ finishedAt?: number;
26
+ }
27
+
28
+ /** A parent thread is idle when it is not working, setting up, running background work or holding a queued message. */
29
+ export function isParentIdle(parent: ParentThread): boolean {
30
+ return !BUSY.has(parent.state.kind);
31
+ }
32
+
33
+ /** When the thread's failure happened: an error bumps `latestAttentionAt`, a failed queue only `updatedAt`. */
34
+ export function failureTime(
35
+ thread: Pick<PluginSidebarThread, "latestAttentionAt" | "updatedAt">,
36
+ flags: ReadonlySet<Flag>,
37
+ ): number {
38
+ let at = 0;
39
+ if (flags.has("unread-failed")) at = Math.max(at, thread.latestAttentionAt);
40
+ if (flags.has("queue-failed")) at = Math.max(at, thread.updatedAt);
41
+ return at;
42
+ }
43
+
44
+ /**
45
+ * An orphaned failure: the parent thread is idle and has not run or
46
+ * finished since the child failed. A busy parent thread, or one that
47
+ * finished after the failure, is taken to know about it. The parent thread's
48
+ * own clock is its last finish (`finishedAt`, or `latestAttentionAt` when it
49
+ * failed): `updatedAt` also moves when someone merely opens or renames it.
50
+ */
51
+ export function isOrphanedFailure(
52
+ thread: Pick<PluginSidebarThread, "latestAttentionAt" | "updatedAt">,
53
+ flags: ReadonlySet<Flag>,
54
+ parent: ParentThread,
55
+ ): boolean {
56
+ if (!flags.has("unread-failed") && !flags.has("queue-failed")) return false;
57
+ if (!isParentIdle(parent)) return false;
58
+ const parentActiveAt = Math.max(parent.thread.latestAttentionAt, parent.finishedAt ?? 0);
59
+ return parentActiveAt <= failureTime(thread, flags);
60
+ }
61
+
62
+ /**
63
+ * The flags of one thread that make it need you. A root keeps its own. A
64
+ * child counts when it waits on you or is offline, or for an orphaned
65
+ * failure; a finished-unread child never does. With `everything`, a child
66
+ * counts like a root.
67
+ */
68
+ export function needsYouFlagsOf(
69
+ flags: ReadonlySet<Flag>,
70
+ options: { isRoot: boolean; mode: ChildAttention; orphaned: boolean },
71
+ ): Set<Flag> {
72
+ const kept = new Set<Flag>();
73
+ if (options.isRoot || options.mode === "everything") {
74
+ for (const flag of flags) if (ROOT_NEEDS_YOU.has(flag)) kept.add(flag);
75
+ return kept;
76
+ }
77
+ if (flags.has("waits-on-you")) kept.add("waits-on-you");
78
+ if (flags.has("offline")) kept.add("offline");
79
+ if (options.orphaned) {
80
+ if (flags.has("unread-failed")) kept.add("unread-failed");
81
+ if (flags.has("queue-failed")) kept.add("queue-failed");
82
+ }
83
+ return kept;
84
+ }
85
+
86
+ /** Whether a set of Needs you flags is worth an auto-reveal: it waits on you or failed. */
87
+ export function revealsOn(needsYou: ReadonlySet<Flag>, isRoot: boolean): boolean {
88
+ if (needsYou.has("waits-on-you") || needsYou.has("unread-failed")) return true;
89
+ return !isRoot && needsYou.has("queue-failed");
90
+ }
91
+
92
+ /** What the section reads of a family. */
93
+ type SectionFamily = Pick<Family, "root" | "needsYouFlags">;
94
+
95
+ /** What Needs you remembers between renders: the family it holds, and the family that was open. */
96
+ export interface NeedsYouHold {
97
+ /** The root of the family held in the section, if any. */
98
+ heldRootId: string | null;
99
+ /** The root of the family the open thread belonged to at the last render, if any. */
100
+ openRootId: string | null;
101
+ }
102
+
103
+ export const NO_HOLD: NeedsYouHold = { heldRootId: null, openRootId: null };
104
+
105
+ /**
106
+ * Whether a family is in the Needs you section: it is the held family, or one
107
+ * of its threads needs you and none of them is open. Nothing moves while you
108
+ * are inside a family, so the open family only enters by being judged when
109
+ * opened (see `holdNeedsYou`).
110
+ */
111
+ export function inNeedsYou(family: SectionFamily, heldRootId: string | null, openRootId: string | null): boolean {
112
+ const rootId = family.root.thread.id;
113
+ if (rootId === heldRootId) return true;
114
+ return family.needsYouFlags.size > 0 && rootId !== openRootId;
115
+ }
116
+
117
+ /**
118
+ * The hold after a render. A family is judged when a thread in it is opened
119
+ * from outside it: it is held if something in it needs you then, and it stays
120
+ * held while one of its threads is open, after nothing in it needs you any
121
+ * more, until a thread outside it is opened. A family that does not need you
122
+ * when opened is not pulled in later, whatever happens in it; nor is one
123
+ * that never needed you. Opening another thread of the open family judges
124
+ * nothing again.
125
+ */
126
+ export function holdNeedsYou(previous: NeedsYouHold, openFamily: SectionFamily | undefined): NeedsYouHold {
127
+ if (openFamily === undefined) return NO_HOLD;
128
+ const openRootId = openFamily.root.thread.id;
129
+ if (openRootId === previous.openRootId) {
130
+ return { heldRootId: previous.heldRootId === openRootId ? openRootId : null, openRootId };
131
+ }
132
+ return { heldRootId: openFamily.needsYouFlags.size > 0 ? openRootId : null, openRootId };
133
+ }
@@ -18,7 +18,7 @@ const PENDING_PREFIX: Record<NeedsKind, string> = {
18
18
  input: "Needs",
19
19
  };
20
20
 
21
- /** The needs-you kind a pending note names, or null when there is none. */
21
+ /** What a pending note says the thread waits on you for, or null when there is none. */
22
22
  export function needsKindOf(notes: ThreadNotes | undefined): NeedsKind | null {
23
23
  const kind = notes?.pending?.kind;
24
24
  return kind === "question" || kind === "approval" || kind === "plan" || kind === "input" ? kind : null;
@@ -0,0 +1,54 @@
1
+ // What the settings popover's controls show and write. Pure.
2
+ import type { ChildAttention, Lifecycle, Preferences, SortField } from "@/shared/preferences";
3
+ import { effectiveDirection, effectiveSortField, naturalDirection } from "./sort";
4
+
5
+ export type ThreadsShown = "active" | "archived" | "both";
6
+
7
+ /** The Threads choice a saved lifecycle list stands for. */
8
+ export function threadsShown(lifecycles: readonly Lifecycle[]): ThreadsShown {
9
+ const active = lifecycles.includes("active");
10
+ const archived = lifecycles.includes("archived");
11
+ if (active && archived) return "both";
12
+ return archived ? "archived" : "active";
13
+ }
14
+
15
+ /** The lifecycles a Threads choice saves; never empty. */
16
+ export function lifecyclesFor(shown: ThreadsShown): Lifecycle[] {
17
+ if (shown === "both") return ["active", "archived"];
18
+ return [shown];
19
+ }
20
+
21
+ /** "Needs you counts every child" is on when children count as `everything`. */
22
+ export function countsEveryChild(childAttention: ChildAttention): boolean {
23
+ return childAttention === "everything";
24
+ }
25
+
26
+ export function childAttentionFor(everyChild: boolean): ChildAttention {
27
+ return everyChild ? "everything" : "blocked";
28
+ }
29
+
30
+ /** Choosing a field starts it in its own direction, as a saved `default` did. */
31
+ export function sortFieldPatch(field: Exclude<SortField, "none">): Partial<Preferences> {
32
+ return { chronologicalSort: field, sortDirection: naturalDirection(field) };
33
+ }
34
+
35
+ export interface SortArrow {
36
+ glyph: "↓" | "↑";
37
+ /** The order the list is in now, in words. */
38
+ label: string;
39
+ /** What pressing the arrow saves. */
40
+ patch: Partial<Preferences>;
41
+ }
42
+
43
+ /** The ↓/↑ button: ↓ descending (newest first, Z–A), ↑ ascending. */
44
+ export function sortArrow(prefs: Pick<Preferences, "chronologicalSort" | "sortDirection">): SortArrow {
45
+ const field = effectiveSortField(prefs.chronologicalSort);
46
+ const direction = effectiveDirection(field, prefs.sortDirection);
47
+ const descending = direction === "descending";
48
+ const label = field === "alpha" ? (descending ? "Z–A" : "A–Z") : descending ? "Newest first" : "Oldest first";
49
+ return {
50
+ glyph: descending ? "↓" : "↑",
51
+ label,
52
+ patch: { sortDirection: descending ? "ascending" : "descending" },
53
+ };
54
+ }
@@ -1,6 +1,6 @@
1
1
  // Structural sharing between two list views: whatever did not change keeps
2
2
  // the previous object, so memoized rows and groups skip their render. Pure.
3
- import type { GroupView, ListView, Row } from "./view";
3
+ import type { GroupView, ListView, NeedsYouView, Row } from "./view";
4
4
 
5
5
  function isPlainObject(value: unknown): value is Record<string, unknown> {
6
6
  if (value === null || typeof value !== "object") return false;
@@ -86,6 +86,11 @@ function shareGroups(previousGroups: GroupView[], all: ReadonlyMap<string, Group
86
86
  return sameItems(previousGroups, result) ? previousGroups : result;
87
87
  }
88
88
 
89
+ function shareNeedsYou(previous: NeedsYouView | null, next: NeedsYouView | null): NeedsYouView | null {
90
+ if (previous === null || next === null) return next;
91
+ return shareFields(previous, next, { rows: shareRows(previous.rows, next.rows) });
92
+ }
93
+
89
94
  /**
90
95
  * `next`, sharing every group and row that did not change since `previous`.
91
96
  * Groups match by id and rows by key, never by position. A group keeps its
@@ -95,6 +100,7 @@ export function shareView(previous: ListView | null, next: ListView): ListView {
95
100
  if (previous === null) return next;
96
101
  const all = new Map([...previous.groups, ...previous.more].map((group) => [group.descriptor.id, group]));
97
102
  return shareFields(previous, next, {
103
+ needsYou: shareNeedsYou(previous.needsYou, next.needsYou),
98
104
  groups: shareGroups(previous.groups, all, next.groups),
99
105
  more: shareGroups(previous.more, all, next.more),
100
106
  });
@@ -38,15 +38,23 @@ export function effectiveSortField(field: SortField): Exclude<SortField, "none">
38
38
  return field === "none" ? "updated" : field;
39
39
  }
40
40
 
41
+ /** A field's own direction: A–Z for names, newest first for dates. */
42
+ export function naturalDirection(field: SortField): "ascending" | "descending" {
43
+ return effectiveSortField(field) === "alpha" ? "ascending" : "descending";
44
+ }
45
+
46
+ /** The direction the list is sorted in: a saved `default` reads as the field's own. */
47
+ export function effectiveDirection(field: SortField, direction: SortDirection): "ascending" | "descending" {
48
+ return direction === "default" ? naturalDirection(field) : direction;
49
+ }
50
+
41
51
  /**
42
- * The comparator for roots in a group. Direction follows bb: `default` keeps
43
- * the field's natural direction (ascending for alpha, descending otherwise).
44
- * Working first keeps active threads on top whatever the direction.
52
+ * The comparator for roots in a group. Working first keeps active threads on
53
+ * top whatever the direction.
45
54
  */
46
55
  export function makeComparator(options: SortOptions): (a: SortKey, b: SortKey) => number {
47
56
  const field = effectiveSortField(options.field);
48
- const natural = field === "alpha" ? "ascending" : "descending";
49
- const sign = options.direction === "default" || options.direction === natural ? 1 : -1;
57
+ const sign = effectiveDirection(field, options.direction) === naturalDirection(field) ? 1 : -1;
50
58
  const base = field === "alpha" ? byAlpha : field === "created" ? byCreated : byUpdated;
51
59
  return (a, b) => {
52
60
  if (field === "updated" && options.workingFirst) {
@@ -70,7 +78,7 @@ export function comparePinned(a: PluginSidebarThread, b: PluginSidebarThread): n
70
78
  return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
71
79
  }
72
80
 
73
- /** Children in Folded nesting: creation order. */
81
+ /** Children under a chip: creation order. */
74
82
  export function compareCreationAscending(a: PluginSidebarThread, b: PluginSidebarThread): number {
75
83
  const diff = a.createdAt - b.createdAt;
76
84
  if (diff !== 0) return diff;
@@ -6,7 +6,7 @@ import type {
6
6
  } from "@get-bb/plugin-sdk/app";
7
7
 
8
8
  export type StateKind =
9
- | "needs-you"
9
+ | "waits-on-you"
10
10
  | "failed"
11
11
  | "queue-failed"
12
12
  | "offline"
@@ -19,11 +19,11 @@ export type StateKind =
19
19
  | "idle";
20
20
 
21
21
  /** A rollup flag. Set independently of the first-match state. */
22
- export type Flag = "needs-you" | "unread-failed" | "queue-failed" | "offline" | "working" | "unread";
22
+ export type Flag = "waits-on-you" | "unread-failed" | "queue-failed" | "offline" | "working" | "unread";
23
23
 
24
24
  /** Flags, most urgent first. */
25
25
  export const FLAG_ORDER: readonly Flag[] = [
26
- "needs-you",
26
+ "waits-on-you",
27
27
  "unread-failed",
28
28
  "queue-failed",
29
29
  "offline",
@@ -179,7 +179,7 @@ export function computeState(thread: PluginSidebarThread, inputs: StateInputs):
179
179
  if (thread.hasPendingInteraction) {
180
180
  const needs = NEEDS[inputs.needsKind ?? "input"] ?? NEEDS.input;
181
181
  const icon = inputs.needsKind ? needs.icon : "CircleQuestion";
182
- return { kind: "needs-you", label: needs.label, glyph: glyph(icon, "attention"), ...none };
182
+ return { kind: "waits-on-you", label: needs.label, glyph: glyph(icon, "attention"), ...none };
183
183
  }
184
184
  if (status === "error") {
185
185
  return {
@@ -236,14 +236,14 @@ export function computeState(thread: PluginSidebarThread, inputs: StateInputs):
236
236
 
237
237
  /**
238
238
  * A plugin row status replaces the glyph for every state except
239
- * needs-you, failed, and working with its plain spinner (the rule bb's list uses).
239
+ * waits-on-you, failed, and working with its plain spinner (the rule bb's list uses).
240
240
  */
241
241
  export function pluginStatusWins(
242
242
  state: ThreadState,
243
243
  rowStatus: PluginSidebarThreadRowStatus | null,
244
244
  ): boolean {
245
245
  if (rowStatus === null) return false;
246
- if (state.kind === "needs-you" || state.kind === "failed") return false;
246
+ if (state.kind === "waits-on-you" || state.kind === "failed") return false;
247
247
  if (state.kind === "working" && state.glyph.spin) return false;
248
248
  return true;
249
249
  }
@@ -251,7 +251,7 @@ export function pluginStatusWins(
251
251
  /** The rollup flags a single thread carries. */
252
252
  export function threadFlags(thread: PluginSidebarThread, unread: boolean): Set<Flag> {
253
253
  const flags = new Set<Flag>();
254
- if (thread.hasPendingInteraction) flags.add("needs-you");
254
+ if (thread.hasPendingInteraction) flags.add("waits-on-you");
255
255
  if (normalizeStatus(thread) === "error" && unread) flags.add("unread-failed");
256
256
  if (normalizeQueued(thread) === "failed") flags.add("queue-failed");
257
257
  if (isOffline(thread)) flags.add("offline");
@@ -260,10 +260,10 @@ export function threadFlags(thread: PluginSidebarThread, unread: boolean): Set<F
260
260
  return flags;
261
261
  }
262
262
 
263
- /** Flags hidden threads contribute: needs-you and unread-failed only. */
263
+ /** Flags hidden threads contribute: waits-on-you and unread-failed only. */
264
264
  export function hiddenThreadFlags(flags: ReadonlySet<Flag>): Set<Flag> {
265
265
  const kept = new Set<Flag>();
266
- if (flags.has("needs-you")) kept.add("needs-you");
266
+ if (flags.has("waits-on-you")) kept.add("waits-on-you");
267
267
  if (flags.has("unread-failed")) kept.add("unread-failed");
268
268
  return kept;
269
269
  }
@@ -279,7 +279,7 @@ export type ChipTone = "attention" | "destructive" | "working" | "neutral";
279
279
  /** The tone of a chip whose most urgent rolled-up flag is `flag`. */
280
280
  export function chipTone(flag: Flag | null): ChipTone {
281
281
  switch (flag) {
282
- case "needs-you":
282
+ case "waits-on-you":
283
283
  return "attention";
284
284
  case "unread-failed":
285
285
  case "queue-failed":
@@ -292,7 +292,7 @@ export function chipTone(flag: Flag | null): ChipTone {
292
292
  }
293
293
 
294
294
  export const FLAG_GLYPHS: Readonly<Record<Flag, Glyph & { label: string }>> = {
295
- "needs-you": { ...glyph("CircleQuestion", "attention"), label: "needs your input" },
295
+ "waits-on-you": { ...glyph("CircleQuestion", "attention"), label: "needs your input" },
296
296
  "unread-failed": { ...glyph("CircleX", "destructive"), label: "failed" },
297
297
  "queue-failed": { ...glyph("AlertTriangle", "destructive"), label: "queued message failed" },
298
298
  offline: { ...glyph("CloudOff", "attention"), label: "machine offline" },
@@ -97,7 +97,7 @@ export function stateSince(
97
97
  const at =
98
98
  kind === "working"
99
99
  ? stamps.startedAt[threadId]
100
- : kind === "needs-you"
100
+ : kind === "waits-on-you"
101
101
  ? stamps.pendingAt[threadId]
102
102
  : kind === "idle" || kind === "unread" || kind === "failed"
103
103
  ? stamps.finishedAt[threadId]