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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +2 -2
  2. package/dist/app.css +1 -1
  3. package/dist/app.js +29 -29
  4. package/dist/app.meta.json +1 -1
  5. package/dist/server.js +1 -1
  6. package/dist/server.js.map +2 -2
  7. package/dist/server.meta.json +1 -1
  8. package/features/thread-list/components/FoldRows.tsx +12 -11
  9. package/features/thread-list/components/GroupSection.tsx +135 -104
  10. package/features/thread-list/components/ThreadDetails.tsx +2 -3
  11. package/features/thread-list/components/ThreadList.tsx +12 -11
  12. package/features/thread-list/components/ThreadRowView.tsx +23 -19
  13. package/features/thread-list/components/Toolbar.tsx +1 -1
  14. package/features/thread-list/components/controller.ts +2 -0
  15. package/features/thread-list/components/glyphs.tsx +19 -1
  16. package/features/thread-list/data/useAttentionHold.ts +19 -0
  17. package/features/thread-list/model/{needs-you.ts → attention.ts} +48 -28
  18. package/features/thread-list/model/counters.ts +3 -3
  19. package/features/thread-list/model/details.ts +1 -1
  20. package/features/thread-list/model/expansion.ts +10 -10
  21. package/features/thread-list/model/families.ts +26 -22
  22. package/features/thread-list/model/layout.ts +0 -43
  23. package/features/thread-list/model/notes.ts +37 -4
  24. package/features/thread-list/model/settings.ts +1 -1
  25. package/features/thread-list/model/share.ts +3 -3
  26. package/features/thread-list/model/state.ts +1 -1
  27. package/features/thread-list/model/toggles.ts +7 -5
  28. package/features/thread-list/model/view.ts +96 -56
  29. package/features/thread-list/testing/fixtures.ts +8 -4
  30. package/package.json +1 -1
  31. package/server/cli.ts +1 -1
  32. package/shared/preferences.ts +2 -2
  33. package/skills/thread-glance/SKILL.md +4 -4
  34. package/features/thread-list/components/Rails.tsx +0 -27
  35. package/features/thread-list/data/useNeedsYouHold.ts +0 -19
@@ -3,7 +3,7 @@
3
3
  "sdkVersion": "0.5.9",
4
4
  "artifactFormatVersion": 1,
5
5
  "pluginId": "thread-glance",
