@gperezmz/bb-plugin-thread-glance 0.2.0 → 0.2.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.
Files changed (34) hide show
  1. package/README.md +2 -2
  2. package/dist/app.css +1 -1
  3. package/dist/app.js +29 -29
  4. package/dist/app.meta.json +1 -1
  5. package/dist/server.js +1 -1
  6. package/dist/server.js.map +2 -2
  7. package/dist/server.meta.json +1 -1
  8. package/features/thread-list/components/FoldRows.tsx +12 -11
  9. package/features/thread-list/components/GroupSection.tsx +131 -102
  10. package/features/thread-list/components/ThreadDetails.tsx +2 -3
  11. package/features/thread-list/components/ThreadList.tsx +10 -9
  12. package/features/thread-list/components/ThreadRowView.tsx +22 -18
  13. package/features/thread-list/components/Toolbar.tsx +1 -1
  14. package/features/thread-list/components/controller.ts +2 -0
  15. package/features/thread-list/components/glyphs.tsx +19 -1
  16. package/features/thread-list/data/{useNeedsYouHold.ts → useAttentionHold.ts} +6 -6
  17. package/features/thread-list/model/{needs-you.ts → attention.ts} +42 -21
  18. package/features/thread-list/model/counters.ts +3 -3
  19. package/features/thread-list/model/details.ts +1 -1
  20. package/features/thread-list/model/expansion.ts +10 -10
  21. package/features/thread-list/model/families.ts +29 -23
  22. package/features/thread-list/model/layout.ts +0 -43
  23. package/features/thread-list/model/notes.ts +37 -4
  24. package/features/thread-list/model/settings.ts +1 -1
  25. package/features/thread-list/model/share.ts +3 -3
  26. package/features/thread-list/model/state.ts +1 -1
  27. package/features/thread-list/model/toggles.ts +7 -5
  28. package/features/thread-list/model/view.ts +69 -47
  29. package/features/thread-list/testing/fixtures.ts +3 -3
  30. package/package.json +1 -1
  31. package/server/cli.ts +1 -1
  32. package/shared/preferences.ts +2 -2
  33. package/skills/thread-glance/SKILL.md +4 -4
  34. package/features/thread-list/components/Rails.tsx +0 -27
@@ -1,4 +1,4 @@
1
- // What needs you, thread by thread. The Needs you section, the header
1
+ // What needs attention, thread by thread. The Needs attention section, the header
2
2
  // counters, the section's order and auto-reveal all read the set this
3
3
  // builds. Pure.
4
4
  import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
@@ -7,7 +7,7 @@ import type { Family } from "./families";
7
7
  import { type Flag, type StateKind, type ThreadState } from "./state";
8
8
 
9
9
  /** The flags that count for a root thread. Working is not one. */
