@gperezmz/bb-plugin-thread-glance 0.2.2 → 0.2.3

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
  "sdkVersion": "0.5.9",
4
4
  "artifactFormatVersion": 1,
5
5
  "pluginId": "thread-glance",
6
- "pluginVersion": "0.2.2",
6
+ "pluginVersion": "0.2.3",
7
7
  "builtWith": {
8
8
  "bbVersion": "0.43.4",
9
9
  "pluginSdkVersion": "0.5.9"
@@ -423,9 +423,9 @@ const ATTENTION_COUNT =
423
423
  "bg-[color-mix(in_oklab,var(--attention)_20%,transparent)] text-[color:color-mix(in_oklab,var(--attention),var(--foreground)_55%)]";
424
424
 
425
425
  /**
426
- * The Needs attention section: a header with its family count, then its rows,
427
- * on one band of bb's attention colour. It has no collapse, menu or drag, and
428
- * no drop target of its own.
426
+ * The Needs attention section: a header with its family count (none when every
427
+ * family in it is attended), then its rows, on one band of bb's attention
428
+ * colour. It has no collapse, menu or drag, and no drop target of its own.
429
429
  */
430
430
  export const AttentionSection = memo(function AttentionSection({ view, ...rest }: SectionProps & { view: AttentionView }) {
431
431
  return (
@@ -435,12 +435,14 @@ export const AttentionSection = memo(function AttentionSection({ view, ...rest }
435
435
  className="sticky z-20 flex h-7 items-center gap-1 rounded-t-md bg-[var(--tg-surface)] pl-2 pr-1 text-xs font-medium text-muted-foreground max-md:pointer-coarse:h-9"
436
436
  >
437
437
  <span className="min-w-0 flex-1 truncate">Needs attention</span>
438
- <span
439
- className={cn("rounded-full px-1.5 text-[11px] leading-4 tabular-nums", ATTENTION_COUNT)}
440
- aria-label={`${view.familyCount} ${view.familyCount === 1 ? "family" : "families"}`}
441
- >
442
- {view.familyCount}
443
- </span>
438
+ {view.familyCount > 0 ? (
439
+ <span
440
+ className={cn("rounded-full px-1.5 text-[11px] leading-4 tabular-nums", ATTENTION_COUNT)}
441
+ aria-label={`${view.familyCount} ${view.familyCount === 1 ? "family" : "families"}`}
442
+ >
443
+ {view.familyCount}
444
+ </span>
445
+ ) : null}
444
446
  </h2>
445
447
  <Rows {...rest} rows={view.rows} groupId={ATTENTION_GROUP_ID} inPinned={false} forceMount={false} />
446
448
  </section>
@@ -160,7 +160,7 @@ function ThreadListBody({
160
160
  [ready, threads, activeThreadId, stamps.finishedAt, stamps.seenAt, draftIds, scheduled, now, notes, prefs.childAttention],
161
161
  );
162
162
  const { targets, prune } = useAutoExpand(hydrated ? forest : null, activeThreadId);
163
- const heldRootId = useAttentionHold(forest, activeThreadId);
163
+ const held = useAttentionHold(forest, activeThreadId);
164
164
  // Rows and groups that did not change keep their objects, so their
165
165
  // memoized components skip the render.
166
166
  const previousView = useRef<ListView | null>(null);
@@ -175,10 +175,10 @@ function ThreadListBody({
175
175
  sections: sidebar.sections,
176
176
  prefs,
177
177
  activeThreadId,
178
- heldRootId,
178
+ held,
179
179
  targets,
180
180
  })),
181
- [forest, threads, sidebar.projects, sidebar.sections, prefs, activeThreadId, heldRootId, targets],
181
+ [forest, threads, sidebar.projects, sidebar.sections, prefs, activeThreadId, held, targets],
182
182
  );
183
183
  useLayoutEffect(() => {
184
184
  previousView.current = view;
@@ -408,7 +408,7 @@ export const ThreadRowView = memo(function ThreadRowView({
408
408
  title={thread.displayTitle}
409
409
  className={cn(
410
410
  "min-w-0 truncate",
411
- info.unread ? "font-semibold" : "font-normal",
411
+ row.bold ? "font-semibold" : "font-normal",
412
412
  // Children sit a step below their parent.
413
413
  row.depth > 0 && "text-xs",
414
414
  // Quiet threads step back so live ones lead; hover brings them back.
@@ -1,19 +1,19 @@
1
- // Which family Needs attention holds, and which family is open, while one of its threads is open. Diffed
2
- // render to render, as auto-expansion is; nothing here is persisted.
1
+ // Which family Needs attention holds, as it was when opened, and which family is open, while one of its
2
+ // threads is open. Diffed render to render, as auto-expansion is; nothing here is persisted.
3
3
  import { useRef } from "react";
4
4
  import type { Forest } from "../model/families";
5
- import { holdAttention, NO_HOLD, type AttentionHold } from "../model/attention";
5
+ import { holdAttention, NO_HOLD, type AttentionHold, type SectionFamily } from "../model/attention";
6
6
 
7
- export function useAttentionHold(forest: Forest | null, activeThreadId: string | null): string | null {
8
- const held = useRef<AttentionHold>(NO_HOLD);
7
+ export function useAttentionHold(forest: Forest | null, activeThreadId: string | null): SectionFamily | null {
8
+ const hold = useRef<AttentionHold>(NO_HOLD);
9
9
  const lastForest = useRef<Forest | null>(null);
10
10
  const lastActive = useRef<string | null | undefined>(undefined);
11
11
  // Derived during render, so the family never leaves for a frame. Idempotent:
12
12
  // a repeated render with the same inputs keeps what it stored.
13
13
  if (forest !== null && (forest !== lastForest.current || activeThreadId !== lastActive.current)) {
14
- held.current = holdAttention(held.current, activeThreadId === null ? undefined : forest.familyOf.get(activeThreadId));
14
+ hold.current = holdAttention(hold.current, forest, activeThreadId);
15
15
  lastForest.current = forest;
16
16
  lastActive.current = activeThreadId;
17
17
  }
18
- return held.current.heldRootId;
18
+ return hold.current.held;
19
19
  }
@@ -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 { Family } from "./families";
6
+ import type { Family, Forest } 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. */
@@ -90,17 +90,20 @@ export function revealsOn(attention: ReadonlySet<Flag>, isRoot: boolean): boolea
90
90
  }
91
91
 
92
92
  /** What the section reads of a family. */
93
- type SectionFamily = Pick<Family, "root" | "attentionFlags">;
93
+ export type SectionFamily = Pick<Family, "root" | "attentionFlags" | "latestAttentionAt">;
94
94
 
95
95
  /** What Needs attention remembers between renders: the family it holds, and the family that was open. */
96
96
  export interface AttentionHold {
97
- /** The root of the family held in the section, if any. */
98
- heldRootId: string | null;
97
+ /**
98
+ * The family held in the section, if any, as it was when opened. The section
99
+ * orders it by this, so nothing that happens inside it moves it.
100
+ */
101
+ held: SectionFamily | null;
99
102
  /** The root of the family the open thread belonged to at the last render, if any. */
100
103
  openRootId: string | null;
101
104
  }
102
105
 
103
- export const NO_HOLD: AttentionHold = { heldRootId: null, openRootId: null };
106
+ export const NO_HOLD: AttentionHold = { held: null, openRootId: null };
104
107
 
105
108
  /**
106
109
  * Whether a family is in the Needs attention section: it is the held family, or one
@@ -114,20 +117,37 @@ export function inAttention(family: SectionFamily, heldRootId: string | null, op
114
117
  return family.attentionFlags.size > 0 && rootId !== openRootId;
115
118
  }
116
119
 
120
+ /**
121
+ * Whether a family is attended: held in the section with nothing in it needing
122
+ * attention any more. It keeps its place and draws and counts as in its home group.
123
+ */
124
+ export function isAttended(family: SectionFamily, heldRootId: string | null): boolean {
125
+ return family.root.thread.id === heldRootId && family.attentionFlags.size === 0;
126
+ }
127
+
117
128
  /**
118
129
  * The hold after a render. A family is judged when a thread in it is opened
119
130
  * from outside it: it is held if something in it needs attention then, and it stays
120
131
  * 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
132
+ * more, until none of its threads is open. An attended family whose open thread
133
+ * is archived counts as closed. A family that does not need attention
122
134
  * when opened is not pulled in later, whatever happens in it; nor is one
123
135
  * that never needed attention. Opening another thread of the open family judges
124
136
  * nothing again.
125
137
  */
126
- export function holdAttention(previous: AttentionHold, openFamily: SectionFamily | undefined): AttentionHold {
138
+ export function holdAttention(
139
+ previous: AttentionHold,
140
+ forest: Pick<Forest, "infos" | "familyOf">,
141
+ activeThreadId: string | null,
142
+ ): AttentionHold {
143
+ if (activeThreadId === null) return NO_HOLD;
144
+ const openFamily = forest.familyOf.get(activeThreadId);
127
145
  if (openFamily === undefined) return NO_HOLD;
128
146
  const openRootId = openFamily.root.thread.id;
129
147
  if (openRootId === previous.openRootId) {
130
- return { heldRootId: previous.heldRootId === openRootId ? openRootId : null, openRootId };
148
+ const archived = forest.infos.get(activeThreadId)?.thread.isArchived ?? false;
149
+ const held = previous.held?.root.thread.id === openRootId && !(archived && openFamily.attentionFlags.size === 0);
150
+ return held ? previous : { held: null, openRootId };
131
151
  }
132
- return { heldRootId: openFamily.attentionFlags.size > 0 ? openRootId : null, openRootId };
152
+ return { held: openFamily.attentionFlags.size > 0 ? openFamily : null, openRootId };
133
153
  }
@@ -20,7 +20,7 @@ import {
20
20
  } from "./groups";
21
21
  import { addCounters, countFamilies, EMPTY_COUNTERS, type Counters } from "./counters";
22
22
  import { comparePinned, effectiveSortField, makeComparator, type SortKey } from "./sort";
23
- import { inAttention } from "./attention";
23
+ import { inAttention, isAttended, type SectionFamily } from "./attention";
24
24
  import { mostUrgent, type Flag } from "./state";
25
25
  import type { Targets } from "./expansion";
26
26
  import type { RowNote } from "./notes";
@@ -50,9 +50,12 @@ export interface ThreadRow {
50
50
  /** Title of the thread this one attaches to, for tooltips and labels. */
51
51
  parentTitle: string | null;
52
52
  chip: Chip | null;
53
+ /** The title is bold: the thread is unread and its family is not attended. */
54
+ bold: boolean;
53
55
  /**
54
56
  * The line under the title. In Needs attention, a row that itself needs
55
- * attention says why; any other row says why it waits on you or failed.
57
+ * attention says why, and an attended family's rows have none; any other
58
+ * row says why it waits on you or failed.
56
59
  */
57
60
  note: RowNote | null;
58
61
  /** The title, and the chip with it, step back: see `isDimmed`. */
@@ -122,7 +125,7 @@ export const ATTENTION_GROUP_ID = "attention";
122
125
 
123
126
  /** The Needs attention section: every family with a thread that needs attention, or held there while one of its threads is open. */
124
127
  export interface AttentionView {
125
- /** How many families the section holds, for its header. */
128
+ /** How many families the section holds, attended ones left out, for its header. */
126
129
  familyCount: number;
127
130
  rows: Row[];
128
131
  /** The home group of each thread drawn here, by thread id: a drop on its row acts there. */
@@ -149,12 +152,13 @@ export interface ViewInputs {
149
152
  sections: readonly PluginSidebarSection[];
150
153
  prefs: Preferences;
151
154
  activeThreadId: string | null;
152
- /** The root of the family Needs attention keeps while one of its threads is open. */
153
- heldRootId: string | null;
155
+ /** The family Needs attention keeps while one of its threads is open, as it was when opened: the section orders it by this. */
156
+ held: SectionFamily | null;
154
157
  targets: Targets;
155
158
  }
156
159
 
157
160
  interface Context extends ViewInputs {
161
+ heldRootId: string | null;
158
162
  compare: (a: SortKey, b: SortKey) => number;
159
163
  expandedChildren: ReadonlySet<string>;
160
164
  expandedOlder: ReadonlySet<string>;
@@ -168,6 +172,8 @@ interface Context extends ViewInputs {
168
172
  sectionNames: ReadonlyMap<string, string>;
169
173
  /** The rows are drawn in Needs attention. */
170
174
  drawnInAttention: boolean;
175
+ /** The rows are an attended family's: they draw as in its home group, with no bold title or why line. */
176
+ attended: boolean;
171
177
  }
172
178
 
173
179
  function titleOf(context: Context, id: string | null): string | null {
@@ -219,7 +225,8 @@ function threadRow(
219
225
  nested: options.nested,
220
226
  parentTitle: titleOf(context, info.parentId),
221
227
  chip: options.chip,
222
- note: context.drawnInAttention && needsAttention ? info.attentionNote : info.note,
228
+ bold: info.unread && !context.attended,
229
+ note: context.attended ? null : context.drawnInAttention && needsAttention ? info.attentionNote : info.note,
223
230
  dimmed: isDimmed(context, info, options.chip),
224
231
  hiddenBadge: info.thread.isHidden,
225
232
  crossGroupLabel: crossGroupLabel(context, info, root),
@@ -421,7 +428,7 @@ function familyUnit(context: Context, family: Family): Unit {
421
428
  }
422
429
 
423
430
  /** Waiting on you, then failed, then offline, then unread, then the rest. */
424
- export function urgency(family: Family): number {
431
+ export function urgency(family: Pick<Family, "attentionFlags">): number {
425
432
  const flags = family.attentionFlags;
426
433
  if (flags.has("waits-on-you")) return 0;
427
434
  if (flags.has("unread-failed") || flags.has("queue-failed")) return 1;
@@ -437,7 +444,11 @@ export function urgency(family: Family): number {
437
444
  * with the root's chip, it draws as in its home group and keeps the path.
438
445
  */
439
446
  function attentionFamilyRows(groupContext: Context, family: Family, homeGroupLabel: string): Row[] {
440
- const context: Context = { ...groupContext, drawnInAttention: true };
447
+ const context: Context = {
448
+ ...groupContext,
449
+ drawnInAttention: true,
450
+ attended: isAttended(family, groupContext.heldRootId),
451
+ };
441
452
  const root = family.root;
442
453
  const onPath = new Set<string>([root.thread.id]);
443
454
  for (const info of family.descendants) {
@@ -475,20 +486,26 @@ function buildAttention(
475
486
  ): AttentionView | null {
476
487
  if (families.length === 0) return null;
477
488
  // Most urgent first, then the chosen field in its natural direction: the
478
- // sort direction orders the groups only.
489
+ // sort direction orders the groups only. The held family sorts as it was
490
+ // when opened, so nothing inside it moves it.
479
491
  const compare = makeComparator({
480
492
  field: context.prefs.chronologicalSort,
481
493
  direction: "default",
482
494
  workingFirst: context.prefs.workingFirst,
483
495
  });
484
- const sorted = [...families].sort(
485
- (a, b) =>
496
+ const slotOf = (family: Family): SectionFamily =>
497
+ context.held !== null && family.root.thread.id === context.heldRootId ? context.held : family;
498
+ const sorted = [...families].sort((familyA, familyB) => {
499
+ const a = slotOf(familyA);
500
+ const b = slotOf(familyB);
501
+ return (
486
502
  urgency(a) - urgency(b) ||
487
503
  compare(
488
504
  { thread: a.root.thread, familyAttention: a.latestAttentionAt },
489
505
  { thread: b.root.thread, familyAttention: b.latestAttentionAt },
490
- ),
491
- );
506
+ )
507
+ );
508
+ });
492
509
  const homeGroupIds: Record<string, string> = {};
493
510
  const rows = sorted.flatMap((family) => {
494
511
  const home = homeGroupOf(family);
@@ -497,7 +514,7 @@ function buildAttention(
497
514
  return familyRows;
498
515
  });
499
516
  return {
500
- familyCount: sorted.length,
517
+ familyCount: sorted.filter((family) => !isAttended(family, context.heldRootId)).length,
501
518
  rows,
502
519
  homeGroupIds,
503
520
  };
@@ -601,6 +618,7 @@ export function buildListView(inputs: ViewInputs): ListView {
601
618
  }
602
619
  const context: Context = {
603
620
  ...inputs,
621
+ heldRootId: inputs.held?.root.thread.id ?? null,
604
622
  compare: makeComparator({
605
623
  field: prefs.chronologicalSort,
606
624
  direction: prefs.sortDirection,
@@ -615,6 +633,7 @@ export function buildListView(inputs: ViewInputs): ListView {
615
633
  projectNames: new Map(inputs.projects.map((project) => [project.id, project.name])),
616
634
  sectionNames: new Map(inputs.sections.map((section) => [section.id, section.name])),
617
635
  drawnInAttention: false,
636
+ attended: false,
618
637
  };
619
638
 
620
639
  const byGroup = new Map<string, Family[]>();
@@ -630,7 +649,7 @@ export function buildListView(inputs: ViewInputs): ListView {
630
649
  list.push(family);
631
650
  byGroup.set(id, list);
632
651
  homeOf.set(family, id);
633
- if (inAttention(family, inputs.heldRootId, openRootId)) section.push(family);
652
+ if (inAttention(family, context.heldRootId, openRootId)) section.push(family);
634
653
  }
635
654
  const inSection = new Set(section);
636
655
 
@@ -8,6 +8,7 @@ import { defaultPreferences, type Preferences } from "@/shared/preferences";
8
8
  import { buildForest, type Forest } from "../model/families";
9
9
  import { buildListView, type ListView, type Row } from "../model/view";
10
10
  import type { Targets } from "../model/expansion";
11
+ import type { SectionFamily } from "../model/attention";
11
12
 
12
13
  export const T0 = 1_780_000_000_000;
13
14
 
@@ -108,7 +109,10 @@ export interface Scenario {
108
109
  sections?: PluginSidebarSection[];
109
110
  prefs?: Partial<Preferences>;
110
111
  activeThreadId?: string | null;
112
+ /** The root of the held family, held as it stands now. */
111
113
  heldRootId?: string | null;
114
+ /** The held family as it was when opened; wins over `heldRootId`. */
115
+ held?: SectionFamily | null;
112
116
  targets?: Targets;
113
117
  finishedAt?: Record<string, number>;
114
118
  seenAt?: Record<string, number>;
@@ -141,7 +145,7 @@ export function viewOf(scenario: Scenario): ListView {
141
145
  sections: scenario.sections ?? [],
142
146
  prefs: { ...defaultPreferences(), ...scenario.prefs },
143
147
  activeThreadId: scenario.activeThreadId ?? null,
144
- heldRootId: scenario.heldRootId ?? null,
148
+ held: scenario.held ?? (scenario.heldRootId == null ? null : (forest.familyOf.get(scenario.heldRootId) ?? null)),
145
149
  targets: scenario.targets ?? new Map(),
146
150
  });
147
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gperezmz/bb-plugin-thread-glance",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bb": ">=0.43",