6
- "pluginVersion": "0.2.1",
6
+ "pluginVersion": "0.2.3",
7
7
  "builtWith": {
8
8
  "bbVersion": "0.43.4",
9
9
  "pluginSdkVersion": "0.5.9"
@@ -1,5 +1,5 @@
1
1
  // The fold rows (`N older` on a group, `N more child threads` on a family),
2
- // the "+N more" line in Needs you, and the environment folder row.
2
+ // the "+N more" line in Needs attention, and the environment folder row.
3
3
  import { memo, useState } from "react";
4
4
  import { experimental_Icon as Icon, experimental_ProviderIcon as ProviderIcon } from "@get-bb/plugin-sdk/app";
5
5
  import type { PluginEnvironmentProvider } from "@get-bb/plugin-sdk/app";
@@ -11,7 +11,6 @@ import {
11
11
  } from "@/components/ui/dropdown-menu";
12
12
  import { cn } from "@/lib/utils";
13
13
  import { ICONS } from "../icons";
14
- import { RowRails } from "./Rails";
15
14
  import { olderRowText } from "../model/labels";
16
15
  import { rowIndent } from "../model/layout";
17
16
  import type { EnvironmentRow, LeftOutRow, OlderRow } from "../model/view";
@@ -37,7 +36,6 @@ export const OlderRowView = memo(function OlderRowView({ row, controller }: { ro
37
36
  )}
38
37
  style={{ paddingLeft: rowIndent(row.depth) + (inFamily ? 0 : 2) }}
39
38
  >
40
- <RowRails rails={row.rails} />
41
39
  {inFamily ? (
42
40
  <span aria-hidden className="inline-flex size-4 shrink-0 items-center justify-center">
43
41
  <Icon name={ICONS.more} className="size-3.5" />
@@ -50,18 +48,22 @@ export const OlderRowView = memo(function OlderRowView({ row, controller }: { ro
50
48
  );
51
49
  });
52
50
 
53
- /** The "+N more" line under a family in Needs you: a count, not a control. */
54
- export const LeftOutRowView = memo(function LeftOutRowView({ row }: { row: LeftOutRow }) {
51
+ /** The "+N more" line under a closed family in Needs attention: it opens the family, as the chip does. */
52
+ export const LeftOutRowView = memo(function LeftOutRowView({ row, controller }: { row: LeftOutRow; controller: RowController }) {
55
53
  const noun = row.count === 1 ? "child thread" : "child threads";
56
54
  return (
57
- <div
58
- className="relative flex h-6 w-full items-center gap-1.5 pr-2 text-xs text-muted-foreground"
55
+ <button
56
+ type="button"
57
+ aria-expanded={false}
58
+ aria-label={`Show ${row.count} more ${noun}`}
59
+ title={`Show ${row.count} more ${noun}`}
60
+ onClick={() => controller.onOpenChildren(row.scopeId)}
61
+ className="relative flex h-6 w-full items-center gap-1.5 rounded-md pr-2 text-left text-xs text-muted-foreground outline-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring"
59
62
  style={{ paddingLeft: rowIndent(row.depth) }}
60
63
  >
61
- <RowRails rails={row.rails} />
62
64
  <span aria-hidden className="size-4 shrink-0" />
63
- <span title={`${row.count} more ${noun}`}>+{row.count} more</span>
64
- </div>
65
+ +{row.count} more
66
+ </button>
65
67
  );
66
68
  });
67
69
 
@@ -84,7 +86,6 @@ export const EnvironmentRowView = memo(function EnvironmentRowView({
84
86
  className="group/row relative flex h-7 w-full items-center gap-1.5 rounded-md pr-1 text-sm text-muted-foreground hover:bg-sidebar-accent"
85
87
  style={{ paddingLeft: rowIndent(row.depth) }}
86
88
  >
87
- <RowRails rails={row.rails} />
88
89
  <button
89
90
  type="button"
90
91
  aria-expanded={!row.collapsed}
@@ -1,5 +1,5 @@
1
1
  // A top-level group: its header with counters, and its rows, windowed
2
- // in chunks; and the Needs you section above every group.
2
+ // in chunks; and the Needs attention section above every group.
3
3
  import { memo, useEffect, useRef, useState } from "react";
4
4
  import { useDraggable, useDroppable } from "@dnd-kit/core";
5
5
  import { experimental_Icon as Icon } from "@get-bb/plugin-sdk/app";
@@ -14,7 +14,7 @@ import {
14
14
  } from "@/components/ui/dropdown-menu";
15
15
  import { cn } from "@/lib/utils";
16
16
  import { ICONS } from "../icons";
17
- import { NEEDS_YOU_GROUP_ID, type GroupView, type NeedsYouView, type Row } from "../model/view";
17
+ import { ATTENTION_GROUP_ID, type GroupView, type AttentionView, type Row } from "../model/view";
18
18
  import { chunk, windowedNavValue } from "../model/windowing";
19
19
  import type { RowController } from "./controller";
20
20
  import { EnvironmentRowView, LeftOutRowView, OlderRowView } from "./FoldRows";
@@ -63,15 +63,31 @@ const GroupHeader = memo(function GroupHeader({
63
63
  dropActive: boolean;
64
64
  }) {
65
65
  const [renaming, setRenaming] = useState(false);
66
+ const [menuOpen, setMenuOpen] = useState(false);
66
67
  const label = group.descriptor.label;
67
68
  const draggable = useDraggable({
68
69
  id: `group:${group.descriptor.id}`,
69
70
  data: { kind: "group", groupId: group.descriptor.id },
70
71
  disabled: controller.compact || inOverflow || renaming,
71
72
  });
72
- const actionsClass = controller.compact
73
+ const compact = controller.compact;
74
+ // Desktop: the counter sits flush right, and "+" and "…" fade in over its
75
+ // place on hover or focus, as a row's actions fade over its age. Nothing is
76
+ // kept for them otherwise. Phones keep them in line, always shown.
77
+ const counterFade = compact
73
78
  ? ""
74
- : "opacity-0 group-hover/header:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100";
79
+ : menuOpen
80
+ ? "opacity-0"
81
+ : "group-hover/header:opacity-0 group-focus-within/header:opacity-0";
82
+ const actionsFade = compact
83
+ ? ""
84
+ : cn(
85
+ "absolute top-1/2 right-0 -translate-y-1/2 pl-1",
86
+ dropActive ? "bg-sidebar-accent" : "bg-sidebar",
87
+ menuOpen
88
+ ? "opacity-100"
89
+ : "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",
90
+ );
75
91
  return (
76
92
  <div
77
93
  ref={draggable.setNodeRef}
@@ -117,98 +133,96 @@ const GroupHeader = memo(function GroupHeader({
117
133
  </button>
118
134
  )}
119
135
  {!renaming ? (
120
- <CounterStrip
121
- counters={visibleCounters(group.counters, { collapsed: group.collapsed, more: false })}
122
- className="ml-auto"
123
- />
124
- ) : null}
125
- {!renaming &&
126
- group.descriptor.kind !== "pinned" &&
127
- (group.descriptor.newThreadProjectId !== null || group.descriptor.kind === "machine") &&
128
- (!controller.compact || controller.activeGroupId === group.descriptor.id) ? (
129
- <button
130
- type="button"
131
- aria-label={`New thread in ${label}`}
132
- title={`New thread in ${label}`}
133
- data-sidebar-hover-actions-mobile={controller.compact ? "always" : undefined}
134
- className={cn(ROW_ICON_BUTTON, actionsClass)}
135
- onPointerDown={(event) => event.stopPropagation()}
136
- onClick={() => controller.onNewThread(group)}
137
- >
138
- <Icon name={ICONS.newThread} aria-hidden className="size-4" />
139
- </button>
140
- ) : !renaming && !controller.compact ? (
141
- // Keeps every header's counters on one edge.
142
- <span aria-hidden className="size-6 shrink-0" />
143
- ) : null}
144
- {!renaming ? (
145
- <DropdownMenu>
146
- <DropdownMenuTrigger asChild>
147
- <button
148
- type="button"
149
- aria-label={`${label} actions`}
150
- className={cn(ROW_ICON_BUTTON, actionsClass)}
151
- onPointerDown={(event) => event.stopPropagation()}
152
- >
153
- <Icon name={ICONS.more} aria-hidden className="size-4" />
154
- </button>
155
- </DropdownMenuTrigger>
156
- <DropdownMenuContent align="end" className="min-w-48">
157
- <DropdownMenuItem onSelect={() => controller.onMarkAllRead(group)}>
158
- <Icon name={ICONS.markRead} aria-hidden className="size-4" />
159
- Mark all read
160
- </DropdownMenuItem>
161
- {group.descriptor.kind !== "pinned" && group.descriptor.newThreadProjectId !== null ? (
162
- <DropdownMenuItem onSelect={() => controller.onNewThread(group)}>
136
+ <span className="relative ml-auto flex shrink-0 items-center">
137
+ <CounterStrip
138
+ counters={visibleCounters(group.counters, { collapsed: group.collapsed, more: false })}
139
+ className={cn("transition-opacity", counterFade)}
140
+ />
141
+ <span className={cn("flex items-center transition-opacity", actionsFade)}>
142
+ {group.descriptor.kind !== "pinned" &&
143
+ (group.descriptor.newThreadProjectId !== null || group.descriptor.kind === "machine") &&
144
+ (!controller.compact || controller.activeGroupId === group.descriptor.id) ? (
145
+ <button
146
+ type="button"
147
+ aria-label={`New thread in ${label}`}
148
+ title={`New thread in ${label}`}
149
+ data-sidebar-hover-actions-mobile={controller.compact ? "always" : undefined}
150
+ className={ROW_ICON_BUTTON}
151
+ onPointerDown={(event) => event.stopPropagation()}
152
+ onClick={() => controller.onNewThread(group)}
153
+ >
163
154
  <Icon name={ICONS.newThread} aria-hidden className="size-4" />
164
- New thread
165
- </DropdownMenuItem>
155
+ </button>
166
156
  ) : null}
167
- {controller.canCreateSections ? (
168
- <DropdownMenuItem onSelect={() => controller.onNewSection()}>
169
- <Icon name={ICONS.newSection} aria-hidden className="size-4" />
170
- New section
171
- </DropdownMenuItem>
172
- ) : null}
173
- {canRename(group) ? (
174
- <DropdownMenuItem onSelect={() => setRenaming(true)}>
175
- <Icon name={ICONS.rename} aria-hidden className="size-4" />
176
- Rename
177
- </DropdownMenuItem>
178
- ) : null}
179
- <DropdownMenuSeparator />
180
- {group.descriptor.kind !== "pinned" ? (
181
- group.hidden ? (
182
- <DropdownMenuItem onSelect={() => controller.onShow(group)}>
183
- <Icon name={ICONS.show} aria-hidden className="size-4" />
184
- Show in list
157
+ <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
158
+ <DropdownMenuTrigger asChild>
159
+ <button
160
+ type="button"
161
+ aria-label={`${label} actions`}
162
+ className={ROW_ICON_BUTTON}
163
+ onPointerDown={(event) => event.stopPropagation()}
164
+ >
165
+ <Icon name={ICONS.more} aria-hidden className="size-4" />
166
+ </button>
167
+ </DropdownMenuTrigger>
168
+ <DropdownMenuContent align="end" className="min-w-48">
169
+ <DropdownMenuItem onSelect={() => controller.onMarkAllRead(group)}>
170
+ <Icon name={ICONS.markRead} aria-hidden className="size-4" />
171
+ Mark all read
185
172
  </DropdownMenuItem>
186
- ) : (
187
- <DropdownMenuItem onSelect={() => controller.onHide(group)}>
188
- <Icon name={ICONS.hidden} aria-hidden className="size-4" />
189
- Hide from list
173
+ {group.descriptor.kind !== "pinned" && group.descriptor.newThreadProjectId !== null ? (
174
+ <DropdownMenuItem onSelect={() => controller.onNewThread(group)}>
175
+ <Icon name={ICONS.newThread} aria-hidden className="size-4" />
176
+ New thread
177
+ </DropdownMenuItem>
178
+ ) : null}
179
+ {controller.canCreateSections ? (
180
+ <DropdownMenuItem onSelect={() => controller.onNewSection()}>
181
+ <Icon name={ICONS.newSection} aria-hidden className="size-4" />
182
+ New section
183
+ </DropdownMenuItem>
184
+ ) : null}
185
+ {canRename(group) ? (
186
+ <DropdownMenuItem onSelect={() => setRenaming(true)}>
187
+ <Icon name={ICONS.rename} aria-hidden className="size-4" />
188
+ Rename
189
+ </DropdownMenuItem>
190
+ ) : null}
191
+ <DropdownMenuSeparator />
192
+ {group.descriptor.kind !== "pinned" ? (
193
+ group.hidden ? (
194
+ <DropdownMenuItem onSelect={() => controller.onShow(group)}>
195
+ <Icon name={ICONS.show} aria-hidden className="size-4" />
196
+ Show in list
197
+ </DropdownMenuItem>
198
+ ) : (
199
+ <DropdownMenuItem onSelect={() => controller.onHide(group)}>
200
+ <Icon name={ICONS.hidden} aria-hidden className="size-4" />
201
+ Hide from list
202
+ </DropdownMenuItem>
203
+ )
204
+ ) : null}
205
+ <DropdownMenuItem onSelect={() => controller.onToggleArchived()}>
206
+ <Icon name={controller.showArchived ? ICONS.check : ICONS.archive} aria-hidden className="size-4" />
207
+ Show archived threads
190
208
  </DropdownMenuItem>
191
- )
192
- ) : null}
193
- <DropdownMenuItem onSelect={() => controller.onToggleArchived()}>
194
- <Icon name={controller.showArchived ? ICONS.check : ICONS.archive} aria-hidden className="size-4" />
195
- Show archived threads
196
- </DropdownMenuItem>
197
- <DropdownMenuItem onSelect={() => controller.onCustomize()}>
198
- <Icon name={ICONS.customize} aria-hidden className="size-4" />
199
- Customize list
200
- </DropdownMenuItem>
201
- {group.descriptor.kind === "section" ? (
202
- <DropdownMenuItem
203
- className="text-destructive focus:text-destructive"
204
- onSelect={() => controller.onRemove(group)}
205
- >
206
- <Icon name={ICONS.remove} aria-hidden className="size-4" />
207
- Remove section
208
- </DropdownMenuItem>
209
- ) : null}
210
- </DropdownMenuContent>
211
- </DropdownMenu>
209
+ <DropdownMenuItem onSelect={() => controller.onCustomize()}>
210
+ <Icon name={ICONS.customize} aria-hidden className="size-4" />
211
+ Customize list
212
+ </DropdownMenuItem>
213
+ {group.descriptor.kind === "section" ? (
214
+ <DropdownMenuItem
215
+ className="text-destructive focus:text-destructive"
216
+ onSelect={() => controller.onRemove(group)}
217
+ >
218
+ <Icon name={ICONS.remove} aria-hidden className="size-4" />
219
+ Remove section
220
+ </DropdownMenuItem>
221
+ ) : null}
222
+ </DropdownMenuContent>
223
+ </DropdownMenu>
224
+ </span>
225
+ </span>
212
226
  ) : null}
213
227
  </div>
214
228
  );
@@ -333,7 +347,7 @@ function Rows({
333
347
  ) : row.type === "older" ? (
334
348
  <OlderRowView key={row.key} row={row} controller={rowController} />
335
349
  ) : row.type === "left-out" ? (
336
- <LeftOutRowView key={row.key} row={row} />
350
+ <LeftOutRowView key={row.key} row={row} controller={rowController} />
337
351
  ) : (
338
352
  <EnvironmentRowView
339
353
  key={row.key}
@@ -382,7 +396,7 @@ export const GroupSection = memo(function GroupSection({
382
396
  dropActive={dropTargetGroupId === group.descriptor.id}
383
397
  />
384
398
  {group.collapsed ? null : group.rows.length === 0 ? (
385
- // A group whose every family sits in Needs you draws only its header.
399
+ // A group whose every family sits in Needs attention draws only its header.
386
400
  group.rootIds.length === 0 ? <p className="py-1 pl-8 text-xs text-muted-foreground">No threads</p> : null
387
401
  ) : (
388
402
  <Rows
@@ -397,23 +411,40 @@ export const GroupSection = memo(function GroupSection({
397
411
  );
398
412
  });
399
413
 
414
+ // bb's attention colour, thinned over the sidebar: the one colour the section
415
+ // adds. Opaque, so the sticky header hides the rows scrolling under it. Mixed
416
+ // in oklab: the sidebar's grey has hue 0 in oklch, which would pull the tint
417
+ // toward pink. Set as the surface quiet titles mix toward; the band is
418
+ // lighter than the sidebar in the dark theme, so they keep 70% of the
419
+ // foreground there to stay above 4.5:1 (68% measured 4.44:1).
420
+ const ATTENTION_BAND =
421
+ "[--tg-surface:color-mix(in_oklab,var(--attention)_8%,var(--sidebar))] [--tg-quiet:70%] bg-[var(--tg-surface)]";
422
+ const ATTENTION_COUNT =
423
+ "bg-[color-mix(in_oklab,var(--attention)_20%,transparent)] text-[color:color-mix(in_oklab,var(--attention),var(--foreground)_55%)]";
424
+
400
425
  /**
401
- * The Needs you section: a header with its family count, then its rows. It
402
- * has no collapse, menu or drag, and no drop target of its own.
426
+ * The Needs attention section: a header with its family count (none when every
427
+ * family in it is attended), then its rows, on one band of bb's attention
428
+ * colour. It has no collapse, menu or drag, and no drop target of its own.
403
429
  */
404
- export const NeedsYouSection = memo(function NeedsYouSection({ view, ...rest }: SectionProps & { view: NeedsYouView }) {
430
+ export const AttentionSection = memo(function AttentionSection({ view, ...rest }: SectionProps & { view: AttentionView }) {
405
431
  return (
406
- <section aria-label="Needs you" className="relative flex w-full min-w-0 flex-col">
432
+ <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)}>
407
433
  <h2
408
434
  style={{ top: TOOLBAR_HEIGHT }}
409
- className="sticky z-20 flex h-7 items-center gap-1 bg-sidebar pl-2 pr-2 text-xs font-medium text-muted-foreground max-md:pointer-coarse:h-9"
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"
410
436
  >
411
- <span className="min-w-0 flex-1 truncate">Needs you</span>
412
- <span className="tabular-nums" aria-label={`${view.familyCount} ${view.familyCount === 1 ? "family" : "families"}`}>
413
- {view.familyCount}
414
- </span>
437
+ <span className="min-w-0 flex-1 truncate">Needs attention</span>
438
+ {view.familyCount > 0 ? (
439
+ <span
440
+ className={cn("rounded-full px-1.5 text-[11px] leading-4 tabular-nums", ATTENTION_COUNT)}
441
+ aria-label={`${view.familyCount} ${view.familyCount === 1 ? "family" : "families"}`}
442
+ >
443
+ {view.familyCount}
444
+ </span>
445
+ ) : null}
415
446
  </h2>
416
- <Rows {...rest} rows={view.rows} groupId={NEEDS_YOU_GROUP_ID} inPinned={false} forceMount={false} />
447
+ <Rows {...rest} rows={view.rows} groupId={ATTENTION_GROUP_ID} inPinned={false} forceMount={false} />
417
448
  </section>
418
449
  );
419
450
  });
@@ -8,7 +8,7 @@ import { lastReply } from "../model/notes";
8
8
  import { finishedAtFor, stateSince } from "../model/time";
9
9
  import { ICONS } from "../icons";
10
10
  import { ListLiveContext, type ModelInfo, type RowController } from "./controller";
11
- import { GlyphIcon } from "./glyphs";
11
+ import { GlyphIcon, NoteLine } from "./glyphs";
12
12
  import { ProviderBadge } from "./ProviderBadge";
13
13
  import { pullRequestLabel, pullRequestTone } from "./PullRequestBadge";
14
14
 
@@ -87,8 +87,7 @@ export function ThreadDetails({
87
87
  </div>
88
88
  {info.note !== null ? (
89
89
  <p className="break-words leading-snug">
90
- <span className={info.note.tone === "destructive" ? "text-destructive" : "text-attention"}>{info.note.prefix}:</span>{" "}
91
- {info.note.text}
90
+ <NoteLine note={info.note} />
92
91
  </p>
93
92
  ) : null}
94
93
  <dl className="flex flex-col gap-1">
@@ -29,7 +29,7 @@ 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 { useNeedsYouHold } from "../data/useNeedsYouHold";
32
+ import { useAttentionHold } from "../data/useAttentionHold";
33
33
  import { useNow } from "../data/useNow";
34
34
  import { usePreferences } from "../data/usePreferences";
35
35
  import { useScheduled } from "../data/useScheduled";
@@ -40,7 +40,7 @@ import { moveGroup, ORDER_PREFERENCE } from "../model/groups";
40
40
  import { MARK_ALL_CONFIRM_ABOVE, type RowMenuAction } from "../model/menu";
41
41
  import { assignProviderMarks, providerMark } from "../model/provider-mark";
42
42
  import { isDoneUnseen } from "../model/state";
43
- import { markAllReadPlan, toggleChip, toggleGroup, toggleOlder, type ToggleOutcome } from "../model/toggles";
43
+ import { markAllReadPlan, openChildren, toggleChip, toggleGroup, toggleOlder, type ToggleOutcome } from "../model/toggles";
44
44
  import { buildListView, type GroupView, type ListView } from "../model/view";
45
45
  import { shareView } from "../model/share";
46
46
  import { ListLiveContext, type ListLive, type ModelInfo, type RowController } from "./controller";
@@ -51,7 +51,7 @@ import { modelDisplayName } from "../model/details";
51
51
  import { groupIdForRoot } from "../model/groups";
52
52
  import { CounterStrip } from "./glyphs";
53
53
  import { cancelPendingCards } from "./row-card";
54
- import { GroupSection, NeedsYouSection, type DropStates, type GroupController } from "./GroupSection";
54
+ import { GroupSection, AttentionSection, type DropStates, type GroupController } from "./GroupSection";
55
55
  import type { ProviderDisplay } from "./ProviderBadge";
56
56
  import { ThreadDetails } from "./ThreadDetails";
57
57
  import { Toolbar } from "./Toolbar";
@@ -160,7 +160,7 @@ function ThreadListBody({
160
160
  [ready, threads, activeThreadId, stamps.finishedAt, stamps.seenAt, draftIds, scheduled, now, notes, prefs.childAttention],
161
161
  );
162
162
  const { targets, prune } = useAutoExpand(hydrated ? forest : null, activeThreadId);
163
- const heldRootId = useNeedsYouHold(forest, activeThreadId);
163
+ const held = useAttentionHold(forest, activeThreadId);
164
164
  // Rows and groups that did not change keep their objects, so their
165
165
  // memoized components skip the render.
166
166
  const previousView = useRef<ListView | null>(null);
@@ -175,10 +175,10 @@ function ThreadListBody({
175
175
  sections: sidebar.sections,
176
176
  prefs,
177
177
  activeThreadId,
178
- heldRootId,
178
+ held,
179
179
  targets,
180
180
  })),
181
- [forest, threads, sidebar.projects, sidebar.sections, prefs, activeThreadId, heldRootId, targets],
181
+ [forest, threads, sidebar.projects, sidebar.sections, prefs, activeThreadId, held, targets],
182
182
  );
183
183
  useLayoutEffect(() => {
184
184
  previousView.current = view;
@@ -368,6 +368,7 @@ function ThreadListBody({
368
368
  const group = view!.groups.find((candidate) => candidate.descriptor.id === row.scopeId) ?? null;
369
369
  applyToggle(toggleOlder(row, prefs, group, forest!));
370
370
  },
371
+ onOpenChildren: (rootId) => applyToggle(openChildren(rootId, latest.current.prefs)),
371
372
  onToggleEnvironment: (environmentId) => {
372
373
  const { collapsedEnvironments } = latest.current.prefs;
373
374
  update({
@@ -523,8 +524,8 @@ function ThreadListBody({
523
524
  }, []);
524
525
 
525
526
  const dropContext = useMemo(() => {
526
- // A row in Needs you drops as it would in its home group.
527
- const groupOfThread = new Map<string, string>(Object.entries(view?.needsYou?.homeGroupIds ?? {}));
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 ?? {}));
528
529
  for (const group of [...(view?.groups ?? []), ...(view?.more ?? [])]) {
529
530
  for (const row of group.rows) if (row.type === "thread") groupOfThread.set(row.info.thread.id, group.descriptor.id);
530
531
  }
@@ -684,9 +685,9 @@ function ThreadListBody({
684
685
  <p className="px-3 py-4 text-sm text-muted-foreground">No threads yet.</p>
685
686
  ) : (
686
687
  <DndContext sensors={sensors} collisionDetection={collision} onDragMove={onDragMove} onDragEnd={onDragEnd} onDragCancel={onDragCancel}>
687
- {view.needsYou !== null ? (
688
- <NeedsYouSection
689
- view={view.needsYou}
688
+ {view.attention !== null ? (
689
+ <AttentionSection
690
+ view={view.attention}
690
691
  rowController={rowController}
691
692
  environmentProviders={environmentProviders}
692
693
  dropStates={dropStates}
@@ -16,18 +16,18 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/h
16
16
  import { cn } from "@/lib/utils";
17
17
  import { ICONS } from "../icons";
18
18
  import { chipLabel, rowAriaLabel } from "../model/labels";
19
- import { rowIndent, titleTreatment } from "../model/layout";
19
+ import { rowIndent } from "../model/layout";
20
20
  import { rowMenuItems } from "../model/menu";
21
+ import { noteText } from "../model/notes";
21
22
  import { chipTone, pluginStatusWins } from "../model/state";
22
23
  import { trailingTime } from "../model/time";
23
24
  import type { ThreadRow } from "../model/view";
24
25
  import type { DraggedThread } from "../model/drag";
25
26
  import type { RowController } from "./controller";
26
- import { CHIP_TONE_CLASS, FlagGlyph, GlyphIcon, PluginStatusGlyph } from "./glyphs";
27
+ import { CHIP_TONE_CLASS, FlagGlyph, GlyphIcon, NoteLine, PluginStatusGlyph } from "./glyphs";
27
28
  import { ProviderBadge } from "./ProviderBadge";
28
29
  import { PullRequestBadge } from "./PullRequestBadge";
29
30
  import { RenameEditor } from "./RenameEditor";
30
- import { RowRails } from "./Rails";
31
31
  import { RowContextMenuContent, RowDropdownMenuContent, type ContextMenuInput } from "./RowMenu";
32
32
  import { SplitMiniMap, type MiniMapPane } from "./SplitMiniMap";
33
33
  import { ThreadDetails } from "./ThreadDetails";
@@ -57,6 +57,14 @@ function swallowNextClick(): void {
57
57
  });
58
58
  }
59
59
 
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.
65
+ */
66
+ const QUIET_TEXT = "text-[color:color-mix(in_oklch,var(--foreground)_var(--tg-quiet,68%),var(--tg-surface,var(--sidebar)))]";
67
+
60
68
  export const ROW_ICON_BUTTON =
61
69
  "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";
62
70
 
@@ -291,10 +299,9 @@ export const ThreadRowView = memo(function ThreadRowView({
291
299
 
292
300
  const chip = row.chip;
293
301
  const indent = rowIndent(row.depth);
294
- const title = titleTreatment(row.depth, { unread: info.unread, active: isActive });
295
- const twoLines = controller.comfortable || info.note !== null;
296
- // A parent keeps full colour so its children read as the quieter ones.
297
- const dimmed = info.quiet && !isActive && !editing && chip === null;
302
+ const note = row.note;
303
+ const twoLines = controller.comfortable || note !== null;
304
+ const dimmed = row.dimmed && !editing;
298
305
  const menuShowing = menuOpen || contextOpen;
299
306
  // Desktop: the actions cross-fade over the harness and age, as bb's
300
307
  // trailing slot does. Compact: "…" always shows beside them.
@@ -359,7 +366,6 @@ export const ThreadRowView = memo(function ThreadRowView({
359
366
  }}
360
367
  className="absolute inset-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
361
368
  />
362
- <RowRails rails={row.rails} />
363
369
  <span className="pointer-events-none relative flex size-4 shrink-0 items-center justify-center">{stateSlot}</span>
364
370
  {row.nested ? (
365
371
  // Tight against the title, and over the row's gap, so it adds 8px.
@@ -402,23 +408,20 @@ export const ThreadRowView = memo(function ThreadRowView({
402
408
  title={thread.displayTitle}
403
409
  className={cn(
404
410
  "min-w-0 truncate",
405
- info.unread ? "font-semibold" : "font-normal",
406
- // Read, idle threads step back so live ones lead.
407
- dimmed && !title.muted && "text-muted-foreground",
408
- // Children sit a step below their parent; hover and selection bring them back.
409
- title.small && "text-xs",
410
- // Foreground thinned toward the sidebar: at least 4.5:1 in both themes, and below a muted root.
411
- title.muted && "text-[color:color-mix(in_oklch,var(--foreground)_72%,var(--sidebar))] group-hover/row:text-foreground",
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`,
412
416
  )}
413
417
  >
414
418
  {/* Plain text: mention pills lost the truncation fight. */}
415
419
  {thread.displayTitle}
416
420
  </span>
417
421
  )}
418
- {!editing && info.note !== null ? (
419
- <span className="min-w-0 truncate text-xs leading-4 text-muted-foreground" title={`${info.note.prefix}: ${info.note.text}`}>
420
- <span className={info.note.tone === "destructive" ? "text-destructive" : "text-attention"}>{info.note.prefix}:</span>{" "}
421
- {info.note.text}
422
+ {!editing && note !== null ? (
423
+ <span className="min-w-0 truncate text-xs leading-4 text-muted-foreground" title={noteText(note)}>
424
+ <NoteLine note={note} />
422
425
  </span>
423
426
  ) : controller.comfortable && !editing ? (
424
427
  <SecondLine row={row} multiHost={controller.multiHost} defaultBranch={controller.defaultBranchOf(thread)} />
@@ -451,6 +454,7 @@ export const ThreadRowView = memo(function ThreadRowView({
451
454
  className={cn(
452
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",
453
456
  CHIP_TONE_CLASS[chipTone(chip.flag)],
457
+ dimmed && QUIET_TEXT,
454
458
  )}
455
459
  >
456
460
  {chip.count}
@@ -199,7 +199,7 @@ export function SettingsPanel({
199
199
  />
200
200
  </Line>
201
201
  <Toggle
202
- label="Needs you counts every child"
202
+ label="Needs attention counts every child"
203
203
  description="Every unread or failed child thread; otherwise only those blocked on you."
204
204
  checked={countsEveryChild(prefs.childAttention)}
205
205
  onChange={(value) => onPrefs({ childAttention: childAttentionFor(value) })}
@@ -33,6 +33,8 @@ export interface RowController {
33
33
  onNavigate(): void;
34
34
  onToggleChip(row: ThreadRow): void;
35
35
  onToggleOlder(row: OlderRow): void;
36
+ /** Opens a family's children from its "+N more" line in Needs attention. */
37
+ onOpenChildren(rootId: string): void;
36
38
  onToggleEnvironment(environmentId: string): void;
37
39
  onMenuAction(action: RowMenuAction, thread: PluginSidebarThread, sectionId?: string | null): void;
38
40
  onRename(threadId: string, title: string): Promise<void>;
@@ -4,6 +4,7 @@ import { experimental_Icon as Icon } from "@get-bb/plugin-sdk/app";
4
4
  import type { PluginSidebarThreadRowStatus } from "@get-bb/plugin-sdk/app";
5
5
  import { cn } from "@/lib/utils";
6
6
  import type { Counters } from "../model/counters";
7
+ import type { RowNote } from "../model/notes";
7
8
  import { FLAG_GLYPHS, type ChipTone, type Flag, type Glyph, type Tone } from "../model/state";
8
9
 
9
10
  export const TONE_CLASS: Record<Tone, string> = {
@@ -92,6 +93,23 @@ export function PluginStatusGlyph({ status }: { status: PluginSidebarThreadRowSt
92
93
  );
93
94
  }
94
95
 
96
+ /** A note's prefix takes the tone of the row's glyph for the same reason. */
97
+ const NOTE_TONE_CLASS: Record<RowNote["tone"], string> = {
98
+ attention: "text-attention",
99
+ destructive: "text-destructive",
100
+ muted: "text-muted-foreground",
101
+ };
102
+
103
+ /** "Failed: timeout", its prefix in the note's tone, or the prefix alone when there is no text. */
104
+ export function NoteLine({ note }: { note: RowNote }) {
105
+ return (
106
+ <>
107
+ <span className={NOTE_TONE_CLASS[note.tone]}>{note.text === "" ? note.prefix : `${note.prefix}:`}</span>
108
+ {note.text === "" ? null : ` ${note.text}`}
109
+ </>
110
+ );
111
+ }
112
+
95
113
  export function FlagGlyph({ flag, className }: { flag: Flag; className?: string }) {
96
114
  return <GlyphIcon glyph={FLAG_GLYPHS[flag]} className={className} />;
97
115
  }
@@ -101,7 +119,7 @@ const COUNTER_ITEMS: readonly {
101
119
  flag: Flag;
102
120
  label: (count: number) => string;
103
121
  }[] = [
104
- { key: "waitsOnYou", flag: "waits-on-you", label: (n) => `${n} need${n === 1 ? "s" : ""} you` },
122
+ { key: "waitsOnYou", flag: "waits-on-you", label: (n) => `${n} waiting on you` },
105
123
  { key: "failed", flag: "unread-failed", label: (n) => `${n} failed` },
106
124
  { key: "offline", flag: "offline", label: (n) => `${n} waiting for an offline machine` },
107
125
  { key: "working", flag: "working", label: (n) => `${n} working` },