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

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 (54) hide show
  1. package/README.md +1 -1
  2. package/app.tsx +3 -11
  3. package/dist/app.css +1 -1
  4. package/dist/app.js +31 -31
  5. package/dist/app.meta.json +1 -1
  6. package/dist/server.js +10 -10
  7. package/dist/server.js.map +3 -3
  8. package/dist/server.meta.json +1 -1
  9. package/features/thread-list/components/FoldRows.tsx +21 -30
  10. package/features/thread-list/components/GroupSection.tsx +7 -45
  11. package/features/thread-list/components/ListHeader.tsx +79 -0
  12. package/features/thread-list/components/ProviderBadge.tsx +0 -1
  13. package/features/thread-list/components/PullRequestProbes.tsx +27 -0
  14. package/features/thread-list/components/SettingsPanel.tsx +20 -34
  15. package/features/thread-list/components/ThreadDetails.tsx +3 -3
  16. package/features/thread-list/components/ThreadList.tsx +117 -83
  17. package/features/thread-list/components/ThreadRowView.tsx +203 -180
  18. package/features/thread-list/components/controller.ts +5 -10
  19. package/features/thread-list/components/glyphs.tsx +10 -16
  20. package/features/thread-list/data/useAutoExpand.ts +1 -1
  21. package/features/thread-list/data/useDefaultBranches.ts +35 -0
  22. package/features/thread-list/data/usePullRequestAnswers.ts +36 -0
  23. package/features/thread-list/data/useSystemFacts.ts +52 -0
  24. package/features/thread-list/icons.ts +1 -0
  25. package/features/thread-list/index.ts +0 -1
  26. package/features/thread-list/model/attention.ts +2 -66
  27. package/features/thread-list/model/branches.ts +27 -0
  28. package/features/thread-list/model/counters.ts +5 -5
  29. package/features/thread-list/model/details.ts +6 -6
  30. package/features/thread-list/model/expansion.ts +5 -5
  31. package/features/thread-list/model/groups.ts +2 -2
  32. package/features/thread-list/model/labels.ts +43 -18
  33. package/features/thread-list/model/menu.ts +1 -0
  34. package/features/thread-list/model/move.ts +1 -1
  35. package/features/thread-list/model/notes.ts +2 -28
  36. package/features/thread-list/model/settings.ts +1 -17
  37. package/features/thread-list/model/settled.ts +122 -0
  38. package/features/thread-list/model/share.ts +1 -7
  39. package/features/thread-list/model/sort.ts +5 -16
  40. package/features/thread-list/model/state.ts +17 -16
  41. package/features/thread-list/model/time.ts +7 -0
  42. package/features/thread-list/model/toggles.ts +35 -24
  43. package/features/thread-list/model/{families.ts → trees.ts} +40 -28
  44. package/features/thread-list/model/view.ts +202 -274
  45. package/features/thread-list/testing/browser.css +13 -0
  46. package/features/thread-list/testing/fixtures.ts +41 -12
  47. package/package.json +5 -1
  48. package/server/cli.ts +1 -1
  49. package/server/import.ts +36 -6
  50. package/server/preference-store.ts +2 -1
  51. package/server.ts +1 -1
  52. package/shared/preferences.ts +38 -27
  53. package/skills/thread-glance/SKILL.md +11 -12
  54. package/features/thread-list/data/useAttentionHold.ts +0 -19
@@ -29,31 +29,36 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
29
29
  import { cn } from "@/lib/utils";
30
30
  import { useAutoExpand } from "../data/useAutoExpand";
31
31
  import { useClientPreferences } from "../data/useClientPreferences";
32
- import { useAttentionHold } from "../data/useAttentionHold";
33
32
  import { useNow } from "../data/useNow";
34
33
  import { usePreferences } from "../data/usePreferences";
35
34
  import { useScheduled } from "../data/useScheduled";
36
35
  import { useStamps } from "../data/useStamps";
37
36
  import { resolveDrop, type DraggedThread, type DropAction, type DropTarget } from "../model/drag";
