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

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.4",
7
7
  "builtWith": {
8
8
  "bbVersion": "0.43.4",
9
9
  "pluginSdkVersion": "0.5.9"
@@ -22,7 +22,7 @@ import { ROW_ICON_BUTTON } from "./ThreadRowView";
22
22
  export const OlderRowView = memo(function OlderRowView({ row, controller }: { row: OlderRow; controller: RowController }) {
23
23
  const { label, ariaLabel } = olderRowText(row);
24
24
  // A family's fold sits where its children do, with a dots glyph in the
25
- // status slot; the group's fold keeps the chevron.
25
+ // Status column; the group's fold keeps the chevron.
26
26
  const inFamily = row.scope !== "group";
27
27
  return (
28
28
  <button
@@ -43,8 +43,6 @@ export interface GroupController {
43
43
 
44
44
  export type DropStates = ReadonlyMap<string, "valid" | "blocked" | "unchanged" | "before" | "after">;
45
45
 
46
- /** The sticky toolbar's height; headers stick below it. */
47
- export const TOOLBAR_HEIGHT = 28;
48
46
  const ROW_HEIGHT = { compact: 28, comfortable: 44 };
49
47
 
50
48
  function canRename(group: GroupView): boolean {
@@ -96,9 +94,8 @@ const GroupHeader = memo(function GroupHeader({
96
94
  role={undefined}
97
95
  tabIndex={undefined}
98
96
  data-sidebar="group-label"
99
- style={{ top: inOverflow ? 0 : TOOLBAR_HEIGHT }}
100
97
  className={cn(
101
- "group/header sticky z-20 flex h-7 items-center gap-1 rounded-md bg-sidebar pl-2 pr-1 text-xs text-muted-foreground max-md:pointer-coarse:h-9",
98
+ "group/header sticky top-0 z-20 flex h-7 items-center gap-1 rounded-md bg-sidebar pl-2 pr-1 text-xs text-muted-foreground max-md:pointer-coarse:h-9",
102
99
  dropActive && "bg-sidebar-accent",
103
100
  draggable.isDragging && "opacity-50",
104
101
  !controller.compact && !inOverflow && "select-none",
@@ -423,24 +420,25 @@ const ATTENTION_COUNT =
423
420
  "bg-[color-mix(in_oklab,var(--attention)_20%,transparent)] text-[color:color-mix(in_oklab,var(--attention),var(--foreground)_55%)]";
424
421
 
425
422
  /**
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.
423
+ * The Needs attention section: a header with its family count (none when every
424
+ * family in it is attended), then its rows, on one band of bb's attention
425
+ * colour. It has no collapse, menu or drag, and no drop target of its own.
429
426
  */
430
427
  export const AttentionSection = memo(function AttentionSection({ view, ...rest }: SectionProps & { view: AttentionView }) {
431
428
  return (
432
429
  <section aria-label="Needs attention" className={cn("relative mb-1 flex w-full min-w-0 flex-col rounded-md pb-0.5", ATTENTION_BAND)}>
433
430
  <h2
434
- style={{ top: TOOLBAR_HEIGHT }}
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"
431
+ className="sticky top-0 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
432
  >
437
433
  <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>
434
+ {view.familyCount > 0 ? (
435
+ <span
436
+ className={cn("rounded-full px-1.5 text-[11px] leading-4 tabular-nums", ATTENTION_COUNT)}
437
+ aria-label={`${view.familyCount} ${view.familyCount === 1 ? "family" : "families"}`}
438
+ >
439
+ {view.familyCount}
440
+ </span>
441
+ ) : null}
444
442
  </h2>
445
443
  <Rows {...rest} rows={view.rows} groupId={ATTENTION_GROUP_ID} inPinned={false} forceMount={false} />
446
444
  </section>
@@ -1,20 +1,21 @@
1
- // The top of the scroll area: the settings button and its popover.
2
- import { experimental_Icon as Icon } from "@get-bb/plugin-sdk/app";
3
- import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
1
+ // Thread Glance's settings, shown by its item in bb's sidebar footer.
2
+ import { experimental_Icon as Icon, type ExperimentalSidebarFooterDisclosureProps } from "@get-bb/plugin-sdk/app";
4
3
  import { cn } from "@/lib/utils";
5
4
  import type { ClientPreferences, Preferences } from "@/shared/preferences";
6
5
  import { childAttentionFor, countsEveryChild, lifecyclesFor, sortArrow, sortFieldPatch, threadsShown } from "../model/settings";
7
6
  import { effectiveSortField } from "../model/sort";
7
+ import { useClientPreferences } from "../data/useClientPreferences";
8
+ import { usePreferences } from "../data/usePreferences";
8
9
  import { ICONS } from "../icons";
9
- import { TOOLBAR_HEIGHT } from "./GroupSection";
10
- import { ROW_ICON_BUTTON } from "./ThreadRowView";
11
10
 
12
11
  // bb's own segmented controls (the Reasoning picker, the diff view toggle)
13
12
  // mark the chosen item with the state-active token and no shadow. The track
14
13
  // is a translucent tint, so the chosen item reads as raised in both themes.
15
- const SEGMENT_TRACK = "flex min-w-0 flex-1 gap-0.5 rounded-md bg-surface-recessed p-0.5";
14
+ // An item never shrinks below its text: where a line cannot hold its label
15
+ // and its control, the control wraps onto a line of its own.
16
+ const SEGMENT_TRACK = "flex flex-1 gap-0.5 rounded-md bg-surface-recessed p-0.5";
16
17
  const SEGMENT_ITEM =
17
- "flex min-w-0 flex-1 items-center justify-center whitespace-nowrap rounded-sm px-1 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring";
18
+ "flex flex-1 items-center justify-center whitespace-nowrap rounded-sm px-1 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring";
18
19
 
19
20
  function segmentClass(selected: boolean): string {
20
21
  return cn(
@@ -25,12 +26,12 @@ function segmentClass(selected: boolean): string {
25
26
  );
26
27
  }
27
28
 
28
- /** A label, then its control, on one line. */
29
+ /** A label, then its control: on one line where the panel is wide enough, else the control below. */
29
30
  function Line({ label, children }: { label: string; children: React.ReactNode }) {
30
31
  return (
31
- <div className="flex items-center gap-2 px-2 py-1">
32
- <span className="w-[5.5rem] shrink-0 text-sm">{label}</span>
33
- {children}
32
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 py-1">
33
+ <span className="min-w-[5.5rem] shrink-0 whitespace-nowrap text-sm">{label}</span>
34
+ <div className="flex flex-1 gap-2">{children}</div>
34
35
  </div>
35
36
  );
36
37
  }
@@ -104,7 +105,7 @@ function Heading({ children }: { children: React.ReactNode }) {
104
105
  return <h3 className="px-2 pb-0.5 pt-3 text-xs font-medium text-muted-foreground first:pt-0">{children}</h3>;
105
106
  }
106
107
 
107
- export function SettingsPanel({
108
+ function SettingsPanel({
108
109
  prefs,
109
110
  client,
110
111
  onPrefs,
@@ -208,31 +209,13 @@ export function SettingsPanel({
208
209
  );
209
210
  }
210
211
 
211
- /** The slim row above the list: ⚙ alone at its right. */
212
- export function Toolbar({
213
- prefs,
214
- client,
215
- onPrefs,
216
- onClient,
217
- }: {
218
- prefs: Preferences;
219
- client: ClientPreferences;
220
- onPrefs(patch: Partial<Preferences>): void;
221
- onClient(patch: Partial<ClientPreferences>): void;
222
- }) {
212
+ /** The footer item's panel, reading and writing the preferences the list reads. */
213
+ export function SettingsDisclosure(_props: ExperimentalSidebarFooterDisclosureProps) {
214
+ const { prefs, update } = usePreferences();
215
+ const [client, updateClient] = useClientPreferences();
223
216
  return (
224
- // Sticky with the group headers, so the settings stay in reach.
225
- <div style={{ height: TOOLBAR_HEIGHT }} className="sticky top-0 z-30 flex items-center justify-end bg-sidebar">
226
- <Popover>
227
- <PopoverTrigger asChild>
228
- <button type="button" aria-label="Thread Glance settings" title="List settings" className={ROW_ICON_BUTTON}>
229
- <Icon name={ICONS.settings} aria-hidden className="size-4" />
230
- </button>
231
- </PopoverTrigger>
232
- <PopoverContent align="end" className="w-80 p-2">
233
- <SettingsPanel prefs={prefs} client={client} onPrefs={onPrefs} onClient={onClient} />
234
- </PopoverContent>
235
- </Popover>
217
+ <div className="p-1">
218
+ <SettingsPanel prefs={prefs} client={client} onPrefs={update} onClient={updateClient} />
236
219
  </div>
237
220
  );
238
221
  }
@@ -54,7 +54,6 @@ import { cancelPendingCards } from "./row-card";
54
54
  import { GroupSection, AttentionSection, type DropStates, type GroupController } from "./GroupSection";
55
55
  import type { ProviderDisplay } from "./ProviderBadge";
56
56
  import { ThreadDetails } from "./ThreadDetails";
57
- import { Toolbar } from "./Toolbar";
58
57
 
59
58
  const PLUGIN_ID = "thread-glance";
60
59
 
@@ -108,7 +107,7 @@ function ThreadListBody({
108
107
  onRetry,
109
108
  }: PluginThreadListProps & { attempt: number; onRetry(): void }) {
110
109
  const { prefs, hydrated, update } = usePreferences();
111
- const [client, updateClient] = useClientPreferences();
110
+ const [client] = useClientPreferences();
112
111
  const sidebar = useSidebarThreads({ experimental_lifecycles: prefs.threadLifecycles });
113
112
  const actions = useThreadActions();
114
113
  const sdk = useSdk();
@@ -160,7 +159,7 @@ function ThreadListBody({
160
159
  [ready, threads, activeThreadId, stamps.finishedAt, stamps.seenAt, draftIds, scheduled, now, notes, prefs.childAttention],
161
160
  );
162
161
  const { targets, prune } = useAutoExpand(hydrated ? forest : null, activeThreadId);
163
- const heldRootId = useAttentionHold(forest, activeThreadId);
162
+ const held = useAttentionHold(forest, activeThreadId);
164
163
  // Rows and groups that did not change keep their objects, so their
165
164
  // memoized components skip the render.
166
165
  const previousView = useRef<ListView | null>(null);
@@ -175,10 +174,10 @@ function ThreadListBody({
175
174
  sections: sidebar.sections,
176
175
  prefs,
177
176
  activeThreadId,
178
- heldRootId,
177
+ held,
179
178
  targets,
180
179
  })),
181
- [forest, threads, sidebar.projects, sidebar.sections, prefs, activeThreadId, heldRootId, targets],
180
+ [forest, threads, sidebar.projects, sidebar.sections, prefs, activeThreadId, held, targets],
182
181
  );
183
182
  useLayoutEffect(() => {
184
183
  previousView.current = view;
@@ -679,7 +678,6 @@ function ThreadListBody({
679
678
  return (
680
679
  <ListLiveContext.Provider value={live}>
681
680
  <div className="flex w-full min-w-0 flex-col px-1.5 pb-2">
682
- <Toolbar prefs={prefs} client={client} onPrefs={update} onClient={updateClient} />
683
681
  {threads.length === 0 ? (
684
682
  // bb's own pinned New thread button covers the empty list.
685
683
  <p className="px-3 py-4 text-sm text-muted-foreground">No threads yet.</p>
@@ -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.
@@ -47,6 +47,18 @@ export function UnreadDot({ label, className }: { label?: string; className?: st
47
47
  );
48
48
  }
49
49
 
50
+ // The Idle state's ring: uncoloured, smaller than the column and thinned, so
51
+ // it reads as status yet stays fainter than the unread dot and the draft
52
+ // pencil. Screen readers skip it, so an Idle row announces what it did before.
53
+ export function IdleRing({ className }: { className?: string }) {
54
+ return (
55
+ <span
56
+ aria-hidden
57
+ className={cn("inline-block size-2.5 shrink-0 rounded-full border-[1.5px] border-current opacity-50", className)}
58
+ />
59
+ );
60
+ }
61
+
50
62
  export function GlyphIcon({
51
63
  glyph,
52
64
  label,
@@ -56,7 +68,7 @@ export function GlyphIcon({
56
68
  label?: string;
57
69
  className?: string;
58
70
  }) {
59
- if (glyph.icon === null) return null;
71
+ if (glyph.icon === "ring") return <IdleRing className={TONE_CLASS[glyph.tone]} />;
60
72
  if (glyph.icon === "dot") return <UnreadDot label={label} />;
61
73
  return (
62
74
  <Icon
@@ -0,0 +1,34 @@
1
+ // The list and the settings panel mount apart, each with its own copy of the
2
+ // preferences. A change made in one reaches the other here, in the same
3
+ // window, at once; the server and localStorage stay what other windows read.
4
+ import { useCallback, useEffect } from "react";
5
+
6
+ type Apply<Patch> = (patch: Patch) => void;
7
+
8
+ export function sameWindow<Patch>(): Set<Apply<Patch>> {
9
+ return new Set();
10
+ }
11
+
12
+ /**
13
+ * Joins this copy to `copies` for as long as it is mounted.
14
+ *
15
+ * Returns the call that hands a patch this copy made to every other copy.
16
+ */
17
+ export function useSameWindow<State extends object>(
18
+ copies: Set<Apply<Partial<State>>>,
19
+ setState: (update: (current: State) => State) => void,
20
+ ): Apply<Partial<State>> {
21
+ const apply = useCallback((patch: Partial<State>) => setState((current) => ({ ...current, ...patch })), [setState]);
22
+ useEffect(() => {
23
+ copies.add(apply);
24
+ return () => {
25
+ copies.delete(apply);
26
+ };
27
+ }, [copies, apply]);
28
+ return useCallback(
29
+ (patch: Partial<State>) => {
30
+ for (const other of copies) if (other !== apply) other(patch);
31
+ },
32
+ [copies, apply],
33
+ );
34
+ }
@@ -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
  }
@@ -5,16 +5,24 @@ import {
5
5
  parseClientPreferences,
6
6
  type ClientPreferences,
7
7
  } from "@/shared/preferences";
8
+ import { sameWindow, useSameWindow } from "./same-window";
8
9
  import { readJson, writeJson } from "./storage";
9
10
 
11
+ const copies = sameWindow<Partial<ClientPreferences>>();
12
+
10
13
  export function useClientPreferences(): [ClientPreferences, (patch: Partial<ClientPreferences>) => void] {
11
14
  const [value, setValue] = useState(() => parseClientPreferences(readJson(CLIENT_PREFERENCES_STORAGE_KEY)));
12
- const update = useCallback((patch: Partial<ClientPreferences>) => {
13
- setValue((current) => {
14
- const next = { ...current, ...patch };
15
- writeJson(CLIENT_PREFERENCES_STORAGE_KEY, next);
16
- return next;
17
- });
18
- }, []);
15
+ const tellOthers = useSameWindow(copies, setValue);
16
+ const update = useCallback(
17
+ (patch: Partial<ClientPreferences>) => {
18
+ setValue((current) => {
19
+ const next = { ...current, ...patch };
20
+ writeJson(CLIENT_PREFERENCES_STORAGE_KEY, next);
21
+ return next;
22
+ });
23
+ tellOthers(patch);
24
+ },
25
+ [tellOthers],
26
+ );
19
27
  return [value, update];
20
28
  }
@@ -14,10 +14,16 @@ import {
14
14
  type PreferenceKey,
15
15
  type Preferences,
16
16
  } from "@/shared/preferences";
17
+ import { sameWindow, useSameWindow } from "./same-window";
17
18
  import { readJson, writeJson } from "./storage";
18
19
 
19
20
  const WRITE_DEBOUNCE_MS = 150;
20
21
 
22
+ const copies = sameWindow<Partial<Preferences>>();
23
+ // Writes not yet sent, whichever copy made them: a reload or an echo in any
24
+ // copy must not put back the value one of them replaced.
25
+ const pending = new Map<PreferenceKey, unknown>();
26
+
21
27
  export interface PreferencesState {
22
28
  prefs: Preferences;
23
29
  /** The server's values have arrived (or failed; the mirror stands in). */
@@ -31,7 +37,6 @@ export function usePreferences(): PreferencesState {
31
37
  coercePreferences(readJson(PREFERENCES_MIRROR_STORAGE_KEY)),
32
38
  );
33
39
  const [hydrated, setHydrated] = useState(false);
34
- const pending = useRef(new Map<PreferenceKey, unknown>());
35
40
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
36
41
  const connection = useRealtimeConnectionState();
37
42
  const wasConnected = useRef(false);
@@ -41,7 +46,7 @@ export function usePreferences(): PreferencesState {
41
46
  const { preferences } = await rpc.call("listPreferences", null);
42
47
  const next = coercePreferences(preferences);
43
48
  // Keys with a write in flight keep the local value.
44
- for (const [key, value] of pending.current) (next as Record<string, unknown>)[key] = value;
49
+ for (const [key, value] of pending) (next as Record<string, unknown>)[key] = value;
45
50
  setPrefs(next);
46
51
  writeJson(PREFERENCES_MIRROR_STORAGE_KEY, next);
47
52
  } finally {
@@ -73,7 +78,7 @@ export function usePreferences(): PreferencesState {
73
78
 
74
79
  useRealtime(CHANNELS.preferences, (payload) => {
75
80
  const signal = payload as PreferenceSignal;
76
- if (!isPreferenceKey(signal?.key) || pending.current.has(signal.key)) return;
81
+ if (!isPreferenceKey(signal?.key) || pending.has(signal.key)) return;
77
82
  const parsed = parsePreference(signal.key, signal.value);
78
83
  if (!parsed.success) return;
79
84
  setPrefs((current) => {
@@ -84,8 +89,8 @@ export function usePreferences(): PreferencesState {
84
89
  });
85
90
 
86
91
  const flush = useCallback(() => {
87
- const writes = [...pending.current];
88
- pending.current.clear();
92
+ const writes = [...pending];
93
+ pending.clear();
89
94
  for (const [key, value] of writes) {
90
95
  rpc.call("setPreference", { key, value }).catch((error: unknown) => {
91
96
  toast.error(`Couldn't save the ${key} setting`, {
@@ -103,6 +108,9 @@ export function usePreferences(): PreferencesState {
103
108
  [flush],
104
109
  );
105
110
 
111
+ // Another copy's change arrives already mirrored and on its way to the server.
112
+ const tellOthers = useSameWindow(copies, setPrefs);
113
+
106
114
  const update = useCallback(
107
115
  (patch: Partial<Preferences>) => {
108
116
  setPrefs((current) => {
@@ -110,8 +118,9 @@ export function usePreferences(): PreferencesState {
110
118
  writeJson(PREFERENCES_MIRROR_STORAGE_KEY, next);
111
119
  return next;
112
120
  });
121
+ tellOthers(patch);
113
122
  for (const [key, value] of Object.entries(patch)) {
114
- if (isPreferenceKey(key)) pending.current.set(key, value);
123
+ if (isPreferenceKey(key)) pending.set(key, value);
115
124
  }
116
125
  if (timer.current !== null) clearTimeout(timer.current);
117
126
  timer.current = setTimeout(() => {
@@ -119,7 +128,7 @@ export function usePreferences(): PreferencesState {
119
128
  flush();
120
129
  }, WRITE_DEBOUNCE_MS);
121
130
  },
122
- [flush],
131
+ [flush, tellOthers],
123
132
  );
124
133
 
125
134
  return { prefs, hydrated, update };
@@ -24,7 +24,6 @@ export const ICONS = {
24
24
  moveUp: "ChevronUp",
25
25
  newThread: "Plus",
26
26
  newSection: "SectionAdd",
27
- settings: "Settings",
28
27
  customize: "SlidersHorizontal",
29
28
  check: "Check",
30
29
  pullRequest: "GitPullRequest",
@@ -1,2 +1,3 @@
1
1
  // The sidebar thread list feature: what the app entry may use.
2
2
  export { ThreadList } from "./components/ThreadList";
3
+ export { SettingsDisclosure } from "./components/SettingsPanel";
@@ -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
  }
@@ -1,9 +1,12 @@
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
- /** Left padding of a root row, as in bb's list. */
3
+ /**
4
+ * Left padding of a root row: its Status column starts where the group
5
+ * header's text does.
6
+ */
4
7
  export const ROOT_INDENT = 8;
5
8
  /**
6
- * A child's status slot starts this far right of its parent's, so the
9
+ * A child's Status column starts this far right of its parent's, so the
7
10
  * child's title sits this far right of the parent's title.
8
11
  */
9
12
  export const FOLDED_STEP = 12;
@@ -1,4 +1,4 @@
1
- // What the settings popover's controls show and write. Pure.
1
+ // What the settings panel's controls show and write. Pure.
2
2
  import type { ChildAttention, Lifecycle, Preferences, SortField } from "@/shared/preferences";
3
3
  import { effectiveDirection, effectiveSortField, naturalDirection } from "./sort";
4
4
 
@@ -48,9 +48,12 @@ const NEEDS: Record<NeedsKind, { icon: string; label: string }> = {
48
48
  input: { icon: "MessageQuestion", label: "Needs your input" },
49
49
  };
50
50
 
51
- /** How a glyph is drawn. `dot` is the unread dot; everything else a host icon. */
51
+ /**
52
+ * How a glyph is drawn. `dot` is the unread dot, `ring` the Idle state's
53
+ * faint ring; everything else a host icon.
54
+ */
52
55
  export interface Glyph {
53
- icon: string | "dot" | null;
56
+ icon: string | "dot" | "ring";
54
57
  tone: Tone;
55
58
  spin: boolean;
56
59
  shine: boolean;
@@ -231,7 +234,7 @@ export function computeState(thread: PluginSidebarThread, inputs: StateInputs):
231
234
  }
232
235
  if (inputs.unread) return { kind: "unread", label: "Unread", glyph: glyph("dot", "none"), ...none };
233
236
  if (inputs.hasDraft) return { kind: "draft", label: "Unsubmitted draft", glyph: glyph("Edit", "muted"), ...none };
234
- return { kind: "idle", label: "Idle", glyph: glyph(null, "none"), ...none };
237
+ return { kind: "idle", label: "Idle", glyph: glyph("ring", "muted"), ...none };
235
238
  }
236
239
 
237
240
  /**