@gperezmz/bb-plugin-thread-glance 0.2.3 → 0.3.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 (55) hide show
  1. package/README.md +1 -1
  2. package/app.tsx +2 -1
  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 +8 -50
  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/{Toolbar.tsx → SettingsPanel.tsx} +26 -57
  15. package/features/thread-list/components/ThreadDetails.tsx +3 -3
  16. package/features/thread-list/components/ThreadList.tsx +116 -84
  17. package/features/thread-list/components/ThreadRowView.tsx +70 -78
  18. package/features/thread-list/components/controller.ts +5 -10
  19. package/features/thread-list/components/glyphs.tsx +39 -14
  20. package/features/thread-list/data/same-window.ts +34 -0
  21. package/features/thread-list/data/useAutoExpand.ts +1 -1
  22. package/features/thread-list/data/useClientPreferences.ts +15 -7
  23. package/features/thread-list/data/useDefaultBranches.ts +35 -0
  24. package/features/thread-list/data/usePreferences.ts +16 -7
  25. package/features/thread-list/data/usePullRequestAnswers.ts +36 -0
  26. package/features/thread-list/data/useSystemFacts.ts +52 -0
  27. package/features/thread-list/icons.ts +1 -1
  28. package/features/thread-list/model/attention.ts +2 -66
  29. package/features/thread-list/model/branches.ts +27 -0
  30. package/features/thread-list/model/counters.ts +5 -5
  31. package/features/thread-list/model/details.ts +6 -6
  32. package/features/thread-list/model/expansion.ts +5 -5
  33. package/features/thread-list/model/groups.ts +2 -2
  34. package/features/thread-list/model/labels.ts +28 -17
  35. package/features/thread-list/model/layout.ts +5 -2
  36. package/features/thread-list/model/menu.ts +1 -0
  37. package/features/thread-list/model/move.ts +1 -1
  38. package/features/thread-list/model/notes.ts +2 -28
  39. package/features/thread-list/model/settings.ts +2 -18
  40. package/features/thread-list/model/settled.ts +122 -0
  41. package/features/thread-list/model/share.ts +1 -7
  42. package/features/thread-list/model/sort.ts +5 -16
  43. package/features/thread-list/model/state.ts +25 -9
  44. package/features/thread-list/model/toggles.ts +35 -24
  45. package/features/thread-list/model/{families.ts → trees.ts} +40 -28
  46. package/features/thread-list/model/view.ts +200 -274
  47. package/features/thread-list/testing/fixtures.ts +41 -12
  48. package/package.json +1 -1
  49. package/server/cli.ts +1 -1
  50. package/server/import.ts +36 -6
  51. package/server/preference-store.ts +2 -1
  52. package/server.ts +1 -1
  53. package/shared/preferences.ts +38 -27
  54. package/skills/thread-glance/SKILL.md +11 -12
  55. package/features/thread-list/data/useAttentionHold.ts +0 -19
@@ -29,32 +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";
57
- import { Toolbar } from "./Toolbar";
60
+ import { ListHeader } from "./ListHeader";
61
+ import type { ThreadTree } from "../model/trees";
58
62
 
59
63
  const PLUGIN_ID = "thread-glance";
60
64
 
@@ -109,7 +113,9 @@ function ThreadListBody({
109
113
  }: PluginThreadListProps & { attempt: number; onRetry(): void }) {
110
114
  const { prefs, hydrated, update } = usePreferences();
111
115
  const [client, updateClient] = useClientPreferences();
112
- const sidebar = useSidebarThreads({ experimental_lifecycles: prefs.threadLifecycles });
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"] });
113
119
  const actions = useThreadActions();
114
120
  const sdk = useSdk();
115
121
  const { providers } = useProviders();
@@ -134,8 +140,6 @@ function ThreadListBody({
134
140
  const [confirm, setConfirm] = useState<Confirm | null>(null);
135
141
  const [dropStates, setDropStates] = useState<DropStates>(() => new Map());
136
142
  const [dropGroupId, setDropGroupId] = useState<string | null>(null);
137
- const [defaultBranches, setDefaultBranches] = useState<ReadonlyMap<string, string | null>>(() => new Map());
138
- const branchRequests = useRef(new Set<string>());
139
143
  const models = useRef(new Map<string, Promise<ModelInfo | null>>());
140
144
 
141
145
  const ready = sidebar.status === "ready";
@@ -160,7 +164,32 @@ function ThreadListBody({
160
164
  [ready, threads, activeThreadId, stamps.finishedAt, stamps.seenAt, draftIds, scheduled, now, notes, prefs.childAttention],
161
165
  );
162
166
  const { targets, prune } = useAutoExpand(hydrated ? forest : null, activeThreadId);
163
- 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
+ );
164
193
  // Rows and groups that did not change keep their objects, so their
165
194
  // memoized components skip the render.
166
195
  const previousView = useRef<ListView | null>(null);
@@ -175,14 +204,37 @@ function ThreadListBody({
175
204
  sections: sidebar.sections,
176
205
  prefs,
177
206
  activeThreadId,
178
- held,
179
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)),
180
214
  })),