38
- import { buildForest } from "../model/families";
37
+ import { buildForest } from "../model/trees";
39
38
  import { moveGroup, ORDER_PREFERENCE } from "../model/groups";
40
39
  import { MARK_ALL_CONFIRM_ABOVE, type RowMenuAction } from "../model/menu";
41
40
  import { assignProviderMarks, providerMark } from "../model/provider-mark";
42
- import { isDoneUnseen } from "../model/state";
43
- import { markAllReadPlan, openChildren, toggleChip, toggleGroup, toggleOlder, type ToggleOutcome } from "../model/toggles";
44
- import { buildListView, type GroupView, type ListView } from "../model/view";
41
+ import { markAllReadPlan, markReadPlanFor, toggleChip, toggleGroup, toggleOlder, toggleSettled, type ToggleOutcome } from "../model/toggles";
42
+ import { pullRequestFact, pullRequestLookupIds, type SettleInputs } from "../model/settled";
43
+ import { usePullRequestAnswers } from "../data/usePullRequestAnswers";
44
+ import { PullRequestProbes } from "./PullRequestProbes";
45
+ import { buildListView, countNeedYou, needYouActive, type GroupView, type ListView } from "../model/view";
45
46
  import { shareView } from "../model/share";
46
47
  import { ListLiveContext, type ListLive, type ModelInfo, type RowController } from "./controller";
47
48
  import { ConfirmDialog, CustomizeDialog, DetailsDialog, MoveDialog, NewSectionDialog, type CustomizeItem } from "./Dialogs";
48
49
  import { useNotes } from "../data/useNotes";
50
+ import { useDefaultBranches } from "../data/useDefaultBranches";
51
+ import { useSystemFacts } from "../data/useSystemFacts";
49
52
  import { moveTargets } from "../model/move";
50
53
  import { modelDisplayName } from "../model/details";
51
54
  import { groupIdForRoot } from "../model/groups";
52
55
  import { CounterStrip } from "./glyphs";
53
56
  import { cancelPendingCards } from "./row-card";
54
- import { GroupSection, AttentionSection, type DropStates, type GroupController } from "./GroupSection";
57
+ import { GroupSection, type DropStates, type GroupController } from "./GroupSection";
55
58
  import type { ProviderDisplay } from "./ProviderBadge";
56
59
  import { ThreadDetails } from "./ThreadDetails";
60
+ import { ListHeader } from "./ListHeader";
61
+ import type { ThreadTree } from "../model/trees";
57
62
 
58
63
  const PLUGIN_ID = "thread-glance";
59
64
 