10
- export const ROOT_NEEDS_YOU: ReadonlySet<Flag> = new Set<Flag>([
10
+ export const ROOT_ATTENTION: ReadonlySet<Flag> = new Set<Flag>([
11
11
  "waits-on-you",
12
12
  "unread-failed",
13
13
  "queue-failed",
@@ -60,18 +60,18 @@ export function isOrphanedFailure(
60
60
  }
61
61
 
62
62
  /**
63
- * The flags of one thread that make it need you. A root keeps its own. A
63
+ * The flags of one thread that make it need attention. A root keeps its own. A
64
64
  * child counts when it waits on you or is offline, or for an orphaned
65
65
  * failure; a finished-unread child never does. With `everything`, a child
66
66
  * counts like a root.
67
67
  */
68
- export function needsYouFlagsOf(
68
+ export function attentionFlagsOf(
69
69
  flags: ReadonlySet<Flag>,
70
70
  options: { isRoot: boolean; mode: ChildAttention; orphaned: boolean },
71
71
  ): Set<Flag> {
72
72
  const kept = new Set<Flag>();
73
73
  if (options.isRoot || options.mode === "everything") {
74
- for (const flag of flags) if (ROOT_NEEDS_YOU.has(flag)) kept.add(flag);
74
+ for (const flag of flags) if (ROOT_ATTENTION.has(flag)) kept.add(flag);
75
75
  return kept;
76
76
  }
77
77
  if (flags.has("waits-on-you")) kept.add("waits-on-you");
@@ -83,30 +83,51 @@ export function needsYouFlagsOf(
83
83
  return kept;
84
84
  }
85
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");
86
+ /** Whether a set of Needs attention flags is worth an auto-reveal: it waits on you or failed. */
87
+ export function revealsOn(attention: ReadonlySet<Flag>, isRoot: boolean): boolean {
88
+ if (attention.has("waits-on-you") || attention.has("unread-failed")) return true;
89
+ return !isRoot && attention.has("queue-failed");
90
90
  }
91
91
 
92
92
  /** What the section reads of a family. */
93
- type SectionFamily = Pick<Family, "root" | "needsYouFlags">;
93
+ type SectionFamily = Pick<Family, "root" | "attentionFlags">;
94
+
95
+ /** What Needs attention remembers between renders: the family it holds, and the family that was open. */
96
+ export interface AttentionHold {
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: AttentionHold = { heldRootId: null, openRootId: null };
94
104
 
95
105
  /**
96
- * Whether a family is in the Needs you section: one of its threads needs
97
- * you, or it is the family the section holds while one of its threads is open.
106
+ * Whether a family is in the Needs attention section: it is the held family, or one
107
+ * of its threads needs attention 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 `holdAttention`).
98
110
  */
99
- export function inNeedsYou(family: SectionFamily, heldRootId: string | null): boolean {
100
- return family.needsYouFlags.size > 0 || family.root.thread.id === heldRootId;
111
+ export function inAttention(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.attentionFlags.size > 0 && rootId !== openRootId;
101
115
  }
102
116
 
103
117
  /**
104
- * The family Needs you holds after a render, by its root's id: the open
105
- * thread's family, when it needs you now or was already held. Held, it stays
106
- * in the section after nothing in it needs you, until a thread outside it is
107
- * opened; a family that never needed you is not pulled in by opening it.
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 attention then, and it stays
120
+ * held while one of its threads is open, after nothing in it needs attention any
121
+ * more, until a thread outside it is opened. A family that does not need attention
122
+ * when opened is not pulled in later, whatever happens in it; nor is one
123
+ * that never needed attention. Opening another thread of the open family judges
124
+ * nothing again.
108
125
  */
109
- export function holdNeedsYou(previous: string | null, openFamily: SectionFamily | undefined): string | null {
110
- if (openFamily === undefined) return null;
111
- return inNeedsYou(openFamily, previous) ? openFamily.root.thread.id : null;
126
+ export function holdAttention(previous: AttentionHold, openFamily: SectionFamily | undefined): AttentionHold {
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.attentionFlags.size > 0 ? openRootId : null, openRootId };
112
133
  }
@@ -13,9 +13,9 @@ export interface Counters {
13
13
  export const EMPTY_COUNTERS: Counters = { waitsOnYou: 0, failed: 0, offline: 0, working: 0, unread: 0 };
14
14
 
15
15
  /**
16
- * Counts the threads in the families. A thread counts what its Needs you
16
+ * Counts the threads in the families. A thread counts what its Needs attention
17
17
  * flags say, so a child adds to waits-on-you, failed, offline and unread only
18
- * as "Needs you counts every child" lets it; working counts every thread that
18
+ * as "Needs attention counts every child" lets it; working counts every thread that
19
19
  * runs. Hidden threads carry only waits-on-you and unread-failed flags
20
20
  * already; archived threads never count.
21
21
  */
@@ -24,7 +24,7 @@ export function countFamilies(families: readonly Family[]): Counters {
24
24
  for (const family of families) {
25
25
  for (const info of [family.root, ...family.descendants]) {
26
26
  if (info.thread.isArchived) continue;
27
- const flags = info.needsYou;
27
+ const flags = info.attentionFlags;
28
28
  if (flags.has("waits-on-you")) counters.waitsOnYou += 1;
29
29
  if (flags.has("unread-failed") || flags.has("queue-failed")) counters.failed += 1;
30
30
  if (flags.has("offline")) counters.offline += 1;
@@ -16,7 +16,7 @@ const SUMMARY_ORDER: readonly [StateKind, string][] = [
16
16
  ["idle", "idle"],
17
17
  ];
18
18
 
19
- /** "2 working, 1 need you, 3 idle": visible descendants by state. */
19
+ /** "2 working, 1 needing you, 3 idle": visible descendants by state. */
20
20
  export function childSummary(descendants: readonly ThreadInfo[]): string | null {
21
21
  const counts = new Map<StateKind, number>();
22
22
  for (const info of descendants) {
@@ -1,6 +1,6 @@
1
1
  // Auto-expansion: transitions found by diffing snapshots, and the
2
2
  // transient targets they leave behind. Pure.
3
- import { revealsOn } from "./needs-you";
3
+ import { revealsOn } from "./attention";
4
4
  import type { Forest } from "./families";
5
5
 
6
6
  /**
@@ -15,20 +15,20 @@ export type Targets = ReadonlyMap<string, TargetKind>;
15
15
  export interface Snapshot {
16
16
  activeThreadId: string | null;
17
17
  unread: ReadonlySet<string>;
18
- /** Threads that wait on you or failed, as Needs you counts them. */
19
- needsYou: ReadonlySet<string>;
18
+ /** Threads that wait on you or failed, as Needs attention counts them. */
19
+ attentionIds: ReadonlySet<string>;
20
20
  }
21
21
 
22
22
  export function snapshotOf(forest: Forest, activeThreadId: string | null): Snapshot {
23
23
  const unread = new Set<string>();
24
- const needsYou = new Set<string>();
24
+ const attentionIds = new Set<string>();
25
25
  for (const info of forest.infos.values()) {
26
26
  if (info.thread.isArchived) continue;
27
- // A finished child that does not need you never opens anything.
28
- if (info.needsYou.has("unread") && !info.thread.isHidden) unread.add(info.thread.id);
29
- if (revealsOn(info.needsYou, info.parentId === null)) needsYou.add(info.thread.id);
27
+ // A finished child that does not need attention never opens anything.
28
+ if (info.attentionFlags.has("unread") && !info.thread.isHidden) unread.add(info.thread.id);
29
+ if (revealsOn(info.attentionFlags, info.parentId === null)) attentionIds.add(info.thread.id);
30
30
  }
31
- return { activeThreadId, unread, needsYou };
31
+ return { activeThreadId, unread, attentionIds };
32
32
  }
33
33
 
34
34
  /**
@@ -40,8 +40,8 @@ export function detectTransitions(previous: Snapshot | null, next: Snapshot): Ma
40
40
  if (next.activeThreadId !== null && next.activeThreadId !== previous?.activeThreadId) {
41
41
  found.set(next.activeThreadId, "reveal");
42
42
  }
43
- for (const id of next.needsYou) {
44
- if (!previous?.needsYou.has(id)) found.set(id, "reveal");
43
+ for (const id of next.attentionIds) {
44
+ if (!previous?.attentionIds.has(id)) found.set(id, "reveal");
45
45
  }
46
46
  for (const id of next.unread) {
47
47
  if (id === next.activeThreadId || found.has(id)) continue;
@@ -12,9 +12,9 @@ import {
12
12
  type StateKind,
13
13
  type ThreadState,
14
14
  } from "./state";
15
- import { needsYouFlagsOf, isOrphanedFailure } from "./needs-you";
15
+ import { attentionFlagsOf, isOrphanedFailure } from "./attention";
16
16
  import { compareCreationAscending } from "./sort";
17
- import { needsKindOf, rowNote, type RowNote } from "./notes";
17
+ import { attentionNote, needsKindOf, rowNote, type RowNote } from "./notes";
18
18
  import type { ThreadNotes } from "@/shared/contract";
19
19
  import type { ChildAttention } from "@/shared/preferences";
20
20
 
@@ -29,17 +29,17 @@ export interface ThreadInfo {
29
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 make it need you, for Needs you, the
32
+ * The flags of this thread that make it need attention, for Needs attention, the
33
33
  * counters and auto-reveal: a child counts less than a root.
34
34
  */
35
- needsYou: Set<Flag>;
35
+ attentionFlags: Set<Flag>;
36
36
  /** Quiet test for the thread alone. */
37
37
  quiet: boolean;
38
38
  /**
39
39
  * A quiet thread, as if no thread were open: nothing to see behind a
40
40
  * family's fold. A root, or any child when `childAttention` is
41
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
42
+ * Otherwise a child is quiet unless it runs or needs attention, so finishing
43
43
  * unread folds it; an archived child is always quiet. The fold reads this,
44
44
  * so opening a thread never changes which children stay shown.
45
45
  */
@@ -47,15 +47,17 @@ export interface ThreadInfo {
47
47
  isActive: boolean;
48
48
  /** The visible ancestor it attaches to, or null for a root. */
49
49
  parentId: string | null;
50
- /** Why it needs you or failed, for the line under the row. */
50
+ /** Why it waits on you or failed, for the line under the row. */
51
51
  note: RowNote | null;
52
+ /** Why it needs attention, for the line under its row in Needs attention. */
53
+ attentionNote: RowNote | null;
52
54
  }
53
55
 
54
56
  /** What a thread's descendants add up to, for its chip and for folding. */
55
57
  export interface Subtree {
56
58
  /** Descendants, depth-first in creation order, hidden ones included. */
57
59
  descendants: ThreadInfo[];
58
- /** Union of the descendants' Needs you flags and working, archived ones left out. */
60
+ /** Union of the descendants' Needs attention flags and working, archived ones left out. */
59
61
  flags: ReadonlySet<Flag>;
60
62
  /** Visible descendants: the chip's number. */
61
63
  visibleCount: number;
@@ -67,12 +69,12 @@ export interface Family {
67
69
  root: ThreadInfo;
68
70
  /** Descendants, depth-first in creation order, hidden ones included. */
69
71
  descendants: ThreadInfo[];
70
- /** Union of the descendants' Needs you flags and working (the chip rollup). */
72
+ /** Union of the descendants' Needs attention flags and working (the chip rollup). */
71
73
  descendantFlags: ReadonlySet<Flag>;
72
74
  /** The same over root and descendants (rollup, folder rows). */
73
75
  flags: ReadonlySet<Flag>;
74
- /** Needs you flags over root and descendants: what the section and its order read. */
75
- needsYouFlags: ReadonlySet<Flag>;
76
+ /** Needs attention flags over root and descendants: what the section and its order read. */
77
+ attentionFlags: ReadonlySet<Flag>;
76
78
  /** Visible descendants: the chip's number. */
77
79
  visibleDescendantCount: number;
78
80
  /** Largest latestAttentionAt over the family. */
@@ -104,7 +106,7 @@ export interface ForestInputs extends ThreadContext {
104
106
  now: number;
105
107
  /** Server notes per thread id; absent until loaded. */
106
108
  notes?: Readonly<Record<string, ThreadNotes>>;
107
- /** Which children can need you; `blocked` when absent. */
109
+ /** Which children can need attention; `blocked` when absent. */
108
110
  childAttention?: ChildAttention;
109
111
  }
110
112
 
@@ -159,29 +161,31 @@ export function buildForest(inputs: ForestInputs): Forest {
159
161
  state,
160
162
  unread,
161
163
  flags: thread.isHidden ? hiddenThreadFlags(flags) : flags,
162
- needsYou: new Set<Flag>(),
164
+ attentionFlags: new Set<Flag>(),
163
165
  quiet: isQuietThread(state, unread, isActive),
164
166
  quietIgnoringOpen: false,
165
167
  isActive,
166
168
  parentId: attachParent(thread, byId),
167
169
  note: rowNote(thread, inputs.notes?.[thread.id]),
170
+ attentionNote: null,
168
171
  });
169
172
  }
170
173
 
171
174
  const mode = inputs.childAttention ?? "blocked";
172
175
  for (const info of infos.values()) {
173
176
  const parent = info.parentId === null ? undefined : infos.get(info.parentId);
174
- info.needsYou = needsYouFlagsOf(info.flags, {
177
+ info.attentionFlags = attentionFlagsOf(info.flags, {
175
178
  isRoot: parent === undefined,
176
179
  mode,
177
180
  orphaned:
178
181
  parent !== undefined &&
179
182
  isOrphanedFailure(info.thread, info.flags, { ...parent, finishedAt: inputs.finishedAt[parent.thread.id] }),
180
183
  });
184
+ info.attentionNote = attentionNote(info.thread, inputs.notes?.[info.thread.id], info.attentionFlags);
181
185
  info.quietIgnoringOpen =
182
186
  parent === undefined || mode === "everything"
183
187
  ? isQuietThread(info.state, info.unread, false)
184
- : info.thread.isArchived || (!RUNNING.has(info.state.kind) && info.needsYou.size === 0);
188
+ : info.thread.isArchived || (!RUNNING.has(info.state.kind) && info.attentionFlags.size === 0);
185
189
  }
186
190
 
187
191
  const children = new Map<string, string[]>();
@@ -216,11 +220,11 @@ export function buildForest(inputs: ForestInputs): Forest {
216
220
  const below = subtreeOf(childId, path);
217
221
  descendants.push(child, ...below.descendants);
218
222
  if (!child.thread.isArchived) {
219
- for (const flag of child.needsYou) flags.add(flag);
223
+ for (const flag of child.attentionFlags) flags.add(flag);
220
224
  if (child.flags.has("working")) flags.add("working");
221
225
  }
222
226
  for (const flag of below.flags) flags.add(flag);
223
- if (child.thread.isHidden ? child.needsYou.size > 0 : !child.quietIgnoringOpen) quietIgnoringOpen = false;
227
+ if (child.thread.isHidden ? child.attentionFlags.size > 0 : !child.quietIgnoringOpen) quietIgnoringOpen = false;
224
228
  if (!child.thread.isHidden) visibleCount += 1;
225
229
  visibleCount += below.visibleCount;
226
230
  if (!below.quietIgnoringOpen) quietIgnoringOpen = false;
@@ -240,23 +244,25 @@ export function buildForest(inputs: ForestInputs): Forest {
240
244
  let quiet = root.quiet;
241
245
  let quietIgnoringOpen = isQuietThread(root.state, root.unread, false);
242
246
  let containsActive = root.isActive;
243
- const needsYouFlags = new Set<Flag>();
247
+ const attentionFlags = new Set<Flag>();
244
248
  for (const info of descendants) {
245
- if (!info.thread.isArchived) for (const flag of info.needsYou) needsYouFlags.add(flag);
249
+ if (!info.thread.isArchived) for (const flag of info.attentionFlags) attentionFlags.add(flag);
246
250
  latestAttentionAt = Math.max(latestAttentionAt, info.thread.latestAttentionAt);
247
251
  if (info.isActive) containsActive = true;
248
252
  if (info.thread.isHidden) {
249
- if (info.needsYou.size > 0) quiet = quietIgnoringOpen = false;
253
+ if (info.attentionFlags.size > 0) quiet = quietIgnoringOpen = false;
250
254
  } else {
251
255
  if (!info.quiet) quiet = false;
252
- if (!isQuietThread(info.state, info.unread, false)) quietIgnoringOpen = false;
256
+ // A child reads as its own fold does, so with `blocked` an unread child
257
+ // does not keep an old family out of the group's fold.
258
+ if (!info.quietIgnoringOpen) quietIgnoringOpen = false;
253
259
  }
254
260
  }
255
261
  const flags = new Set<Flag>(descendantFlags);
256
262
  if (!root.thread.isArchived) {
257
- for (const flag of root.needsYou) {
263
+ for (const flag of root.attentionFlags) {
258
264
  flags.add(flag);
259
- needsYouFlags.add(flag);
265
+ attentionFlags.add(flag);
260
266
  }
261
267
  if (root.flags.has("working")) flags.add("working");
262
268
  }
@@ -265,7 +271,7 @@ export function buildForest(inputs: ForestInputs): Forest {
265
271
  descendants,
266
272
  descendantFlags,
267
273
  flags,
268
- needsYouFlags,
274
+ attentionFlags,
269
275
  visibleDescendantCount,
270
276
  latestAttentionAt,
271
277
  quiet: quiet && !containsActive,
@@ -11,46 +11,3 @@ export const FOLDED_STEP = 12;
11
11
  export function rowIndent(depth: number): number {
12
12
  return ROOT_INDENT + depth * FOLDED_STEP;
13
13
  }
14
-
15
- /** How far the rail sits from a row's left edge: the centre of the status slot at that level. */
16
- export function railLeft(level: number): number {
17
- return rowIndent(level) + 8;
18
- }
19
-
20
- /**
21
- * One level of guide rail on one row. `start` begins under the row's
22
- * status slot, `full` runs the row's height, `end` stops at its middle.
23
- */
24
- export type Rail = "start" | "full" | "end";
25
-
26
- /**
27
- * The rails every row draws, from the depths of the rows in order. Level `k`
28
- * runs from the row at depth `k` through the rows below it, and stops at the
29
- * last of them, so a family's rail ends on its last row, the fold row
30
- * included. Entry `k` of a row's result is its rail at level `k`, or null.
31
- */
32
- export function railsFor(depths: readonly number[]): (Rail | null)[][] {
33
- return depths.map((depth, index) => {
34
- const next = depths[index + 1];
35
- const rails: (Rail | null)[] = [];
36
- for (let level = 0; level <= depth; level += 1) {
37
- const continues = next !== undefined && next > level;
38
- if (level === depth) rails.push(continues ? "start" : null);
39
- else rails.push(continues ? "full" : "end");
40
- }
41
- return rails;
42
- });
43
- }
44
-
45
- /**
46
- * How a row's title reads. Children step back from roots: a size smaller and
47
- * muted, unless the thread is unread or open. Hover restores the colour in
48
- * the component.
49
- */
50
- export function titleTreatment(
51
- depth: number,
52
- state: { unread: boolean; active: boolean },
53
- ): { small: boolean; muted: boolean } {
54
- const child = depth > 0;
55
- return { small: child, muted: child && !state.unread && !state.active };
56
- }
@@ -2,13 +2,15 @@
2
2
  // the server stores what it saw; this decides what a row says.
3
3
  import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
4
4
  import type { Note, ThreadNotes } from "@/shared/contract";
5
- import { normalizeQueued, normalizeStatus, type NeedsKind } from "./state";
5
+ import { normalizeQueued, normalizeStatus, type Flag, type NeedsKind } from "./state";
6
6
 
7
7
  export interface RowNote {
8
- /** "Asks", "Approve", "Plan", "Failed"… */
8
+ /** "Asks", "Approve", "Plan", "Failed", "Offline", "Finished"… */
9
9
  prefix: string;
10
+ /** Empty when there is nothing to add: the prefix stands alone. */
10
11
  text: string;
11
- tone: "attention" | "destructive";
12
+ /** The tone of the row's glyph for the same reason. */
13
+ tone: "attention" | "destructive" | "muted";
12
14
  }
13
15
 
14
16
  const PENDING_PREFIX: Record<NeedsKind, string> = {
@@ -25,7 +27,7 @@ export function needsKindOf(notes: ThreadNotes | undefined): NeedsKind | null {
25
27
  }
26
28
 
27
29
  /**
28
- * The line under a row: why it needs you, or why it failed. Other states
30
+ * The line under a row: why it needs attention, or why it failed. Other states
29
31
  * have none, so the list stays one line per thread where nothing is wrong.
30
32
  */
31
33
  export function rowNote(thread: PluginSidebarThread, notes: ThreadNotes | undefined): RowNote | null {
@@ -46,6 +48,37 @@ export function rowNote(thread: PluginSidebarThread, notes: ThreadNotes | undefi
46
48
  return null;
47
49
  }
48
50
 
51
+ /**
52
+ * The line under a row in Needs attention that itself needs attention: its
53
+ * most urgent reason among `attention`, its Needs attention flags. Waits on
54
+ * you, then failed, then offline, then finished; each keeps its prefix when
55
+ * there is no text to follow it.
56
+ */
57
+ export function attentionNote(
58
+ thread: PluginSidebarThread,
59
+ notes: ThreadNotes | undefined,
60
+ attention: ReadonlySet<Flag>,
61
+ ): RowNote | null {
62
+ if (attention.has("waits-on-you")) {
63
+ return { prefix: PENDING_PREFIX[needsKindOf(notes) ?? "input"], text: notes?.pending?.text.trim() ?? "", tone: "attention" };
64
+ }
65
+ if (attention.has("unread-failed")) {
66
+ const text = notes?.failed?.text.trim() ?? "";
67
+ return { prefix: "Failed", text: text.toLowerCase() === "failed" ? "" : text, tone: "destructive" };
68
+ }
69
+ if (attention.has("queue-failed")) {
70
+ return { prefix: "Failed", text: "queued message wasn't sent", tone: "destructive" };
71
+ }
72
+ if (attention.has("offline")) return { prefix: "Offline", text: thread.host?.name.trim() ?? "", tone: "attention" };
73
+ if (attention.has("unread")) return { prefix: "Finished", text: lastReply(notes)?.text.trim() ?? "", tone: "muted" };
74
+ return null;
75
+ }
76
+
77
+ /** A note as one line of text: "Failed: timeout", or "Failed" alone. */
78
+ export function noteText(note: RowNote): string {
79
+ return note.text === "" ? note.prefix : `${note.prefix}: ${note.text}`;
80
+ }
81
+
49
82
  /** The hover card's last-reply line. */
50
83
  export function lastReply(notes: ThreadNotes | undefined): Note | null {
51
84
  return notes?.done ?? null;
@@ -18,7 +18,7 @@ export function lifecyclesFor(shown: ThreadsShown): Lifecycle[] {
18
18
  return [shown];
19
19
  }
20
20
 
21
- /** "Needs you counts every child" is on when children count as `everything`. */
21
+ /** "Needs attention counts every child" is on when children count as `everything`. */
22
22
  export function countsEveryChild(childAttention: ChildAttention): boolean {
23
23
  return childAttention === "everything";
24
24
  }
@@ -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, NeedsYouView, Row } from "./view";
3
+ import type { GroupView, ListView, AttentionView, 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,7 +86,7 @@ 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 {
89
+ function shareAttention(previous: AttentionView | null, next: AttentionView | null): AttentionView | null {
90
90
  if (previous === null || next === null) return next;
91
91
  return shareFields(previous, next, { rows: shareRows(previous.rows, next.rows) });
92
92
  }
@@ -100,7 +100,7 @@ export function shareView(previous: ListView | null, next: ListView): ListView {
100
100
  if (previous === null) return next;
101
101
  const all = new Map([...previous.groups, ...previous.more].map((group) => [group.descriptor.id, group]));
102
102
  return shareFields(previous, next, {
103
- needsYou: shareNeedsYou(previous.needsYou, next.needsYou),
103
+ attention: shareAttention(previous.attention, next.attention),
104
104
  groups: shareGroups(previous.groups, all, next.groups),
105
105
  more: shareGroups(previous.more, all, next.more),
106
106
  });
@@ -38,7 +38,7 @@ export const FLAG_ORDER: readonly Flag[] = [
38
38
  */
39
39
  export type Tone = "attention" | "destructive" | "working" | "background" | "muted" | "muted-strong" | "none";
40
40
 
41
- /** What a thread that needs you is waiting for, from its note. */
41
+ /** What a thread that needs attention is waiting for, from its note. */
42
42
  export type NeedsKind = "question" | "approval" | "plan" | "input";
43
43
 
44
44
  const NEEDS: Record<NeedsKind, { icon: string; label: string }> = {
@@ -26,14 +26,16 @@ export function toggleChip(row: ThreadRow, prefs: Preferences, forest: Forest):
26
26
  // Expanding shows every child; collapsing also drops auto-reveals.
27
27
  return expanded
28
28
  ? { patch: { expandedChildren: without(prefs.expandedChildren, id) }, drop: under(forest, id) }
29
- : { patch: { expandedChildren: [...without(prefs.expandedChildren, id), id] }, drop: null };
29
+ : openChildren(id, prefs);
30
+ }
31
+
32
+ /** Opens a parent's children, as its chip does: "+N more" in Needs attention and an auto-reveal's fold. */
33
+ export function openChildren(parentId: string, prefs: Preferences): ToggleOutcome {
34
+ return { patch: { expandedChildren: [...without(prefs.expandedChildren, parentId), parentId] }, drop: null };
30
35
  }
31
36
 
32
37
  export function toggleOlder(row: OlderRow, prefs: Preferences, group: GroupView | null, forest: Forest): ToggleOutcome {
33
- if (row.scope === "reveal") {
34
- // "+N more" expands the whole family, as the chip does.
35
- return { patch: { expandedChildren: [...without(prefs.expandedChildren, row.scopeId), row.scopeId] }, drop: null };
36
- }
38
+ if (row.scope === "reveal") return openChildren(row.scopeId, prefs);
37
39
  if (!row.expanded) {
38
40
  return { patch: { expandedOlder: [...without(prefs.expandedOlder, row.scopeId), row.scopeId] }, drop: null };
39
41
  }