181
- [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
+ ],
182
230
  );
183
231
  useLayoutEffect(() => {
184
232
  previousView.current = view;
185
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);
186
238
 
187
239
  // Viewing a child stamps seenAt, on arrival and on leaving, so a child
188
240
  // that finishes while you watch doesn't turn unread behind you.
@@ -198,22 +250,6 @@ function ThreadListBody({
198
250
  // eslint-disable-next-line react-hooks/exhaustive-deps
199
251
  }, [activeThreadId]);
200
252
 
201
- // Each project's default branch, fetched once per session.
202
- useEffect(() => {
203
- if (!prefs.showPullRequests || forest === null) return;
204
- for (const family of forest.families) {
205
- const thread = family.root.thread;
206
- const projectId = thread.projectId;
207
- if (thread.environment?.branchName == null || thread.host === null) continue;
208
- if (branchRequests.current.has(projectId)) continue;
209
- branchRequests.current.add(projectId);
210
- sdk.projects.branches({ projectId, hostId: thread.host.id, limit: "1" }).then(
211
- (result) => setDefaultBranches((current) => new Map(current).set(projectId, result.defaultBranch)),
212
- () => setDefaultBranches((current) => new Map(current).set(projectId, null)),
213
- );
214
- }
215
- }, [prefs.showPullRequests, forest, sdk]);
216
-
217
253
  const marks = useMemo(() => assignProviderMarks(providers), [providers]);
218
254
  // One display per harness, built once: rows compare it by identity.
219
255
  const providerDisplay = useMemo(() => {
@@ -258,10 +294,13 @@ function ThreadListBody({
258
294
  case "copy-id":
259
295
  void copyText(thread.id, "Thread ID copied");
260
296
  return;
261
- case "mark-read":
262
- if (isDoneUnseen(thread, context)) markSeen([thread.id]);
263
- 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"));
264
302
  return;
303
+ }
265
304
  case "mark-unread":
266
305
  clearSeen([thread.id]);
267
306
  actions.setRead(thread.id, false).catch(fail("Couldn't mark unread"));
@@ -342,7 +381,6 @@ function ThreadListBody({
342
381
  [sdk],
343
382
  );
344
383
 
345
- const multiHost = view?.multiHost ?? false;
346
384
  const built = forest !== null && view !== null;
347
385
  const rowController: RowController | null = useMemo(() => {
348
386
  if (!built) return null;
@@ -350,11 +388,7 @@ function ThreadListBody({
350
388
  compact: isCompactViewport,
351
389
  comfortable: client.density === "comfortable",
352
390
  setEditingId,
353
- showPullRequests: prefs.showPullRequests,
354
391
  harnessIcon: prefs.harnessIcon,
355
- defaultBranchOf: (thread) =>
356
- defaultBranches.has(thread.projectId) ? defaultBranches.get(thread.projectId) ?? null : undefined,
357
- multiHost,
358
392
  provider: providerDisplay,
359
393
  sections: sidebar.sections,
360
394
  mode: prefs.organizationMode,
@@ -364,11 +398,10 @@ function ThreadListBody({
364
398
  applyToggle(toggleChip(row, prefs, forest!));
365
399
  },
366
400
  onToggleOlder: (row) => {
367
- const { prefs, forest, view } = latest.current;
368
- const group = view!.groups.find((candidate) => candidate.descriptor.id === row.scopeId) ?? null;
369
- applyToggle(toggleOlder(row, prefs, group, forest!));
401
+ const { prefs, forest } = latest.current;
402
+ applyToggle(toggleOlder(row, prefs, forest!));
370
403
  },
371
- onOpenChildren: (rootId) => applyToggle(openChildren(rootId, latest.current.prefs)),
404
+ onToggleSettled: (row) => applyToggle(toggleSettled(row, latest.current.prefs)),
372
405
  onToggleEnvironment: (environmentId) => {
373
406
  const { collapsedEnvironments } = latest.current.prefs;
374
407
  update({
@@ -398,11 +431,8 @@ function ThreadListBody({
398
431
  built,
399
432
  isCompactViewport,
400
433
  client.density,
401
- prefs.showPullRequests,
402
434
  prefs.harnessIcon,
403
435
  prefs.organizationMode,
404
- defaultBranches,
405
- multiHost,
406
436
  providerDisplay,
407
437
  sidebar.sections,
408
438
  onNavigate,
@@ -414,54 +444,58 @@ function ThreadListBody({
414
444
  ]);
415
445
 
416
446
  const live: ListLive = useMemo(
417
- () => ({ now, stamps, notes, familyOf: (threadId) => forest?.familyOf.get(threadId) }),
447
+ () => ({ now, stamps, notes, treeOf: (threadId) => forest?.treeOf.get(threadId) }),
418
448
  [now, stamps, notes, forest],
419
449
  );
420
450
 
421
451
  const activeGroupId = useMemo(() => {
422
- const family = activeThreadId === null || forest === null ? undefined : forest.familyOf.get(activeThreadId);
423
- return family === undefined
452
+ const tree = activeThreadId === null || forest === null ? undefined : forest.treeOf.get(activeThreadId);
453
+ return tree === undefined
424
454
  ? null
425
- : groupIdForRoot(family.root.thread, { mode: prefs.organizationMode, projects: sidebar.projects });
455
+ : groupIdForRoot(tree.root.thread, { mode: prefs.organizationMode, projects: sidebar.projects });
426
456
  }, [activeThreadId, forest, prefs.organizationMode, sidebar.projects]);
427
- const showArchived = prefs.threadLifecycles.includes("archived");
457
+ const showArchived = prefs.showArchived;
428
458
 
429
- const groupController: GroupController | null = useMemo(() => {
430
- if (!built) return null;
431
- const markAll = (group: GroupView) => {
432
- const { forest, activeThreadId, stamps } = latest.current;
433
- // Folded roots count too: every family bucketed in the group.
434
- const families = group.rootIds.flatMap((id) => forest!.familyOf.get(id) ?? []);
435
- const plan = markAllReadPlan(families, {
436
- activeThreadId,
437
- finishedAt: stamps.finishedAt,
438
- seenAt: stamps.seenAt,
439
- });
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 });
440
464
  const run = () => {
441
465
  if (plan.seen.length > 0) markSeen(plan.seen);
442
466
  for (const id of plan.read) actions.setRead(id, true).catch(() => undefined);
443
467
  };
444
468
  if (plan.read.length === 0) {
445
- toast(`Nothing unread in ${group.descriptor.label}`);
469
+ toast(`Nothing unread in ${where}`);
446
470
  } else if (plan.read.length > MARK_ALL_CONFIRM_ABOVE) {
447
471
  setConfirm({
448
472
  title: `Mark ${plan.read.length} threads read?`,
449
- 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.`,
450
474
  confirmLabel: "Mark all read",
451
475
  run,
452
476
  });
453
477
  } else {
454
478
  run();
455
479
  }
456
- };
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
+ );
457
494
  return {
458
495
  compact: isCompactViewport,
459
496
  activeGroupId,
460
497
  showArchived,
461
- onToggleArchived: () =>
462
- update({
463
- threadLifecycles: latest.current.prefs.threadLifecycles.includes("archived") ? ["active"] : ["active", "archived"],
464
- }),
498
+ onToggleArchived: () => update({ showArchived: !latest.current.prefs.showArchived }),
465
499
  canCreateSections: prefs.organizationMode === "chronological",
466
500
  onToggleCollapse: (group) => {
467
501
  const { prefs, forest } = latest.current;
@@ -502,7 +536,7 @@ function ThreadListBody({
502
536
  onMarkAllRead: markAll,
503
537
  onNewSection: () => setNewSectionOpen(true),
504
538
  };
505
- }, [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]);
506
540
 
507
541
  const sensors = useSensors(
508
542
  useSensor(MouseSensor, MOUSE_SENSOR),
@@ -524,8 +558,7 @@ function ThreadListBody({
524
558
  }, []);
525
559
 
526
560
  const dropContext = useMemo(() => {
527
- // A row in Needs attention drops as it would in its home group.
528
- const groupOfThread = new Map<string, string>(Object.entries(view?.attention?.homeGroupIds ?? {}));
561
+ const groupOfThread = new Map<string, string>();
529
562
  for (const group of [...(view?.groups ?? []), ...(view?.more ?? [])]) {
530
563
  for (const row of group.rows) if (row.type === "thread") groupOfThread.set(row.info.thread.id, group.descriptor.id);
531
564
  }
@@ -679,24 +712,23 @@ function ThreadListBody({
679
712
  return (
680
713
  <ListLiveContext.Provider value={live}>
681
714
  <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} />
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} />
683
727
  {threads.length === 0 ? (
684
728
  // bb's own pinned New thread button covers the empty list.
685
729
  <p className="px-3 py-4 text-sm text-muted-foreground">No threads yet.</p>
686
730
  ) : (
687
731
  <DndContext sensors={sensors} collisionDetection={collision} onDragMove={onDragMove} onDragEnd={onDragEnd} onDragCancel={onDragCancel}>
688
- {view.attention !== null ? (
689
- <AttentionSection
690
- view={view.attention}
691
- rowController={rowController}
692
- environmentProviders={environmentProviders}
693
- dropStates={dropStates}
694
- activeThreadId={activeThreadId}
695
- editingId={editingId}
696
- now={now}
697
- stamps={stamps}
698
- />
699
- ) : null}
700
732
  {view.groups.map((group) => (
701
733
  <GroupSection
702
734
  key={group.descriptor.id}
@@ -824,7 +856,7 @@ function ThreadListBody({
824
856
  <ThreadDetails
825
857
  info={details}
826
858
  controller={rowController}
827
- showPullRequest={prefs.showPullRequests}
859
+ showPullRequest
828
860
  actions={{
829
861
  open: () => {
830
862
  setDetailsId(null);
@@ -19,12 +19,12 @@ import { chipLabel, rowAriaLabel } from "../model/labels";
19
19
  import { rowIndent } from "../model/layout";
20
20
  import { rowMenuItems } from "../model/menu";
21
21
  import { noteText } from "../model/notes";
22
- import { chipTone, pluginStatusWins } from "../model/state";
22
+ import { pluginStatusWins } from "../model/state";
23
23
  import { trailingTime } from "../model/time";
24
24
  import type { ThreadRow } from "../model/view";
25
25
  import type { DraggedThread } from "../model/drag";
26
26
  import type { RowController } from "./controller";
27
- import { CHIP_TONE_CLASS, FlagGlyph, GlyphIcon, NoteLine, PluginStatusGlyph } from "./glyphs";
27
+ import { ChildDot, GlyphIcon, NoteLine, PluginStatusGlyph } from "./glyphs";
28
28
  import { ProviderBadge } from "./ProviderBadge";
29
29
  import { PullRequestBadge } from "./PullRequestBadge";
30
30
  import { RenameEditor } from "./RenameEditor";
@@ -58,12 +58,11 @@ function swallowNextClick(): void {
58
58
  }
59
59
 
60
60
  /**
61
- * A quiet title, and its chip: the foreground mixed toward the surface under
62
- * the row (the sidebar, or the Needs attention band) in oklch, which keeps
63
- * 4.5:1 in both of bb's themes. Opacity blends in sRGB and lands lower in
64
- * the light theme.
61
+ * A quiet title, and its chip: the foreground mixed toward the sidebar in
62
+ * oklch, which keeps 4.5:1 in both of bb's themes. Opacity blends in sRGB and
63
+ * lands lower in the light theme.
65
64
  */
66
- const QUIET_TEXT = "text-[color:color-mix(in_oklch,var(--foreground)_var(--tg-quiet,68%),var(--tg-surface,var(--sidebar)))]";
65
+ const QUIET_TEXT = "text-[color:color-mix(in_oklch,var(--foreground)_68%,var(--sidebar))]";
67
66
 
68
67
  export const ROW_ICON_BUTTON =
69
68
  "pointer-events-auto relative z-10 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-state-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:bg-state-active";
@@ -175,7 +174,7 @@ export const ThreadRowView = memo(function ThreadRowView({
175
174
  );
176
175
  const menuItems = rowMenuItems({
177
176
  thread,
178
- unread: info.unread,
177
+ unread: row.depth === 0 ? row.treeUnread : info.unread,
179
178
  splitAvailable: split.isAvailable,
180
179
  isRoot: thread.parentThreadId === null,
181
180
  hasSections: controller.mode === "chronological" || controller.sections.length > 0,
@@ -219,10 +218,7 @@ export const ThreadRowView = memo(function ThreadRowView({
219
218
  controller.setEditingId(thread.id);
220
219
  };
221
220
 
222
- const defaultBranch = controller.showPullRequests && row.depth === 0 ? controller.defaultBranchOf(thread) : undefined;
223
- const branch = thread.environment?.branchName ?? null;
224
- const showPullRequest =
225
- controller.showPullRequests && row.depth === 0 && branch !== null && defaultBranch !== undefined && branch !== defaultBranch;
221
+ const showPullRequest = row.pullRequest !== null;
226
222
 
227
223
  const stateSlot = miniMap ? (
228
224
  <SplitMiniMap panes={miniMap} label={`${thread.displayTitle} — open in split; ${info.state.label}`} working={info.flags.has("working")} />
@@ -300,7 +296,7 @@ export const ThreadRowView = memo(function ThreadRowView({
300
296
  const chip = row.chip;
301
297
  const indent = rowIndent(row.depth);
302
298
  const note = row.note;
303
- const twoLines = controller.comfortable || note !== null;
299
+ const twoLines = note !== null || row.branchLine !== null;
304
300
  const dimmed = row.dimmed && !editing;
305
301
  const menuShowing = menuOpen || contextOpen;
306
302
  // Desktop: the actions cross-fade over the harness and age, as bb's
@@ -366,7 +362,10 @@ export const ThreadRowView = memo(function ThreadRowView({
366
362
  }}
367
363
  className="absolute inset-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
368
364
  />
369
- <span className="pointer-events-none relative flex size-4 shrink-0 items-center justify-center">{stateSlot}</span>
365
+ <span className="pointer-events-none relative flex size-4 shrink-0 items-center justify-center">
366
+ {stateSlot}
367
+ {row.childDot !== null ? <ChildDot flag={row.childDot} /> : null}
368
+ </span>
370
369
  {row.nested ? (
371
370
  // Tight against the title, and over the row's gap, so it adds 8px.
372
371
  <span
@@ -404,27 +403,31 @@ export const ThreadRowView = memo(function ThreadRowView({
404
403
  }}
405
404
  />
406
405
  ) : (
407
- <span
408
- title={thread.displayTitle}
409
- className={cn(
410
- "min-w-0 truncate",
411
- row.bold ? "font-semibold" : "font-normal",
412
- // Children sit a step below their parent.
413
- row.depth > 0 && "text-xs",
414
- // Quiet threads step back so live ones lead; hover brings them back.
415
- dimmed && `${QUIET_TEXT} group-hover/row:text-foreground`,
416
- )}
417
- >
418
- {/* Plain text: mention pills lost the truncation fight. */}
419
- {thread.displayTitle}
406
+ <span className="flex min-w-0 items-center gap-1.5">
407
+ <span
408
+ title={thread.displayTitle}
409
+ className={cn(
410
+ "min-w-0 truncate",
411
+ row.bold ? "font-semibold" : "font-normal",
412
+ // Children sit a step below their parent.
413
+ row.depth > 0 && "text-xs",
414
+ // Quiet threads step back so live ones lead; hover brings them back.
415
+ dimmed && `${QUIET_TEXT} group-hover/row:text-foreground`,
416
+ )}
417
+ >
418
+ {/* Plain text: mention pills lost the truncation fight. */}
419
+ {thread.displayTitle}
420
+ </span>
421
+ {/* On a two-line row the badge stays on the title's line, not centred beside both. */}
422
+ {row.pullRequest === "title" && twoLines ? <PullRequestBadge threadId={thread.id} /> : null}
420
423
  </span>
421
424
  )}
422
425
  {!editing && note !== null ? (
423
426
  <span className="min-w-0 truncate text-xs leading-4 text-muted-foreground" title={noteText(note)}>
424
427
  <NoteLine note={note} />
425
428
  </span>
426
- ) : controller.comfortable && !editing ? (
427
- <SecondLine row={row} multiHost={controller.multiHost} defaultBranch={controller.defaultBranchOf(thread)} />
429
+ ) : row.branchLine !== null && !editing ? (
430
+ <BranchLine row={row} branch={row.branchLine} />
428
431
  ) : null}
429
432
  </span>
430
433
  {row.hiddenBadge ? (
@@ -432,7 +435,7 @@ export const ThreadRowView = memo(function ThreadRowView({
432
435
  <Icon name={ICONS.hidden} aria-hidden className="size-3.5" />
433
436
  </span>
434
437
  ) : null}
435
- {showPullRequest && !editing ? (
438
+ {row.pullRequest === "title" && !twoLines && !editing ? (
436
439
  <span className="pointer-events-none relative">
437
440
  <PullRequestBadge threadId={thread.id} />
438
441
  </span>
@@ -441,8 +444,8 @@ export const ThreadRowView = memo(function ThreadRowView({
441
444
  <button
442
445
  type="button"
443
446
  aria-expanded={chip.expanded}
444
- aria-label={chipLabel(thread.displayTitle, chip.count, chip.flag, chip.expanded)}
445
- title={chipLabel(thread.displayTitle, chip.count, chip.flag, chip.expanded)}
447
+ aria-label={chipLabel(thread.displayTitle, chip.count, chip.expanded)}
448
+ title={chipLabel(thread.displayTitle, chip.count, chip.expanded)}
446
449
  onClick={(event) => {
447
450
  event.preventDefault();
448
451
  event.stopPropagation();
@@ -450,20 +453,12 @@ export const ThreadRowView = memo(function ThreadRowView({
450
453
  }}
451
454
  onPointerDown={(event) => event.stopPropagation()}
452
455
  onKeyDown={(event) => event.stopPropagation()}
453
- data-tone={chipTone(chip.flag)}
454
456
  className={cn(
455
- "pointer-events-auto relative z-10 inline-flex h-5 shrink-0 items-center gap-0.5 rounded-md border px-1 text-[11px] leading-none tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
456
- CHIP_TONE_CLASS[chipTone(chip.flag)],
457
+ "pointer-events-auto relative z-10 inline-flex h-5 shrink-0 items-center gap-0.5 rounded-md px-0.5 text-[11px] leading-none tabular-nums text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring",
457
458
  dimmed && QUIET_TEXT,
458
459
  )}
459
460
  >
460
461
  {chip.count}
461
- {chip.flag !== null ? <FlagGlyph flag={chip.flag} className="size-3" /> : null}
462
- {controller.harnessIcon !== "hidden"
463
- ? chip.providerIds.map((providerId) => (
464
- <ProviderBadge key={providerId} display={controller.provider(providerId)} className="size-3 [&_*]:size-3" />
465
- ))
466
- : null}
467
462
  <Icon
468
463
  name={ICONS.expand}
469
464
  aria-hidden
@@ -471,7 +466,7 @@ export const ThreadRowView = memo(function ThreadRowView({
471
466
  />
472
467
  </button>
473
468
  ) : null}
474
- {!editing ? (
469
+ {!editing && row.harness ? (
475
470
  <span
476
471
  className={cn(
477
472
  "pointer-events-none relative inline-flex shrink-0 transition-opacity",
@@ -490,14 +485,16 @@ export const ThreadRowView = memo(function ThreadRowView({
490
485
  </kbd>
491
486
  ) : (
492
487
  <>
493
- {row.homeGroupLabel !== null ? (
488
+ {row.machine !== null ? (
494
489
  <span
495
- title={`In ${row.homeGroupLabel}`}
496
- className={cn("pointer-events-none max-w-24 truncate text-[11px] text-muted-foreground transition-opacity", fadeClass)}
490
+ title={`On ${row.machine}`}
491
+ aria-label={`On ${row.machine}`}
492
+ className={cn("pointer-events-none max-w-20 truncate text-[11px] text-muted-foreground transition-opacity", fadeClass)}
497
493
  >
498
- {row.homeGroupLabel}
494
+ {row.machine}
499
495
  </span>
500
- ) : time !== null ? (
496
+ ) : null}
497
+ {time !== null ? (
501
498
  <span
502
499
  title={time.label}
503
500
  aria-label={time.label}
@@ -521,6 +518,22 @@ export const ThreadRowView = memo(function ThreadRowView({
521
518
  : "absolute right-0 opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100",
522
519
  )}
523
520
  >
521
+ {!compact && row.treeUnread ? (
522
+ <button
523
+ type="button"
524
+ aria-label="Mark read"
525
+ title="Mark read"
526
+ className={ROW_ICON_BUTTON}
527
+ onPointerDown={(event) => event.stopPropagation()}
528
+ onClick={(event) => {
529
+ event.preventDefault();
530
+ event.stopPropagation();
531
+ controller.onMenuAction("mark-read", thread);
532
+ }}
533
+ >
534
+ <Icon name={ICONS.markRead} aria-hidden className="size-4" />
535
+ </button>
536
+ ) : null}
524
537
  {!compact ? (
525
538
  <button
526
539
  type="button"
@@ -601,38 +614,17 @@ export const ThreadRowView = memo(function ThreadRowView({
601
614
  );
602
615
  });
603
616
 
604
- function SecondLine({
605
- row,
606
- multiHost,
607
- defaultBranch,
608
- }: {
609
- row: ThreadRow;
610
- multiHost: boolean;
611
- defaultBranch: string | null | undefined;
612
- }) {
613
- const thread = row.info.thread;
614
- const environment = thread.environment;
615
- const parts: React.ReactNode[] = [];
616
- // The branch earns the line only when it isn't the project's default:
617
- // "main" on every row said nothing.
618
- const branch = environment?.branchName ?? null;
619
- if (branch !== null && branch !== defaultBranch) {
620
- parts.push(
621
- <span key="branch" className="inline-flex min-w-0 items-center gap-0.5">
617
+ /** The Comfortable second line: the branch, then its pull request badge. */
618
+ function BranchLine({ row, branch }: { row: ThreadRow; branch: string }) {
619
+ const environment = row.info.thread.environment;
620
+ return (
621
+ <span className="flex min-w-0 items-center gap-1.5 text-xs leading-4 text-muted-foreground">
622
+ <span className="inline-flex min-w-0 items-center gap-0.5">
622
623
  <Icon name={environment?.isWorktree ? ICONS.worktree : ICONS.branch} aria-hidden className="size-3 shrink-0" />
623
624
  <span className="truncate">{branch}</span>
624
625
  {environment?.isWorktree ? <span className="sr-only"> (worktree)</span> : null}
625
- </span>,
626
- );
627
- }
628
- if (multiHost && thread.host !== null) {
629
- parts.push(
630
- <span key="host" className="inline-flex shrink-0 items-center gap-0.5">
631
- <Icon name={ICONS.machine} aria-hidden className="size-3" />
632
- {thread.host.name}
633
- </span>,
634
- );
635
- }
636
- if (parts.length === 0) return null;
637
- return <span className="flex min-w-0 items-center gap-2 text-xs leading-4 text-muted-foreground">{parts}</span>;
626
+ </span>
627
+ {row.pullRequest === "second-line" ? <PullRequestBadge threadId={row.info.thread.id} /> : null}
628
+ </span>
629
+ );
638
630
  }