@@ -107,8 +112,10 @@ function ThreadListBody({
107
112
  onRetry,
108
113
  }: PluginThreadListProps & { attempt: number; onRetry(): void }) {
109
114
  const { prefs, hydrated, update } = usePreferences();
110
- const [client] = useClientPreferences();
111
- const sidebar = useSidebarThreads({ experimental_lifecycles: prefs.threadLifecycles });
115
+ const [client, updateClient] = useClientPreferences();
116
+ // The need-you filter is per window and starts off on every load.
117
+ const [needYouOn, setNeedYouOn] = useState(false);
118
+ const sidebar = useSidebarThreads({ experimental_lifecycles: prefs.showArchived ? ["active", "archived"] : ["active"] });
112
119
  const actions = useThreadActions();
113
120
  const sdk = useSdk();
114
121
  const { providers } = useProviders();
@@ -133,8 +140,6 @@ function ThreadListBody({
133
140
  const [confirm, setConfirm] = useState<Confirm | null>(null);
134
141
  const [dropStates, setDropStates] = useState<DropStates>(() => new Map());
135
142
  const [dropGroupId, setDropGroupId] = useState<string | null>(null);
136
- const [defaultBranches, setDefaultBranches] = useState<ReadonlyMap<string, string | null>>(() => new Map());
137
- const branchRequests = useRef(new Set<string>());
138
143
  const models = useRef(new Map<string, Promise<ModelInfo | null>>());
139
144
 
140
145
  const ready = sidebar.status === "ready";
@@ -159,7 +164,32 @@ function ThreadListBody({
159
164
  [ready, threads, activeThreadId, stamps.finishedAt, stamps.seenAt, draftIds, scheduled, now, notes, prefs.childAttention],
160
165
  );
161
166
  const { targets, prune } = useAutoExpand(hydrated ? forest : null, activeThreadId);
162
- const held = useAttentionHold(forest, activeThreadId);
167
+ // Projects with a thread on a branch: the rest need no default branch.
168
+ const branchedProjectIds = useMemo(
169
+ () => [...new Set(threads.flatMap((thread) => (thread.environment?.branchName ? [thread.projectId] : [])))],
170
+ [threads],
171
+ );
172
+ const defaultBranches = useDefaultBranches(branchedProjectIds);
173
+ const defaultBranchOf = useCallback(
174
+ (thread: PluginSidebarThread) => (defaultBranches.has(thread.projectId) ? (defaultBranches.get(thread.projectId) ?? null) : undefined),
175
+ [defaultBranches],
176
+ );
177
+ const [pullRequests, onPullRequest] = usePullRequestAnswers();
178
+ const system = useSystemFacts();
179
+ const pullRequestLookups = useMemo(
180
+ () => (forest === null ? [] : pullRequestLookupIds(forest.infos.values(), defaultBranchOf)),
181
+ [forest, defaultBranchOf],
182
+ );
183
+ const settle: SettleInputs = useMemo(
184
+ () => ({
185
+ now,
186
+ settleAfter: prefs.settleAfter,
187
+ startedAt: stamps.startedAt,
188
+ finishedAt: stamps.finishedAt,
189
+ pullRequestOf: (thread) => pullRequestFact(thread, defaultBranchOf(thread), pullRequests),
190
+ }),
191
+ [now, prefs.settleAfter, stamps.startedAt, stamps.finishedAt, defaultBranchOf, pullRequests],
192
+ );
163
193
  // Rows and groups that did not change keep their objects, so their
164
194
  // memoized components skip the render.
165
195
  const previousView = useRef<ListView | null>(null);
@@ -174,14 +204,37 @@ function ThreadListBody({
174
204
  sections: sidebar.sections,
175
205
  prefs,
176
206
  activeThreadId,
177
- held,
178
207
  targets,
208
+ settle,
209
+ defaultProviderId: system.defaultProviderId,
210
+ primaryHostId: system.primaryHostId,
211
+ comfortable: client.density === "comfortable",
212
+ defaultBranchOf,
213
+ needYouOnly: needYouActive(needYouOn, countNeedYou(forest)),
179
214
  })),
180
- [forest, threads, sidebar.projects, sidebar.sections, prefs, activeThreadId, held, targets],
215
+ [
216
+ forest,
217
+ threads,
218
+ sidebar.projects,
219
+ sidebar.sections,
220
+ prefs,
221
+ activeThreadId,
222
+ targets,
223
+ settle,
224
+ system.defaultProviderId,
225
+ system.primaryHostId,
226
+ client.density,
227
+ defaultBranchOf,
228
+ needYouOn,
229
+ ],
181
230
  );
182
231
  useLayoutEffect(() => {
183
232
  previousView.current = view;
184
233
  }, [view]);
234
+ // Nothing left that needs you turns the filter off, so it does not narrow
235
+ // the list again unasked when something next does. Set during render, as
236
+ // React adjusts state from a changed input.
237
+ if (needYouOn && forest !== null && !needYouActive(needYouOn, countNeedYou(forest))) setNeedYouOn(false);
185
238
 
186
239
  // Viewing a child stamps seenAt, on arrival and on leaving, so a child
187
240
  // that finishes while you watch doesn't turn unread behind you.
@@ -197,22 +250,6 @@ function ThreadListBody({
197
250
  // eslint-disable-next-line react-hooks/exhaustive-deps
198
251
  }, [activeThreadId]);
199
252
 
200
- // Each project's default branch, fetched once per session.
201
- useEffect(() => {
202
- if (!prefs.showPullRequests || forest === null) return;
203
- for (const family of forest.families) {
204
- const thread = family.root.thread;
205
- const projectId = thread.projectId;
206
- if (thread.environment?.branchName == null || thread.host === null) continue;
207
- if (branchRequests.current.has(projectId)) continue;
208
- branchRequests.current.add(projectId);
209
- sdk.projects.branches({ projectId, hostId: thread.host.id, limit: "1" }).then(
210
- (result) => setDefaultBranches((current) => new Map(current).set(projectId, result.defaultBranch)),
211
- () => setDefaultBranches((current) => new Map(current).set(projectId, null)),
212
- );
213
- }
214
- }, [prefs.showPullRequests, forest, sdk]);
215
-
216
253
  const marks = useMemo(() => assignProviderMarks(providers), [providers]);
217
254
  // One display per harness, built once: rows compare it by identity.
218
255
  const providerDisplay = useMemo(() => {
@@ -257,10 +294,13 @@ function ThreadListBody({
257
294
  case "copy-id":
258
295
  void copyText(thread.id, "Thread ID copied");
259
296
  return;
260
- case "mark-read":
261
- if (isDoneUnseen(thread, context)) markSeen([thread.id]);
262
- actions.setRead(thread.id, true).catch(fail("Couldn't mark read"));
297
+ case "mark-read": {
298
+ const forest = latest.current.forest;
299
+ const plan = forest === null ? { read: [thread.id], seen: [] } : markReadPlanFor(thread.id, forest, context);
300
+ if (plan.seen.length > 0) markSeen(plan.seen);
301
+ for (const id of plan.read) actions.setRead(id, true).catch(fail("Couldn't mark read"));
263
302
  return;
303
+ }
264
304
  case "mark-unread":
265
305
  clearSeen([thread.id]);
266
306
  actions.setRead(thread.id, false).catch(fail("Couldn't mark unread"));
@@ -341,7 +381,6 @@ function ThreadListBody({
341
381
  [sdk],
342
382
  );
343
383
 
344
- const multiHost = view?.multiHost ?? false;
345
384
  const built = forest !== null && view !== null;
346
385
  const rowController: RowController | null = useMemo(() => {
347
386
  if (!built) return null;
@@ -349,11 +388,7 @@ function ThreadListBody({
349
388
  compact: isCompactViewport,
350
389
  comfortable: client.density === "comfortable",
351
390
  setEditingId,
352
- showPullRequests: prefs.showPullRequests,
353
391
  harnessIcon: prefs.harnessIcon,
354
- defaultBranchOf: (thread) =>
355
- defaultBranches.has(thread.projectId) ? defaultBranches.get(thread.projectId) ?? null : undefined,
356
- multiHost,
357
392
  provider: providerDisplay,
358
393
  sections: sidebar.sections,
359
394
  mode: prefs.organizationMode,
@@ -363,11 +398,10 @@ function ThreadListBody({
363
398
  applyToggle(toggleChip(row, prefs, forest!));
364
399
  },
365
400
  onToggleOlder: (row) => {
366
- const { prefs, forest, view } = latest.current;
367
- const group = view!.groups.find((candidate) => candidate.descriptor.id === row.scopeId) ?? null;
368
- applyToggle(toggleOlder(row, prefs, group, forest!));
401
+ const { prefs, forest } = latest.current;
402
+ applyToggle(toggleOlder(row, prefs, forest!));
369
403
  },
370
- onOpenChildren: (rootId) => applyToggle(openChildren(rootId, latest.current.prefs)),
404
+ onToggleSettled: (row) => applyToggle(toggleSettled(row, latest.current.prefs)),
371
405
  onToggleEnvironment: (environmentId) => {
372
406
  const { collapsedEnvironments } = latest.current.prefs;
373
407
  update({
@@ -397,11 +431,8 @@ function ThreadListBody({
397
431
  built,
398
432
  isCompactViewport,
399
433
  client.density,
400
- prefs.showPullRequests,
401
434
  prefs.harnessIcon,
402
435
  prefs.organizationMode,
403
- defaultBranches,
404
- multiHost,
405
436
  providerDisplay,
406
437
  sidebar.sections,
407
438
  onNavigate,
@@ -413,54 +444,58 @@ function ThreadListBody({
413
444
  ]);
414
445
 
415
446
  const live: ListLive = useMemo(
416
- () => ({ now, stamps, notes, familyOf: (threadId) => forest?.familyOf.get(threadId) }),
447
+ () => ({ now, stamps, notes, treeOf: (threadId) => forest?.treeOf.get(threadId) }),
417
448
  [now, stamps, notes, forest],
418
449
  );
419
450
 
420
451
  const activeGroupId = useMemo(() => {
421
- const family = activeThreadId === null || forest === null ? undefined : forest.familyOf.get(activeThreadId);
422
- return family === undefined
452
+ const tree = activeThreadId === null || forest === null ? undefined : forest.treeOf.get(activeThreadId);
453
+ return tree === undefined
423
454
  ? null
424
- : groupIdForRoot(family.root.thread, { mode: prefs.organizationMode, projects: sidebar.projects });
455
+ : groupIdForRoot(tree.root.thread, { mode: prefs.organizationMode, projects: sidebar.projects });
425
456
  }, [activeThreadId, forest, prefs.organizationMode, sidebar.projects]);
426
- const showArchived = prefs.threadLifecycles.includes("archived");
457
+ const showArchived = prefs.showArchived;
427
458
 
428
- const groupController: GroupController | null = useMemo(() => {
429
- if (!built) return null;
430
- const markAll = (group: GroupView) => {
431
- const { forest, activeThreadId, stamps } = latest.current;
432
- // Folded roots count too: every family bucketed in the group.
433
- const families = group.rootIds.flatMap((id) => forest!.familyOf.get(id) ?? []);
434
- const plan = markAllReadPlan(families, {
435
- activeThreadId,
436
- finishedAt: stamps.finishedAt,
437
- seenAt: stamps.seenAt,
438
- });
459
+ /** Marks every unread thread in the trees read, asking first above MARK_ALL_CONFIRM_ABOVE. `where` names them. */
460
+ const markTreesRead = useCallback(
461
+ (trees: readonly ThreadTree[], where: string) => {
462
+ const { activeThreadId, stamps } = latest.current;
463
+ const plan = markAllReadPlan(trees, { activeThreadId, finishedAt: stamps.finishedAt, seenAt: stamps.seenAt });
439
464
  const run = () => {
440
465
  if (plan.seen.length > 0) markSeen(plan.seen);
441
466
  for (const id of plan.read) actions.setRead(id, true).catch(() => undefined);
442
467
  };
443
468
  if (plan.read.length === 0) {
444
- toast(`Nothing unread in ${group.descriptor.label}`);
469
+ toast(`Nothing unread in ${where}`);
445
470
  } else if (plan.read.length > MARK_ALL_CONFIRM_ABOVE) {
446
471
  setConfirm({
447
472
  title: `Mark ${plan.read.length} threads read?`,
448
- description: `Every unread thread in ${group.descriptor.label}, child threads included, will be marked read.`,
473
+ description: `Every unread thread in ${where}, child threads included, will be marked read.`,
449
474
  confirmLabel: "Mark all read",
450
475
  run,
451
476
  });
452
477
  } else {
453
478
  run();
454
479
  }
455
- };
480
+ },
481
+ [actions, markSeen],
482
+ );
483
+ const onMarkListRead = useCallback(() => markTreesRead(latest.current.forest?.trees ?? [], "the list"), [markTreesRead]);
484
+ const onToggleNeedYou = useCallback(() => setNeedYouOn((on) => !on), []);
485
+
486
+ const groupController: GroupController | null = useMemo(() => {
487
+ if (!built) return null;
488
+ // Folded roots count too: every tree bucketed in the group.
489
+ const markAll = (group: GroupView) =>
490
+ markTreesRead(
491
+ group.rootIds.flatMap((id) => latest.current.forest!.treeOf.get(id) ?? []),
492
+ group.descriptor.label,
493
+ );
456
494
  return {
457
495
  compact: isCompactViewport,
458
496
  activeGroupId,
459
497
  showArchived,
460
- onToggleArchived: () =>
461
- update({
462
- threadLifecycles: latest.current.prefs.threadLifecycles.includes("archived") ? ["active"] : ["active", "archived"],
463
- }),
498
+ onToggleArchived: () => update({ showArchived: !latest.current.prefs.showArchived }),
464
499
  canCreateSections: prefs.organizationMode === "chronological",
465
500
  onToggleCollapse: (group) => {
466
501
  const { prefs, forest } = latest.current;
@@ -501,7 +536,7 @@ function ThreadListBody({
501
536
  onMarkAllRead: markAll,
502
537
  onNewSection: () => setNewSectionOpen(true),
503
538
  };
504
- }, [built, isCompactViewport, activeGroupId, showArchived, prefs.organizationMode, applyToggle, actions, onNavigate, update, sdk, markSeen]);
539
+ }, [built, isCompactViewport, activeGroupId, showArchived, prefs.organizationMode, applyToggle, actions, onNavigate, update, sdk, markTreesRead]);
505
540
 
506
541
  const sensors = useSensors(
507
542
  useSensor(MouseSensor, MOUSE_SENSOR),
@@ -523,8 +558,7 @@ function ThreadListBody({
523
558
  }, []);
524
559
 
525
560
  const dropContext = useMemo(() => {
526
- // A row in Needs attention drops as it would in its home group.
527
- const groupOfThread = new Map<string, string>(Object.entries(view?.attention?.homeGroupIds ?? {}));
561
+ const groupOfThread = new Map<string, string>();
528
562
  for (const group of [...(view?.groups ?? []), ...(view?.more ?? [])]) {
529
563
  for (const row of group.rows) if (row.type === "thread") groupOfThread.set(row.info.thread.id, group.descriptor.id);
530
564
  }
@@ -678,23 +712,23 @@ function ThreadListBody({
678
712
  return (
679
713
  <ListLiveContext.Provider value={live}>
680
714
  <div className="flex w-full min-w-0 flex-col px-1.5 pb-2">
715
+ <ListHeader
716
+ mode={prefs.organizationMode}
717
+ needYouCount={view.needYouCount}
718
+ needYouOnly={needYouOn}
719
+ onToggleNeedYou={onToggleNeedYou}
720
+ onMarkAllRead={onMarkListRead}
721
+ prefs={prefs}
722
+ client={client}
723
+ onPrefs={update}
724
+ onClient={updateClient}
725
+ />
726
+ <PullRequestProbes threadIds={pullRequestLookups} onAnswer={onPullRequest} />
681
727
  {threads.length === 0 ? (
682
728
  // bb's own pinned New thread button covers the empty list.
683
729
  <p className="px-3 py-4 text-sm text-muted-foreground">No threads yet.</p>
684
730
  ) : (
685
731
  <DndContext sensors={sensors} collisionDetection={collision} onDragMove={onDragMove} onDragEnd={onDragEnd} onDragCancel={onDragCancel}>
686
- {view.attention !== null ? (
687
- <AttentionSection
688
- view={view.attention}
689
- rowController={rowController}
690
- environmentProviders={environmentProviders}
691
- dropStates={dropStates}
692
- activeThreadId={activeThreadId}
693
- editingId={editingId}
694
- now={now}
695
- stamps={stamps}
696
- />
697
- ) : null}
698
732
  {view.groups.map((group) => (
699
733
  <GroupSection
700
734
  key={group.descriptor.id}
@@ -822,7 +856,7 @@ function ThreadListBody({
822
856
  <ThreadDetails
823
857
  info={details}
824
858
  controller={rowController}
825
- showPullRequest={prefs.showPullRequests}
859
+ showPullRequest
826
860
  actions={{
827
861
  open: () => {
828
862
  setDetailsId(null);