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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@
3
3
  "sdkVersion": "0.5.9",
4
4
  "artifactFormatVersion": 1,
5
5
  "pluginId": "thread-glance",
6
- "pluginVersion": "0.4.1",
6
+ "pluginVersion": "0.4.2",
7
7
  "builtWith": {
8
8
  "bbVersion": "0.43.4",
9
9
  "pluginSdkVersion": "0.5.9"
@@ -21,6 +21,7 @@ import { EnvironmentRowView, OlderRowView, SettledRowView } from "./FoldRows";
21
21
  import { CounterStrip } from "./glyphs";
22
22
  import { visibleCounters } from "../model/counters";
23
23
  import { RenameEditor } from "./RenameEditor";
24
+ import { HEADER_HOVER_HIDES, HEADER_HOVER_SHOWS } from "./input-modality";
24
25
  import { ROW_ICON_BUTTON, ThreadRowView } from "./ThreadRowView";
25
26
 
26
27
  export interface GroupController {
@@ -70,13 +71,13 @@ const GroupHeader = memo(function GroupHeader({
70
71
  });
71
72
  const compact = controller.compact;
72
73
  // Desktop: the counter sits flush right, and "+" and "…" fade in over its
73
- // place on hover or focus, as a row's actions fade over its age. Nothing is
74
+ // place on hover or keyboard focus, as a row's actions fade over its age. Nothing is
74
75
  // kept for them otherwise. Phones keep them in line, always shown.
75
76
  const counterFade = compact
76
77
  ? ""
77
78
  : menuOpen
78
79
  ? "opacity-0"
79
- : "group-hover/header:opacity-0 group-focus-within/header:opacity-0";
80
+ : HEADER_HOVER_HIDES;
80
81
  const actionsFade = compact
81
82
  ? ""
82
83
  : cn(
@@ -84,7 +85,7 @@ const GroupHeader = memo(function GroupHeader({
84
85
  dropActive ? "bg-sidebar-accent" : "bg-sidebar",
85
86
  menuOpen
86
87
  ? "opacity-100"
87
- : "pointer-events-none opacity-0 group-hover/header:pointer-events-auto group-hover/header:opacity-100 group-focus-within/header:pointer-events-auto group-focus-within/header:opacity-100",
88
+ : HEADER_HOVER_SHOWS,
88
89
  );
89
90
  return (
90
91
  <div
@@ -163,10 +164,12 @@ const GroupHeader = memo(function GroupHeader({
163
164
  </button>
164
165
  </DropdownMenuTrigger>
165
166
  <DropdownMenuContent align="end" className="min-w-48">
166
- <DropdownMenuItem onSelect={() => controller.onMarkAllRead(group)}>
167
- <Icon name={ICONS.markRead} aria-hidden className="size-4" />
168
- Mark all read
169
- </DropdownMenuItem>
167
+ {group.hasUnread ? (
168
+ <DropdownMenuItem onSelect={() => controller.onMarkAllRead(group)}>
169
+ <Icon name={ICONS.markRead} aria-hidden className="size-4" />
170
+ Mark all read
171
+ </DropdownMenuItem>
172
+ ) : null}
170
173
  {group.descriptor.kind !== "pinned" && group.descriptor.newThreadProjectId !== null ? (
171
174
  <DropdownMenuItem onSelect={() => controller.onNewThread(group)}>
172
175
  <Icon name={ICONS.newThread} aria-hidden className="size-4" />
@@ -22,6 +22,8 @@ export interface ListHeaderProps {
22
22
  needYouCount: number;
23
23
  needYouOnly: boolean;
24
24
  onToggleNeedYou(): void;
25
+ /** A thread in the list is unread; Mark all read is not drawn otherwise. */
26
+ hasUnread: boolean;
25
27
  onMarkAllRead(): void;
26
28
  prefs: Preferences;
27
29
  client: ClientPreferences;
@@ -34,6 +36,7 @@ export const ListHeader = memo(function ListHeader({
34
36
  needYouCount,
35
37
  needYouOnly,
36
38
  onToggleNeedYou,
39
+ hasUnread,
37
40
  onMarkAllRead,
38
41
  prefs,
39
42
  client,
@@ -61,9 +64,11 @@ export const ListHeader = memo(function ListHeader({
61
64
  {needYouCount} need you
62
65
  </button>
63
66
  ) : null}
64
- <button type="button" aria-label="Mark all read" title="Mark all read" className={ROW_ICON_BUTTON} onClick={onMarkAllRead}>
65
- <Icon name={ICONS.markRead} aria-hidden className="size-4" />
66
- </button>
67
+ {hasUnread ? (
68
+ <button type="button" aria-label="Mark all read" title="Mark all read" className={ROW_ICON_BUTTON} onClick={onMarkAllRead}>
69
+ <Icon name={ICONS.markRead} aria-hidden className="size-4" />
70
+ </button>
71
+ ) : null}
67
72
  <Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
68
73
  <PopoverTrigger asChild>
69
74
  <button type="button" aria-label="Thread Glance settings" title="Settings" className={ROW_ICON_BUTTON}>
@@ -20,6 +20,7 @@ import {
20
20
  experimental_useSidebarThreads as useSidebarThreads,
21
21
  useEnvironmentProviders,
22
22
  useSdk,
23
+ useSidebarSplitLayout,
23
24
  useSidebarThreadDraftIds,
24
25
  } from "@get-bb/plugin-sdk/app";
25
26
  import type { PluginSidebarThread, PluginThreadListProps } from "@get-bb/plugin-sdk/app";
@@ -29,6 +30,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
29
30
  import { cn } from "@/lib/utils";
30
31
  import { useAutoExpand } from "../data/useAutoExpand";
31
32
  import { useClientPreferences } from "../data/useClientPreferences";
33
+ import { useIdleSince } from "../data/useIdleSince";
32
34
  import { useNow } from "../data/useNow";
33
35
  import { usePreferences } from "../data/usePreferences";
34
36
  import { useScheduled } from "../data/useScheduled";
@@ -52,6 +54,7 @@ import { modelDisplayName } from "../model/details";
52
54
  import { groupIdForRoot } from "../model/groups";
53
55
  import { CounterStrip } from "./glyphs";
54
56
  import { cancelPendingCards } from "./row-card";
57
+ import { useInputModality } from "./input-modality";
55
58
  import { GroupSection, type DropStates, type GroupController } from "./GroupSection";
56
59
  import type { ProviderDisplay } from "./ProviderBadge";
57
60
  import { ThreadDetails } from "./ThreadDetails";
@@ -113,19 +116,31 @@ function ThreadListBody({
113
116
  const [client, updateClient] = useClientPreferences();
114
117
  // The need-you filter is per window and starts off on every load.
115
118
  const [needYouOn, setNeedYouOn] = useState(false);
119
+ const root = useRef<HTMLDivElement>(null);
120
+ useInputModality(root);
116
121
  const sidebar = useSidebarThreads({ experimental_lifecycles: prefs.showArchived ? ["active", "archived"] : ["active"] });
117
122
  const actions = useThreadActions();
118
123
  const sdk = useSdk();
119
124
  const { providers } = useProviders();
120
125
  const { providers: environmentProviders } = useEnvironmentProviders();
121
126
  const draftIds = useSidebarThreadDraftIds();
122
- const { stamps, markSeen, clearSeen } = useStamps();
127
+ const splitLayout = useSidebarSplitLayout();
128
+ // Every thread a split pane shows is open, not only the focused one.
129
+ const openThreadIds = useMemo(
130
+ () => new Set(splitLayout?.panes.flatMap((pane) => (pane.threadId === null ? [] : [pane.threadId])) ?? []),
131
+ [splitLayout],
132
+ );
133
+ const { stamps, loaded: stampsLoaded, markSeen, markIdle, clearSeen } = useStamps();
123
134
  const scheduled = useScheduled();
124
135
  const notes = useNotes();
136
+ // When the next child's failure becomes orphaned. The forest knows it, but
137
+ // the forest is built from the clock, so the clock takes it from state.
138
+ const [orphanDeadline, setOrphanDeadline] = useState<number | null>(null);
125
139
  const nextDeadline = useMemo(() => {
126
140
  const future = Object.values(scheduled).filter((at) => at > Date.now());
141
+ if (orphanDeadline !== null) future.push(orphanDeadline);
127
142
  return future.length > 0 ? Math.min(...future) : null;
128
- }, [scheduled]);
143
+ }, [scheduled, orphanDeadline]);
129
144
  const now = useNow(nextDeadline);
130
145
 
131
146
  const [editingId, setEditingId] = useState<string | null>(null);
@@ -143,6 +158,7 @@ function ThreadListBody({
143
158
  const ready = sidebar.status === "ready";
144
159
  const threads = sidebar.threads;
145
160
  const byId = useMemo(() => new Map(threads.map((thread) => [thread.id, thread])), [threads]);
161
+ const idleSince = useIdleSince(threads, markIdle);
146
162
 
147
163
  const forest = useMemo(
148
164
  () =>
@@ -150,6 +166,7 @@ function ThreadListBody({
150
166
  ? buildForest({
151
167
  threads,
152
168
  activeThreadId,
169
+ openThreadIds,
153
170
  finishedAt: stamps.finishedAt,
154
171
  seenAt: stamps.seenAt,
155
172
  draftIds,
@@ -157,10 +174,15 @@ function ThreadListBody({
157
174
  now,
158
175
  notes,
159
176
  childAttention: prefs.childAttention,
177
+ idleSince,
178
+ idleAt: stamps.idleAt,
179
+ stampsLoaded,
160
180
  })
161
181
  : null,
162
- [ready, threads, activeThreadId, stamps.finishedAt, stamps.seenAt, draftIds, scheduled, now, notes, prefs.childAttention],
182
+ [ready, threads, activeThreadId, openThreadIds, stamps.finishedAt, stamps.seenAt, stamps.idleAt, stampsLoaded, draftIds, scheduled, now, notes, prefs.childAttention, idleSince],
163
183
  );
184
+ const nextOrphanAt = forest?.nextOrphanAt ?? null;
185
+ if (nextOrphanAt !== orphanDeadline) setOrphanDeadline(nextOrphanAt);
164
186
  const { targets, prune } = useAutoExpand(hydrated ? forest : null, activeThreadId);
165
187
  // Projects with a thread on a branch: the rest need no default branch.
166
188
  const branchedProjectIds = useMemo(
@@ -273,7 +295,7 @@ function ThreadListBody({
273
295
 
274
296
  const runMenuAction = useCallback(
275
297
  (action: RowMenuAction, thread: PluginSidebarThread, sectionId?: string | null) => {
276
- const context = { activeThreadId, finishedAt: stamps.finishedAt, seenAt: stamps.seenAt };
298
+ const context = { activeThreadId, openThreadIds, finishedAt: stamps.finishedAt, seenAt: stamps.seenAt };
277
299
  const fail = (message: string) => (error: unknown) => toast.error(message, { description: describeError(error) });
278
300
  switch (action) {
279
301
  case "open-in-split":
@@ -326,7 +348,7 @@ function ThreadListBody({
326
348
  return;
327
349
  }
328
350
  },
329
- [actions, activeThreadId, clearSeen, markSeen, onNavigate, sdk, stamps.finishedAt, stamps.seenAt],
351
+ [actions, activeThreadId, openThreadIds, clearSeen, markSeen, onNavigate, sdk, stamps.finishedAt, stamps.seenAt],
330
352
  );
331
353
  const runMenuActionRef = useRef(runMenuAction);
332
354
  useLayoutEffect(() => {
@@ -335,9 +357,9 @@ function ThreadListBody({
335
357
 
336
358
  // The committed state for the controllers' callbacks: they read it when
337
359
  // called, so the controllers themselves stay put while threads change.
338
- const latest = useRef({ forest, view, prefs, byId, activeThreadId, stamps });
360
+ const latest = useRef({ forest, view, prefs, byId, activeThreadId, openThreadIds, stamps });
339
361
  useLayoutEffect(() => {
340
- latest.current = { forest, view, prefs, byId, activeThreadId, stamps };
362
+ latest.current = { forest, view, prefs, byId, activeThreadId, openThreadIds, stamps };
341
363
  });
342
364
 
343
365
  const loadModel = useCallback(
@@ -451,15 +473,13 @@ function ThreadListBody({
451
473
  /** Marks every unread thread in the trees read, asking first above MARK_ALL_CONFIRM_ABOVE. `where` names them. */
452
474
  const markTreesRead = useCallback(
453
475
  (trees: readonly ThreadTree[], where: string) => {
454
- const { activeThreadId, stamps } = latest.current;
455
- const plan = markAllReadPlan(trees, { activeThreadId, finishedAt: stamps.finishedAt, seenAt: stamps.seenAt });
476
+ const { activeThreadId, openThreadIds, stamps } = latest.current;
477
+ const plan = markAllReadPlan(trees, { activeThreadId, openThreadIds, finishedAt: stamps.finishedAt, seenAt: stamps.seenAt });
456
478
  const run = () => {
457
479
  if (plan.seen.length > 0) markSeen(plan.seen);
458
480
  for (const id of plan.read) actions.setRead(id, true).catch(() => undefined);
459
481
  };
460
- if (plan.read.length === 0) {
461
- toast(`Nothing unread in ${where}`);
462
- } else if (plan.read.length > MARK_ALL_CONFIRM_ABOVE) {
482
+ if (plan.read.length > MARK_ALL_CONFIRM_ABOVE) {
463
483
  setConfirm({
464
484
  title: `Mark ${plan.read.length} threads read?`,
465
485
  description: `Every unread thread in ${where}, child threads included, will be marked read.`,
@@ -703,12 +723,13 @@ function ThreadListBody({
703
723
 
704
724
  return (
705
725
  <ListLiveContext.Provider value={live}>
706
- <div className="flex w-full min-w-0 flex-col px-1.5 pb-2">
726
+ <div ref={root} className="flex w-full min-w-0 flex-col px-1.5 pb-2">
707
727
  <ListHeader
708
728
  mode={prefs.organizationMode}
709
729
  needYouCount={view.needYouCount}
710
730
  needYouOnly={needYouOn}
711
731
  onToggleNeedYou={onToggleNeedYou}
732
+ hasUnread={view.hasUnread}
712
733
  onMarkAllRead={onMarkListRead}
713
734
  prefs={prefs}
714
735
  client={client}
@@ -32,6 +32,7 @@ import { RowContextMenuContent, RowDropdownMenuContent, type ContextMenuInput }
32
32
  import { SplitMiniMap, type MiniMapPane } from "./SplitMiniMap";
33
33
  import { ThreadDetails } from "./ThreadDetails";
34
34
  import { useRowCard } from "./row-card";
35
+ import { ROW_HOVER_HIDES, ROW_HOVER_LAYS_OUT, ROW_HOVER_SHOWS } from "./input-modality";
35
36
 
36
37
  /** Two clicks on one row within this window start a rename, as in bb. */
37
38
  const RENAME_CLICK_MS = 400;
@@ -88,7 +89,7 @@ export interface ThreadRowViewProps {
88
89
  inPinned: boolean;
89
90
  /** Drop feedback from the list's drag state. */
90
91
  dropState: "valid" | "blocked" | "unchanged" | "before" | "after" | null;
91
- /** The open thread is this one. */
92
+ /** The focused thread is this one. */
92
93
  active: boolean;
93
94
  /** Its title is being renamed. */
94
95
  editing: boolean;
@@ -309,7 +310,7 @@ export const ThreadRowView = memo(function ThreadRowView({
309
310
  ? ""
310
311
  : menuShowing
311
312
  ? "opacity-0"
312
- : "group-hover/row:opacity-0 group-focus-within/row:opacity-0";
313
+ : ROW_HOVER_HIDES;
313
314
 
314
315
  const body = (
315
316
  <div
@@ -471,7 +472,7 @@ export const ThreadRowView = memo(function ThreadRowView({
471
472
  <span
472
473
  className={cn(
473
474
  "items-center justify-self-end gap-0.5 pl-1.5 [grid-area:1/1]",
474
- menuShowing ? "flex" : "hidden group-hover/row:flex group-focus-within/row:flex",
475
+ menuShowing ? "flex" : ROW_HOVER_LAYS_OUT,
475
476
  )}
476
477
  >
477
478
  {row.treeUnread ? (
@@ -577,7 +578,7 @@ export const ThreadRowView = memo(function ThreadRowView({
577
578
  ? "relative"
578
579
  : menuShowing
579
580
  ? "opacity-100"
580
- : "opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100",
581
+ : ROW_HOVER_SHOWS,
581
582
  )}
582
583
  >
583
584
  <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
@@ -54,7 +54,7 @@ export interface ListLive {
54
54
 
55
55
  export const ListLiveContext = createContext<ListLive>({
56
56
  now: 0,
57
- stamps: { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {} },
57
+ stamps: { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {}, idleAt: {} },
58
58
  notes: {},
59
59
  treeOf: () => undefined,
60
60
  });
@@ -0,0 +1,51 @@
1
+ // Which input moved focus last, keyboard or pointer, written on the list's
2
+ // root as `data-input-modality`. Rows and group headers show their hover
3
+ // actions for focus inside them only under `keyboard`, so a click that leaves
4
+ // focus in a row does not keep its hover look once the pointer leaves.
5
+ // `:focus-visible` says the same, but Chromium drops focus on Tab from a row's
6
+ // link when the actions it would reach are shown by `:has(:focus-visible)`.
7
+ import { useEffect, type RefObject } from "react";
8
+
9
+ export type InputModality = "keyboard" | "pointer";
10
+
11
+ /** The modality an event starts, or null for one that changes nothing. */
12
+ export function modalityOf(event: { type: string; key?: string; metaKey?: boolean; ctrlKey?: boolean; altKey?: boolean }): InputModality | null {
13
+ if (event.type === "pointerdown") return "pointer";
14
+ if (event.type !== "keydown") return null;
15
+ // A shortcut or a lone modifier does not move focus, as Chromium's own
16
+ // `:focus-visible` treats them.
17
+ if (event.metaKey || event.ctrlKey || event.altKey) return null;
18
+ if (event.key === "Shift" || event.key === "Meta" || event.key === "Control" || event.key === "Alt") return null;
19
+ return "keyboard";
20
+ }
21
+
22
+ // What a row or group header shows while hovered or holding keyboard focus,
23
+ // as whole class strings so Tailwind finds each one in the source.
24
+
25
+ /** A row part that fades out on hover, for the actions that take its place. */
26
+ export const ROW_HOVER_HIDES = "group-hover/row:opacity-0 in-data-[input-modality=keyboard]:group-focus-within/row:opacity-0";
27
+ /** A row part that fades in on hover. */
28
+ export const ROW_HOVER_SHOWS = "opacity-0 group-hover/row:opacity-100 in-data-[input-modality=keyboard]:group-focus-within/row:opacity-100";
29
+ /** A row part laid out only on hover, so it takes no width at rest. */
30
+ export const ROW_HOVER_LAYS_OUT = "hidden group-hover/row:flex in-data-[input-modality=keyboard]:group-focus-within/row:flex";
31
+ /** A group header part that fades out on hover. */
32
+ export const HEADER_HOVER_HIDES = "group-hover/header:opacity-0 in-data-[input-modality=keyboard]:group-focus-within/header:opacity-0";
33
+ /** A group header part that fades in on hover, and takes the pointer only then. */
34
+ export const HEADER_HOVER_SHOWS =
35
+ "pointer-events-none opacity-0 group-hover/header:pointer-events-auto group-hover/header:opacity-100 in-data-[input-modality=keyboard]:group-focus-within/header:pointer-events-auto in-data-[input-modality=keyboard]:group-focus-within/header:opacity-100";
36
+
37
+ /** Keeps `data-input-modality` on the element current for the whole document. */
38
+ export function useInputModality(root: RefObject<HTMLElement | null>): void {
39
+ useEffect(() => {
40
+ const onInput = (event: Event) => {
41
+ const modality = modalityOf(event as KeyboardEvent);
42
+ if (modality !== null) root.current?.setAttribute("data-input-modality", modality);
43
+ };
44
+ document.addEventListener("keydown", onInput, true);
45
+ document.addEventListener("pointerdown", onInput, true);
46
+ return () => {
47
+ document.removeEventListener("keydown", onInput, true);
48
+ document.removeEventListener("pointerdown", onInput, true);
49
+ };
50
+ }, [root]);
51
+ }
@@ -80,7 +80,7 @@ export function useRowCard(isActive: boolean, enabled: boolean): RowCard {
80
80
  };
81
81
  }, []);
82
82
 
83
- // Becoming or ceasing to be the open thread, or a menu or rename taking
83
+ // Becoming or ceasing to be the focused thread, or a menu or rename taking
84
84
  // the row, closes the card and drops a pending open.
85
85
  useEffect(() => {
86
86
  cancel();
@@ -0,0 +1,27 @@
1
+ // When each thread last went from busy to idle, as this list saw it: the
2
+ // orphaned-failure wait counts from it. Each change seen is also written to
3
+ // the server's `idleAt` stamp, so a reload or another window keeps it.
4
+ import { useEffect, useState } from "react";
5
+ import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
6
+ import { trackIdle, type IdleTracker } from "../model/attention";
7
+
8
+ const NONE: Readonly<Record<string, number>> = {};
9
+
10
+ export function useIdleSince(
11
+ threads: readonly PluginSidebarThread[],
12
+ markIdle: (threadIds: string[]) => void,
13
+ ): Readonly<Record<string, number>> {
14
+ const [seen, setSeen] = useState<readonly PluginSidebarThread[] | null>(null);
15
+ const [tracker, setTracker] = useState<IdleTracker | null>(null);
16
+ // Adjusted during render, as React adjusts state from a changed input, so
17
+ // the render that sees the change already carries its time.
18
+ if (threads !== seen) {
19
+ const at = Date.now();
20
+ setSeen(threads);
21
+ setTracker((previous) => trackIdle(previous, threads, at));
22
+ }
23
+ useEffect(() => {
24
+ if (tracker !== null && tracker.wentIdle.length > 0) markIdle([...tracker.wentIdle]);
25
+ }, [tracker, markIdle]);
26
+ return tracker?.idleSince ?? NONE;
27
+ }
@@ -1,5 +1,6 @@
1
1
  // A clock that ticks each minute (row ages) and at the next deadline
2
- // (scheduled → queued), so rows change without a request.
2
+ // (scheduled → queued, a failure becoming orphaned), so rows change without
3
+ // a request. A deadline already past ticks at once.
3
4
  import { useEffect, useState } from "react";
4
5
 
5
6
  export function useNow(nextDeadline: number | null): number {
@@ -11,8 +12,8 @@ export function useNow(nextDeadline: number | null): number {
11
12
  useEffect(() => {
12
13
  if (nextDeadline === null) return;
13
14
  const wait = nextDeadline - Date.now();
14
- if (wait <= 0 || wait > 2 ** 31 - 1) return;
15
- const timeout = setTimeout(() => setNow(Date.now()), wait + 50);
15
+ if (wait > 2 ** 31 - 1) return;
16
+ const timeout = setTimeout(() => setNow(Date.now()), Math.max(wait, 0) + 50);
16
17
  return () => clearTimeout(timeout);
17
18
  }, [nextDeadline]);
18
19
  return now;
@@ -4,25 +4,33 @@ import { useRealtime, useRealtimeConnectionState, useRpc } from "@get-bb/plugin-
4
4
  import type { RpcContract } from "@/shared/contract";
5
5
  import { CHANNELS, type StampSignal, type Stamps } from "@/shared/signals";
6
6
 
7
- const EMPTY: Stamps = { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {} };
7
+ const EMPTY: Stamps = { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {}, idleAt: {} };
8
8
  const KINDS = new Set(Object.keys(EMPTY));
9
9
 
10
10
  export interface StampsState {
11
11
  stamps: Stamps;
12
+ /** The first listing came back, or failed: until then every map is empty for want of it. */
13
+ loaded: boolean;
12
14
  markSeen(threadIds: string[]): void;
15
+ /** Records on the server that these threads were just seen going idle. */
16
+ markIdle(threadIds: string[]): void;
13
17
  clearSeen(threadIds: string[]): void;
14
18
  }
15
19
 
16
20
  export function useStamps(): StampsState {
17
21
  const rpc = useRpc<RpcContract>();
18
22
  const [stamps, setStamps] = useState<Stamps>(EMPTY);
23
+ const [loaded, setLoaded] = useState(false);
19
24
  const connection = useRealtimeConnectionState();
20
25
  const wasConnected = useRef(false);
21
26
 
22
27
  const load = useCallback(() => {
23
28
  rpc.call("listStamps", null).then(
24
- (result) => setStamps(result.stamps),
25
- () => undefined,
29
+ (result) => {
30
+ setStamps(result.stamps);
31
+ setLoaded(true);
32
+ },
33
+ () => setLoaded(true),
26
34
  );
27
35
  }, [rpc]);
28
36
 
@@ -55,6 +63,13 @@ export function useStamps(): StampsState {
55
63
  },
56
64
  [apply, rpc],
57
65
  );
66
+ const markIdle = useCallback(
67
+ (threadIds: string[]) => {
68
+ if (threadIds.length === 0) return;
69
+ rpc.call("markIdle", { threadIds }).catch(() => undefined);
70
+ },
71
+ [rpc],
72
+ );
58
73
  const clearSeen = useCallback(
59
74
  (threadIds: string[]) => {
60
75
  if (threadIds.length === 0) return;
@@ -64,5 +79,5 @@ export function useStamps(): StampsState {
64
79
  [apply, rpc],
65
80
  );
66
81
 
67
- return { stamps, markSeen, clearSeen };
82
+ return { stamps, loaded, markSeen, markIdle, clearSeen };
68
83
  }
@@ -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 Flag, type StateKind, type ThreadState } from "./state";
6
+ import { computeState, type Flag, type StateKind, type ThreadState } from "./state";
7
7
 
8
8
  /** The flags that count for a root thread. Working is not one. */
9
9
  export const ROOT_ATTENTION: ReadonlySet<Flag> = new Set<Flag>([
@@ -17,11 +17,24 @@ export const ROOT_ATTENTION: ReadonlySet<Flag> = new Set<Flag>([
17
17
  /** States in which a parent thread is busy and may still deal with a failed child. */
18
18
  const BUSY: ReadonlySet<StateKind> = new Set<StateKind>(["working", "background", "queued", "scheduled"]);
19
19
 
20
+ /**
21
+ * How long a parent thread stays idle before a child's failure counts as
22
+ * orphaned. bb reports a child's turn to its parent after a 2 s batch delay,
23
+ * and the parent then has to start: a failure it is about to pick up never
24
+ * reaches the need-you filter.
25
+ */
26
+ export const ORPHAN_WAIT_MS = 5_000;
27
+
20
28
  export interface ParentThread {
21
29
  thread: Pick<PluginSidebarThread, "latestAttentionAt">;
22
30
  state: Pick<ThreadState, "kind">;
23
31
  /** When the plugin server last saw the parent thread finish a turn, if it did. */
24
32
  finishedAt?: number;
33
+ /**
34
+ * When a window last saw the parent thread go from busy to idle, if one did;
35
+ * null while that is not yet known, and no failure under it counts.
36
+ */
37
+ idleSince?: number | null;
25
38
  }
26
39
 
27
40
  /** A parent thread is idle when it is not working, setting up, running background work or holding a queued message. */
@@ -41,21 +54,72 @@ export function failureTime(
41
54
  }
42
55
 
43
56
  /**
44
- * An orphaned failure: the parent thread is idle and has not run or
45
- * finished since the child failed. A busy parent thread, or one that
57
+ * When a child's failure becomes an orphaned failure, or null while it cannot:
58
+ * the parent thread is idle and has not run or finished since the child
59
+ * failed, and has stayed idle for `ORPHAN_WAIT_MS` from the later of the
60
+ * failure and its last becoming idle. A busy parent thread, or one that
46
61
  * finished after the failure, is taken to know about it. The parent thread's
47
62
  * own clock is its last finish (`finishedAt`, or `latestAttentionAt` when it
48
63
  * failed): `updatedAt` also moves when someone merely opens or renames it.
49
64
  */
65
+ export function orphanedAt(
66
+ thread: Pick<PluginSidebarThread, "latestAttentionAt" | "updatedAt">,
67
+ flags: ReadonlySet<Flag>,
68
+ parent: ParentThread,
69
+ ): number | null {
70
+ if (!flags.has("unread-failed") && !flags.has("queue-failed")) return null;
71
+ if (!isParentIdle(parent) || parent.idleSince === null) return null;
72
+ const parentActiveAt = Math.max(parent.thread.latestAttentionAt, parent.finishedAt ?? 0);
73
+ const failedAt = failureTime(thread, flags);
74
+ if (parentActiveAt > failedAt) return null;
75
+ return Math.max(failedAt, parent.idleSince ?? 0) + ORPHAN_WAIT_MS;
76
+ }
77
+
78
+ /** Whether a child's failure is an orphaned failure at `now` (see `orphanedAt`). */
50
79
  export function isOrphanedFailure(
51
80
  thread: Pick<PluginSidebarThread, "latestAttentionAt" | "updatedAt">,
52
81
  flags: ReadonlySet<Flag>,
53
82
  parent: ParentThread,
83
+ now: number,
54
84
  ): boolean {
55
- if (!flags.has("unread-failed") && !flags.has("queue-failed")) return false;
56
- if (!isParentIdle(parent)) return false;
57
- const parentActiveAt = Math.max(parent.thread.latestAttentionAt, parent.finishedAt ?? 0);
58
- return parentActiveAt <= failureTime(thread, flags);
85
+ const at = orphanedAt(thread, flags, parent);
86
+ return at !== null && at <= now;
87
+ }
88
+
89
+ /** Which threads the list last saw busy, and when each last went from busy to idle. */
90
+ export interface IdleTracker {
91
+ busy: ReadonlySet<string>;
92
+ idleSince: Readonly<Record<string, number>>;
93
+ /** The threads this step saw go idle, for the server's `idleAt`. */
94
+ wentIdle: readonly string[];
95
+ }
96
+
97
+ function isBusyThread(thread: PluginSidebarThread): boolean {
98
+ const state = computeState(thread, { unread: false, hasDraft: false, scheduledAt: null, now: 0 });
99
+ return !isParentIdle({ thread, state });
100
+ }
101
+
102
+ /**
103
+ * The tracker after the list sees `threads` at `at`: a thread busy before and
104
+ * idle now went idle at `at`. A thread first seen idle has no time here; the
105
+ * server's `idleAt` holds what an earlier window saw.
106
+ */
107
+ export function trackIdle(previous: IdleTracker | null, threads: readonly PluginSidebarThread[], at: number): IdleTracker {
108
+ const busy = new Set<string>();
109
+ const idleSince: Record<string, number> = {};
110
+ const wentIdle: string[] = [];
111
+ for (const thread of threads) {
112
+ const id = thread.id;
113
+ if (isBusyThread(thread)) {
114
+ busy.add(id);
115
+ } else if (previous?.busy.has(id)) {
116
+ idleSince[id] = at;
117
+ wentIdle.push(id);
118
+ } else if (previous?.idleSince[id] !== undefined) {
119
+ idleSince[id] = previous.idleSince[id];
120
+ }
121
+ }
122
+ return { busy, idleSince, wentIdle };
59
123
  }
60
124
 
61
125
  /**
@@ -6,7 +6,7 @@ import type { Forest } from "./trees";
6
6
  /**
7
7
  * `reveal` targets open the parent chips on their path. `open` targets (a
8
8
  * thread that newly became unread) open no chip, so finished children don't
9
- * expand their parent's. The open thread's target, of either kind, also
9
+ * expand their parent's. The focused thread's target, of either kind, also
10
10
  * opens its collapsed group. No target opens a settled fold.
11
11
  */
12
12
  export type TargetKind = "reveal" | "open";
@@ -42,7 +42,7 @@ export function lastActivityAt(
42
42
  }
43
43
 
44
44
  /**
45
- * A settled thread: quiet as if no thread were open, not needing attention,
45
+ * A settled thread: quiet as if no thread were focused, not needing attention,
46
46
  * not pinned, and its own last activity is older than the Settle after
47
47
  * period. Hidden and archived threads take the same test.
48
48
  */
@@ -109,17 +109,30 @@ export function isOffline(thread: Pick<PluginSidebarThread, "status" | "runtimeS
109
109
  /** Per-thread facts the host payload doesn't carry. */
110
110
  export interface ThreadContext {
111
111
  activeThreadId: string | null;
112
+ /**
113
+ * Threads shown in a visible pane of the window besides the active one:
114
+ * bb's split panes. Absent, only the active (focused) thread is open.
115
+ */
116
+ openThreadIds?: ReadonlySet<string>;
112
117
  /** Stamps, from the plugin server. */
113
118
  finishedAt: Readonly<Record<string, number>>;
114
119
  seenAt: Readonly<Record<string, number>>;
115
120
  }
116
121
 
122
+ /** Whether the thread is shown in a visible pane: the active one or another split pane. */
123
+ export function isOpenThread(thread: Pick<PluginSidebarThread, "id">, context: ThreadContext): boolean {
124
+ return thread.id === context.activeThreadId || (context.openThreadIds?.has(thread.id) ?? false);
125
+ }
126
+
117
127
  /**
118
128
  * bb's rule for every thread: unread when it has finished (idle or
119
129
  * error) since it was last read. Children are also unread when they finished
120
- * after you last looked at them (done-unseen).
130
+ * after you last looked at them (done-unseen). An open thread is never
131
+ * unread: bb marks it read moments after it finishes or fails, and counting
132
+ * it until then only flashes the need-you filter.
121
133
  */
122
134
  export function isUnread(thread: PluginSidebarThread, context: ThreadContext): boolean {
135
+ if (isOpenThread(thread, context)) return false;
123
136
  const status = normalizeStatus(thread);
124
137
  const lastRead = thread.lastReadAt ?? 0;
125
138
  if ((status === "idle" || status === "error") && lastRead < thread.latestAttentionAt) {
@@ -132,7 +145,7 @@ export function isUnread(thread: PluginSidebarThread, context: ThreadContext): b
132
145
  export function isDoneUnseen(thread: PluginSidebarThread, context: ThreadContext): boolean {
133
146
  if (thread.parentThreadId === null) return false;
134
147
  if (normalizeStatus(thread) !== "idle") return false;
135
- if (thread.id === context.activeThreadId) return false;
148
+ if (isOpenThread(thread, context)) return false;
136
149
  const finished = context.finishedAt[thread.id];
137
150
  if (finished === undefined) return false;
138
151
  return finished > Math.max(thread.lastReadAt ?? 0, context.seenAt[thread.id] ?? 0);