@gperezmz/bb-plugin-thread-glance 0.1.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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +20 -0
  3. package/THIRD_PARTY_NOTICES.md +230 -0
  4. package/app.tsx +12 -0
  5. package/components/ui/alert-dialog.tsx +142 -0
  6. package/components/ui/button.tsx +59 -0
  7. package/components/ui/card.tsx +83 -0
  8. package/components/ui/coarse-pointer-sizing.ts +66 -0
  9. package/components/ui/context-menu.tsx +355 -0
  10. package/components/ui/dialog.tsx +416 -0
  11. package/components/ui/dropdown-menu.tsx +640 -0
  12. package/components/ui/hooks/use-compact-viewport.tsx +37 -0
  13. package/components/ui/hooks/use-media-query.ts +70 -0
  14. package/components/ui/hooks/use-pointer-coarse.ts +7 -0
  15. package/components/ui/hover-card.tsx +36 -0
  16. package/components/ui/icon-extended.tsx +333 -0
  17. package/components/ui/icon-registry.ts +189 -0
  18. package/components/ui/icon.tsx +444 -0
  19. package/components/ui/input.tsx +30 -0
  20. package/components/ui/menu-item-hover.tsx +93 -0
  21. package/components/ui/motion.ts +4 -0
  22. package/components/ui/overlay-trigger.ts +84 -0
  23. package/components/ui/popover.tsx +223 -0
  24. package/components/ui/responsive-overlay.tsx +823 -0
  25. package/components/ui/tooltip.tsx +63 -0
  26. package/dist/app.css +2 -0
  27. package/dist/app.js +66 -0
  28. package/dist/app.meta.json +11 -0
  29. package/dist/server.js +71 -0
  30. package/dist/server.js.map +6 -0
  31. package/dist/server.meta.json +11 -0
  32. package/features/thread-list/components/Dialogs.tsx +299 -0
  33. package/features/thread-list/components/FoldRows.tsx +147 -0
  34. package/features/thread-list/components/GroupSection.tsx +370 -0
  35. package/features/thread-list/components/ProviderBadge.tsx +57 -0
  36. package/features/thread-list/components/PullRequestBadge.tsx +50 -0
  37. package/features/thread-list/components/Rails.tsx +28 -0
  38. package/features/thread-list/components/RenameEditor.tsx +118 -0
  39. package/features/thread-list/components/RowMenu.tsx +168 -0
  40. package/features/thread-list/components/SplitMiniMap.tsx +55 -0
  41. package/features/thread-list/components/ThreadDetails.tsx +138 -0
  42. package/features/thread-list/components/ThreadList.tsx +855 -0
  43. package/features/thread-list/components/ThreadRowView.tsx +631 -0
  44. package/features/thread-list/components/Toolbar.tsx +296 -0
  45. package/features/thread-list/components/controller.ts +64 -0
  46. package/features/thread-list/components/glyphs.tsx +129 -0
  47. package/features/thread-list/components/row-card.ts +127 -0
  48. package/features/thread-list/data/storage.ts +18 -0
  49. package/features/thread-list/data/useAutoExpand.ts +53 -0
  50. package/features/thread-list/data/useClientPreferences.ts +20 -0
  51. package/features/thread-list/data/useNotes.ts +41 -0
  52. package/features/thread-list/data/useNow.ts +19 -0
  53. package/features/thread-list/data/usePreferences.ts +126 -0
  54. package/features/thread-list/data/useScheduled.ts +43 -0
  55. package/features/thread-list/data/useStamps.ts +68 -0
  56. package/features/thread-list/icons.ts +44 -0
  57. package/features/thread-list/index.ts +2 -0
  58. package/features/thread-list/model/attention.ts +89 -0
  59. package/features/thread-list/model/counters.ts +67 -0
  60. package/features/thread-list/model/details.ts +79 -0
  61. package/features/thread-list/model/drag.ts +104 -0
  62. package/features/thread-list/model/expansion.ts +71 -0
  63. package/features/thread-list/model/families.ts +297 -0
  64. package/features/thread-list/model/groups.ts +263 -0
  65. package/features/thread-list/model/labels.ts +50 -0
  66. package/features/thread-list/model/layout.ts +61 -0
  67. package/features/thread-list/model/menu.ts +78 -0
  68. package/features/thread-list/model/move.ts +46 -0
  69. package/features/thread-list/model/notes.ts +52 -0
  70. package/features/thread-list/model/provider-mark.ts +59 -0
  71. package/features/thread-list/model/share.ts +101 -0
  72. package/features/thread-list/model/sort.ts +78 -0
  73. package/features/thread-list/model/state.ts +330 -0
  74. package/features/thread-list/model/time.ts +107 -0
  75. package/features/thread-list/model/toggles.ts +99 -0
  76. package/features/thread-list/model/view.ts +669 -0
  77. package/features/thread-list/model/windowing.ts +27 -0
  78. package/features/thread-list/testing/fixtures.ts +162 -0
  79. package/hooks/useBrowserDimmingModal.ts +5 -0
  80. package/lib/portal-scope.ts +15 -0
  81. package/lib/utils.ts +17 -0
  82. package/package.json +96 -0
  83. package/server/cli.ts +107 -0
  84. package/server/failures.ts +136 -0
  85. package/server/import.ts +144 -0
  86. package/server/notes.ts +236 -0
  87. package/server/preference-store.ts +96 -0
  88. package/server/scheduled.ts +129 -0
  89. package/server/serial.ts +12 -0
  90. package/server/stamps.ts +142 -0
  91. package/server/startup.ts +121 -0
  92. package/server.ts +126 -0
  93. package/shared/contract.ts +73 -0
  94. package/shared/preferences.ts +241 -0
  95. package/shared/signals.ts +71 -0
  96. package/skills/thread-glance/SKILL.md +69 -0
  97. package/tsconfig.json +36 -0
  98. package/types/global-jsx.d.ts +10 -0
@@ -0,0 +1,631 @@
1
+ // One thread row. It draws the row the model built and reports what
2
+ // the user did; every decision was made before it rendered.
3
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { useDraggable, useDroppable } from "@dnd-kit/core";
5
+ import {
6
+ experimental_Icon as Icon,
7
+ experimental_useSidebarThreadSplit as useThreadSplit,
8
+ useSidebarSplitLayout,
9
+ useSidebarThreadDraft,
10
+ useSidebarThreadRowStatus,
11
+ useSidebarThreadShortcut,
12
+ } from "@get-bb/plugin-sdk/app";
13
+ import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
14
+ import { DropdownMenu, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
15
+ import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
16
+ import { cn } from "@/lib/utils";
17
+ import { ICONS } from "../icons";
18
+ import { chipLabel, rowAriaLabel } from "../model/labels";
19
+ import { rowIndent, titleTreatment } from "../model/layout";
20
+ import { rowMenuItems } from "../model/menu";
21
+ import { chipTone, pluginStatusWins } from "../model/state";
22
+ import { trailingTime } from "../model/time";
23
+ import type { ThreadRow } from "../model/view";
24
+ import type { DraggedThread } from "../model/drag";
25
+ import type { RowController } from "./controller";
26
+ import { CHIP_TONE_CLASS, FlagGlyph, GlyphIcon, PluginStatusGlyph } from "./glyphs";
27
+ import { ProviderBadge } from "./ProviderBadge";
28
+ import { PullRequestBadge } from "./PullRequestBadge";
29
+ import { RenameEditor } from "./RenameEditor";
30
+ import { RowRails } from "./Rails";
31
+ import { RowContextMenuContent, RowDropdownMenuContent, type ContextMenuInput } from "./RowMenu";
32
+ import { SplitMiniMap, type MiniMapPane } from "./SplitMiniMap";
33
+ import { ThreadDetails } from "./ThreadDetails";
34
+ import { useRowCard } from "./row-card";
35
+
36
+ /** Two clicks on one row within this window start a rename, as in bb. */
37
+ const RENAME_CLICK_MS = 400;
38
+ const LONG_PRESS_MS = 700;
39
+ const LONG_PRESS_TOLERANCE = 10;
40
+ let lastClick: { threadId: string; at: number } | null = null;
41
+
42
+ /**
43
+ * Drops the click that ends a long-press, wherever it lands, so lifting the
44
+ * finger never selects the menu item that opened under it.
45
+ */
46
+ function swallowNextClick(): void {
47
+ const swallow = (event: Event) => {
48
+ event.preventDefault();
49
+ event.stopPropagation();
50
+ window.removeEventListener("click", swallow, true);
51
+ };
52
+ window.addEventListener("click", swallow, true);
53
+ // A long-press that ends without a click must not eat the next real tap.
54
+ window.addEventListener("touchend", () => setTimeout(() => window.removeEventListener("click", swallow, true), 400), {
55
+ capture: true,
56
+ once: true,
57
+ });
58
+ }
59
+
60
+ export const ROW_ICON_BUTTON =
61
+ "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
+
63
+ function useMiniMap(threadId: string): MiniMapPane[] | null {
64
+ const layout = useSidebarSplitLayout();
65
+ return useMemo(() => {
66
+ if (layout === null) return null;
67
+ const panes = layout.panes.map((pane) => ({
68
+ paneId: pane.paneId,
69
+ rect: pane.rect,
70
+ isMe: pane.threadId === threadId,
71
+ isFocused: pane.isFocused,
72
+ }));
73
+ return panes.some((pane) => pane.isMe) ? panes : null;
74
+ }, [layout, threadId]);
75
+ }
76
+
77
+ export interface ThreadRowViewProps {
78
+ row: ThreadRow;
79
+ controller: RowController;
80
+ groupId: string;
81
+ inPinned: boolean;
82
+ /** Drop feedback from the list's drag state. */
83
+ dropState: "valid" | "blocked" | "unchanged" | "before" | "after" | null;
84
+ stickyTop: number | null;
85
+ /** The open thread is this one. */
86
+ active: boolean;
87
+ /** Its title is being renamed. */
88
+ editing: boolean;
89
+ /** The list clock, for the trailing age and timer. */
90
+ now: number;
91
+ /** This thread's stamps, for the trailing time. */
92
+ startedAt: number | undefined;
93
+ finishedAt: number | undefined;
94
+ pendingAt: number | undefined;
95
+ }
96
+
97
+ export const ThreadRowView = memo(function ThreadRowView({
98
+ row,
99
+ controller,
100
+ groupId,
101
+ inPinned,
102
+ dropState,
103
+ stickyTop,
104
+ active: isActive,
105
+ editing,
106
+ now,
107
+ startedAt,
108
+ finishedAt,
109
+ pendingAt,
110
+ }: ThreadRowViewProps) {
111
+ const { info } = row;
112
+ const thread = info.thread;
113
+ const archived = thread.archivedAt !== null || thread.isArchived;
114
+ const compact = controller.compact;
115
+
116
+ const shortcut = useSidebarThreadShortcut(thread.id);
117
+ const rowStatus = useSidebarThreadRowStatus(thread.id);
118
+ const { hasUnsubmittedDraft } = useSidebarThreadDraft(thread.id);
119
+ const split = useThreadSplit(thread.id);
120
+ const miniMap = useMiniMap(thread.id);
121
+ const [menuOpen, setMenuOpen] = useState(false);
122
+ const [contextOpen, setContextOpen] = useState(false);
123
+ const card = useRowCard(isActive, !compact && !editing && !menuOpen && !contextOpen);
124
+ const contextInput = useRef<ContextMenuInput>({ pressed: false, keyed: false });
125
+ const anchor = useRef<HTMLAnchorElement>(null);
126
+ const press = useRef<{ x: number; y: number; timer: ReturnType<typeof setTimeout> } | null>(null);
127
+
128
+ const dragData: DraggedThread = {
129
+ threadId: thread.id,
130
+ parentThreadId: thread.parentThreadId,
131
+ sectionId: thread.sectionId,
132
+ pinned: thread.pinnedAt !== null || thread.isPinned,
133
+ };
134
+ const draggable = useDraggable({
135
+ id: `thread:${thread.id}:${groupId}`,
136
+ data: { kind: "thread", thread: dragData, groupId },
137
+ disabled: editing || compact,
138
+ });
139
+ const droppable = useDroppable({
140
+ id: `drop-thread:${thread.id}:${groupId}`,
141
+ data: { kind: "thread", threadId: thread.id, inPinned, groupId },
142
+ });
143
+ const setRefs = useCallback(
144
+ (node: HTMLDivElement | null) => {
145
+ draggable.setNodeRef(node);
146
+ droppable.setNodeRef(node);
147
+ },
148
+ [draggable.setNodeRef, droppable.setNodeRef],
149
+ );
150
+
151
+ const provider = controller.provider(thread.providerId);
152
+ const showPlugin = pluginStatusWins(info.state, rowStatus);
153
+ const label = rowAriaLabel(row, {
154
+ providerName: provider.name,
155
+ pluginLabel: showPlugin ? rowStatus!.label : null,
156
+ hasDraft: hasUnsubmittedDraft,
157
+ });
158
+ const time = archived
159
+ ? null
160
+ : trailingTime(
161
+ thread,
162
+ info.flags.has("working"),
163
+ {
164
+ startedAt: startedAt === undefined ? {} : { [thread.id]: startedAt },
165
+ finishedAt: finishedAt === undefined ? {} : { [thread.id]: finishedAt },
166
+ },
167
+ now,
168
+ info.state.kind === "needs-you" ? (pendingAt ?? null) : undefined,
169
+ );
170
+ const menuItems = rowMenuItems({
171
+ thread,
172
+ unread: info.unread,
173
+ splitAvailable: split.isAvailable,
174
+ isRoot: thread.parentThreadId === null,
175
+ hasSections: controller.mode === "chronological" || controller.sections.length > 0,
176
+ compact,
177
+ });
178
+ // Rename waits for the menu to close: an open menu traps focus and would
179
+ // take it back from the editor, and a closing one returns it to the row.
180
+ const renameFromMenu = useRef(false);
181
+ // Set until the closing menu's focus return has been refused, whichever
182
+ // path started the rename: a returned focus would blur and close the editor.
183
+ const refuseCloseFocus = useRef(false);
184
+ const onMenuAction = (action: Parameters<RowController["onMenuAction"]>[0], sectionId?: string | null) => {
185
+ if (action === "rename") {
186
+ renameFromMenu.current = true;
187
+ refuseCloseFocus.current = true;
188
+ setTimeout(() => {
189
+ refuseCloseFocus.current = false;
190
+ }, 1500);
191
+ return;
192
+ }
193
+ controller.onMenuAction(action, thread, sectionId);
194
+ };
195
+ // Fallback for menus that close without a close-focus event (the compact
196
+ // drawer; a context menu opened from the keyboard): start once every menu
197
+ // has closed and its focus return has settled.
198
+ useEffect(() => {
199
+ if (menuOpen || contextOpen || !renameFromMenu.current) return;
200
+ const timer = setTimeout(() => {
201
+ if (!renameFromMenu.current) return;
202
+ renameFromMenu.current = false;
203
+ controller.setEditingId(thread.id);
204
+ }, 60);
205
+ return () => clearTimeout(timer);
206
+ }, [menuOpen, contextOpen, controller, thread.id]);
207
+ const onMenuCloseAutoFocus = (event: Event) => {
208
+ if (!refuseCloseFocus.current) return;
209
+ refuseCloseFocus.current = false;
210
+ event.preventDefault();
211
+ if (!renameFromMenu.current) return;
212
+ renameFromMenu.current = false;
213
+ controller.setEditingId(thread.id);
214
+ };
215
+
216
+ const defaultBranch = controller.showPullRequests && row.depth === 0 ? controller.defaultBranchOf(thread) : undefined;
217
+ const branch = thread.environment?.branchName ?? null;
218
+ const showPullRequest =
219
+ controller.showPullRequests && row.depth === 0 && branch !== null && defaultBranch !== undefined && branch !== defaultBranch;
220
+
221
+ const stateSlot = miniMap ? (
222
+ <SplitMiniMap panes={miniMap} label={`${thread.displayTitle} — open in split; ${info.state.label}`} working={info.flags.has("working")} />
223
+ ) : showPlugin ? (
224
+ <PluginStatusGlyph status={rowStatus!} />
225
+ ) : (
226
+ <span title={info.state.label} className="inline-flex">
227
+ <GlyphIcon glyph={info.state.glyph} label={info.state.label} />
228
+ </span>
229
+ );
230
+
231
+ const onAnchorClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
232
+ if (editing) {
233
+ event.preventDefault();
234
+ return;
235
+ }
236
+ if (split.isAvailable && (event.metaKey || event.ctrlKey)) {
237
+ event.preventDefault();
238
+ controller.onMenuAction("open-in-split", thread);
239
+ return;
240
+ }
241
+ const now = Date.now();
242
+ if (lastClick !== null && lastClick.threadId === thread.id && now - lastClick.at < RENAME_CLICK_MS) {
243
+ lastClick = null;
244
+ event.preventDefault();
245
+ controller.setEditingId(thread.id);
246
+ return;
247
+ }
248
+ lastClick = { threadId: thread.id, at: now };
249
+ controller.onNavigate();
250
+ };
251
+
252
+ const cancelPress = () => {
253
+ if (press.current !== null) clearTimeout(press.current.timer);
254
+ press.current = null;
255
+ };
256
+ const longPressFired = useRef(false);
257
+ const longPress = compact
258
+ ? {
259
+ onTouchStart: (event: React.TouchEvent) => {
260
+ const touch = event.touches[0];
261
+ if (touch === undefined) return;
262
+ cancelPress();
263
+ longPressFired.current = false;
264
+ press.current = {
265
+ x: touch.clientX,
266
+ y: touch.clientY,
267
+ timer: setTimeout(() => {
268
+ press.current = null;
269
+ longPressFired.current = true;
270
+ swallowNextClick();
271
+ setMenuOpen(true);
272
+ }, LONG_PRESS_MS),
273
+ };
274
+ },
275
+ onTouchMove: (event: React.TouchEvent) => {
276
+ const touch = event.touches[0];
277
+ if (press.current === null || touch === undefined) return;
278
+ if (Math.hypot(touch.clientX - press.current.x, touch.clientY - press.current.y) > LONG_PRESS_TOLERANCE) {
279
+ cancelPress();
280
+ }
281
+ },
282
+ onTouchEnd: (event: React.TouchEvent) => {
283
+ cancelPress();
284
+ // The finger lifts over the drawer that just opened: without this,
285
+ // the click that follows would select the item under it.
286
+ if (longPressFired.current) event.preventDefault();
287
+ },
288
+ onTouchCancel: cancelPress,
289
+ onContextMenu: (event: React.MouseEvent) => event.preventDefault(),
290
+ style: { WebkitTouchCallout: "none" } as React.CSSProperties,
291
+ }
292
+ : {};
293
+
294
+ const chip = row.chip;
295
+ const indent = rowIndent(row.depth, controller.nesting);
296
+ const title = titleTreatment(row.depth, { unread: info.unread, active: isActive });
297
+ const twoLines = controller.comfortable || info.note !== null;
298
+ // A parent keeps full colour so its children read as the quieter ones.
299
+ const dimmed = info.quiet && !isActive && !editing && chip === null;
300
+ const menuShowing = menuOpen || contextOpen;
301
+ // Desktop: the actions cross-fade over the harness and age, as bb's
302
+ // trailing slot does. Compact: "…" always shows beside them.
303
+ const fadeClass = compact
304
+ ? ""
305
+ : menuShowing
306
+ ? "opacity-0"
307
+ : "group-hover/row:opacity-0 group-focus-within/row:opacity-0";
308
+
309
+ const body = (
310
+ <div
311
+ ref={setRefs}
312
+ data-sidebar-rename-row=""
313
+ data-sidebar-nest-target={
314
+ dropState === "valid" || dropState === "blocked" || dropState === "unchanged" ? dropState : undefined
315
+ }
316
+ data-sidebar-reorder-placement={dropState === "before" || dropState === "after" ? dropState : undefined}
317
+ {...draggable.attributes}
318
+ {...draggable.listeners}
319
+ role={undefined}
320
+ tabIndex={undefined}
321
+ aria-roledescription={undefined}
322
+ onPointerEnter={card.rowProps.onPointerEnter}
323
+ onPointerMove={card.rowProps.onPointerMove}
324
+ onPointerDown={(event) => {
325
+ card.rowProps.onPointerDown();
326
+ if (!editing) split.splitProps.onPointerDown?.(event);
327
+ }}
328
+ onPointerLeave={card.rowProps.onPointerLeave}
329
+ onFocus={card.rowProps.onFocus}
330
+ onBlur={card.rowProps.onBlur}
331
+ {...longPress}
332
+ className={cn(
333
+ "group/row relative flex w-full items-center gap-1.5 rounded-md pr-1 text-sm transition-colors",
334
+ twoLines ? "h-11 max-md:pointer-coarse:h-12" : "h-7 max-md:pointer-coarse:h-9",
335
+ isActive
336
+ ? "bg-state-active text-sidebar-foreground"
337
+ : "cursor-pointer text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
338
+ !isActive && menuShowing && "bg-sidebar-accent",
339
+ miniMap && !isActive && "bb-sidebar-open-in-split-row",
340
+ draggable.isDragging && "opacity-50",
341
+ dropState === "valid" && "ring-1 ring-inset ring-sidebar-ring/80 bg-sidebar-accent/45",
342
+ dropState === "blocked" && "ring-1 ring-inset ring-destructive/60",
343
+ dropState === "before" && "shadow-[inset_0_2px_0_0_var(--sidebar-ring)]",
344
+ dropState === "after" && "shadow-[inset_0_-2px_0_0_var(--sidebar-ring)]",
345
+ stickyTop !== null && "sticky z-10 bg-sidebar",
346
+ stickyTop !== null && isActive && "bg-state-active",
347
+ )}
348
+ style={{ paddingLeft: indent, top: stickyTop ?? undefined }}
349
+ >
350
+ <a
351
+ ref={anchor}
352
+ href={thread.href}
353
+ data-sidebar-thread-shortcut-target=""
354
+ data-sidebar-thread-id={thread.id}
355
+ data-sidebar-rename-anchor=""
356
+ aria-label={label}
357
+ aria-keyshortcuts={shortcut?.ariaKeyshortcuts}
358
+ aria-current={isActive ? "page" : undefined}
359
+ onClick={onAnchorClick}
360
+ onDoubleClick={(event) => {
361
+ event.preventDefault();
362
+ controller.setEditingId(thread.id);
363
+ }}
364
+ className="absolute inset-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
365
+ />
366
+ <RowRails rails={row.rails} nesting={controller.nesting} />
367
+ <span className="pointer-events-none relative flex size-4 shrink-0 items-center justify-center">{stateSlot}</span>
368
+ {row.nested ? (
369
+ // Tight against the title, and over the row's gap, so it adds 8px.
370
+ <span
371
+ aria-hidden
372
+ title={`Child of ${row.parentTitle ?? "a thread"}`}
373
+ className={cn(
374
+ "pointer-events-none relative -ml-[3px] -mr-px w-1.5 shrink-0 text-center text-[10px] leading-none text-muted-foreground",
375
+ // On two-line rows it sits beside the title, not between the lines.
376
+ twoLines && "mt-[9px] self-start",
377
+ )}
378
+ >
379
+ ↳
380
+ </span>
381
+ ) : null}
382
+ {row.crossGroupLabel !== null ? (
383
+ <span
384
+ role="img"
385
+ aria-label={row.crossGroupLabel}
386
+ title={row.crossGroupLabel}
387
+ data-sidebar-thread-cross-project=""
388
+ className="pointer-events-none relative inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground"
389
+ >
390
+ <Icon name={ICONS.crossGroup} aria-hidden className="size-3.5" />
391
+ </span>
392
+ ) : null}
393
+ <span className="pointer-events-none relative flex min-w-0 flex-1 flex-col justify-center">
394
+ {editing ? (
395
+ <RenameEditor
396
+ initial={thread.displayTitle}
397
+ label="Thread name"
398
+ onSave={(title) => controller.onRename(thread.id, title)}
399
+ onDone={() => {
400
+ controller.setEditingId(null);
401
+ anchor.current?.focus();
402
+ }}
403
+ />
404
+ ) : (
405
+ <span
406
+ title={thread.displayTitle}
407
+ className={cn(
408
+ "min-w-0 truncate",
409
+ info.unread ? "font-semibold" : "font-normal",
410
+ // Read, idle threads step back so live ones lead.
411
+ dimmed && !title.muted && "text-muted-foreground",
412
+ // Children sit a step below their parent; hover and selection bring them back.
413
+ title.small && "text-xs",
414
+ // Foreground thinned toward the sidebar: at least 4.5:1 in both themes, and below a muted root.
415
+ title.muted && "text-[color:color-mix(in_oklch,var(--foreground)_72%,var(--sidebar))] group-hover/row:text-foreground",
416
+ )}
417
+ >
418
+ {/* Plain text: mention pills lost the truncation fight. */}
419
+ {thread.displayTitle}
420
+ </span>
421
+ )}
422
+ {!editing && info.note !== null ? (
423
+ <span className="min-w-0 truncate text-xs leading-4 text-muted-foreground" title={`${info.note.prefix}: ${info.note.text}`}>
424
+ <span className={info.note.tone === "destructive" ? "text-destructive" : "text-attention"}>{info.note.prefix}:</span>{" "}
425
+ {info.note.text}
426
+ </span>
427
+ ) : controller.comfortable && !editing ? (
428
+ <SecondLine row={row} multiHost={controller.multiHost} defaultBranch={controller.defaultBranchOf(thread)} />
429
+ ) : null}
430
+ </span>
431
+ {row.hiddenBadge ? (
432
+ <span role="img" aria-label="Hidden thread" title="Hidden thread" className="pointer-events-none relative shrink-0 text-muted-foreground">
433
+ <Icon name={ICONS.hidden} aria-hidden className="size-3.5" />
434
+ </span>
435
+ ) : null}
436
+ {showPullRequest && !editing ? (
437
+ <span className="pointer-events-none relative">
438
+ <PullRequestBadge threadId={thread.id} />
439
+ </span>
440
+ ) : null}
441
+ {chip !== null && !editing ? (
442
+ <button
443
+ type="button"
444
+ aria-expanded={chip.expanded}
445
+ aria-label={chipLabel(thread.displayTitle, chip.count, chip.flag, chip.expanded)}
446
+ title={chipLabel(thread.displayTitle, chip.count, chip.flag, chip.expanded)}
447
+ onClick={(event) => {
448
+ event.preventDefault();
449
+ event.stopPropagation();
450
+ controller.onToggleChip(row);
451
+ }}
452
+ onPointerDown={(event) => event.stopPropagation()}
453
+ onKeyDown={(event) => event.stopPropagation()}
454
+ data-tone={chipTone(chip.flag)}
455
+ className={cn(
456
+ "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",
457
+ CHIP_TONE_CLASS[chipTone(chip.flag)],
458
+ )}
459
+ >
460
+ {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
+ <Icon
468
+ name={ICONS.expand}
469
+ aria-hidden
470
+ className={cn("size-3 transition-transform duration-150", chip.expanded && "rotate-90")}
471
+ />
472
+ </button>
473
+ ) : null}
474
+ {!editing ? (
475
+ <span
476
+ className={cn(
477
+ "pointer-events-none relative inline-flex shrink-0 transition-opacity",
478
+ // The hover actions take this slot too, as bb's trailing slot does.
479
+ shortcut === null && fadeClass,
480
+ )}
481
+ >
482
+ <ProviderBadge display={provider} mode={controller.harnessIcon} />
483
+ </span>
484
+ ) : null}
485
+ {!editing ? (
486
+ <span className="relative flex h-6 min-w-8 shrink-0 items-center justify-end gap-1">
487
+ {shortcut !== null ? (
488
+ <kbd aria-hidden className="pointer-events-none rounded border border-border px-1 font-sans text-[10px] leading-4 text-muted-foreground">
489
+ {shortcut.label}
490
+ </kbd>
491
+ ) : (
492
+ <>
493
+ {time !== null ? (
494
+ <span
495
+ title={time.label}
496
+ aria-label={time.label}
497
+ className={cn(
498
+ "pointer-events-none text-[11px] tabular-nums transition-opacity",
499
+ // A running timer reads as work, an age as history.
500
+ time.kind === "timer" ? "font-medium text-[var(--timeline-accent)]" : "text-muted-foreground",
501
+ fadeClass,
502
+ )}
503
+ >
504
+ {time.text}
505
+ </span>
506
+ ) : null}
507
+ <span
508
+ className={cn(
509
+ "flex items-center gap-0.5 transition-opacity",
510
+ compact
511
+ ? "relative"
512
+ : menuShowing
513
+ ? "absolute right-0 opacity-100"
514
+ : "absolute right-0 opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100",
515
+ )}
516
+ >
517
+ {!compact ? (
518
+ <button
519
+ type="button"
520
+ aria-label={archived ? "Unarchive thread" : "Archive thread"}
521
+ title={archived ? "Unarchive" : "Archive"}
522
+ className={ROW_ICON_BUTTON}
523
+ onPointerDown={(event) => event.stopPropagation()}
524
+ onClick={(event) => {
525
+ event.preventDefault();
526
+ event.stopPropagation();
527
+ controller.onMenuAction(archived ? "unarchive" : "archive", thread);
528
+ }}
529
+ >
530
+ <Icon name={archived ? ICONS.unarchive : ICONS.archive} aria-hidden className="size-4" />
531
+ </button>
532
+ ) : null}
533
+ <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
534
+ <DropdownMenuTrigger asChild>
535
+ <button
536
+ type="button"
537
+ aria-label="Thread actions"
538
+ // Phones open the menu by long-press, as bb's lists do;
539
+ // the button stays for keyboards and screen readers.
540
+ className={compact ? "sr-only" : ROW_ICON_BUTTON}
541
+ onPointerDown={(event) => event.stopPropagation()}
542
+ onClick={(event) => event.stopPropagation()}
543
+ >
544
+ <Icon name={ICONS.more} aria-hidden className="size-4" />
545
+ </button>
546
+ </DropdownMenuTrigger>
547
+ <RowDropdownMenuContent
548
+ items={menuItems}
549
+ sections={controller.sections}
550
+ currentSectionId={thread.sectionId}
551
+ onAction={onMenuAction}
552
+ onCloseAutoFocus={onMenuCloseAutoFocus}
553
+ />
554
+ </DropdownMenu>
555
+ </span>
556
+ </>
557
+ )}
558
+ </span>
559
+ ) : null}
560
+ </div>
561
+ );
562
+
563
+ if (compact) return body;
564
+ return (
565
+ <ContextMenu
566
+ onOpenChange={(open) => {
567
+ if (open) contextInput.current = { pressed: false, keyed: false };
568
+ setContextOpen(open);
569
+ }}
570
+ >
571
+ <HoverCard
572
+ closeDelay={100}
573
+ open={card.open}
574
+ onOpenChange={card.onOpenChange}
575
+ >
576
+ <ContextMenuTrigger asChild disabled={editing}>
577
+ <HoverCardTrigger asChild>{body}</HoverCardTrigger>
578
+ </ContextMenuTrigger>
579
+ {!editing && !menuOpen && !contextOpen ? (
580
+ <HoverCardContent side="right" align="start" collisionPadding={8} className="z-[100] w-72 p-3">
581
+ <ThreadDetails info={info} controller={controller} showPullRequest={showPullRequest} />
582
+ </HoverCardContent>
583
+ ) : null}
584
+ </HoverCard>
585
+ <RowContextMenuContent
586
+ items={menuItems}
587
+ sections={controller.sections}
588
+ currentSectionId={thread.sectionId}
589
+ onAction={onMenuAction}
590
+ onCloseAutoFocus={onMenuCloseAutoFocus}
591
+ input={contextInput}
592
+ />
593
+ </ContextMenu>
594
+ );
595
+ });
596
+
597
+ function SecondLine({
598
+ row,
599
+ multiHost,
600
+ defaultBranch,
601
+ }: {
602
+ row: ThreadRow;
603
+ multiHost: boolean;
604
+ defaultBranch: string | null | undefined;
605
+ }) {
606
+ const thread = row.info.thread;
607
+ const environment = thread.environment;
608
+ const parts: React.ReactNode[] = [];
609
+ // The branch earns the line only when it isn't the project's default:
610
+ // "main" on every row said nothing.
611
+ const branch = environment?.branchName ?? null;
612
+ if (branch !== null && branch !== defaultBranch) {
613
+ parts.push(
614
+ <span key="branch" className="inline-flex min-w-0 items-center gap-0.5">
615
+ <Icon name={environment?.isWorktree ? ICONS.worktree : ICONS.branch} aria-hidden className="size-3 shrink-0" />
616
+ <span className="truncate">{branch}</span>
617
+ {environment?.isWorktree ? <span className="sr-only"> (worktree)</span> : null}
618
+ </span>,
619
+ );
620
+ }
621
+ if (multiHost && thread.host !== null) {
622
+ parts.push(
623
+ <span key="host" className="inline-flex shrink-0 items-center gap-0.5">
624
+ <Icon name={ICONS.machine} aria-hidden className="size-3" />
625
+ {thread.host.name}
626
+ </span>,
627
+ );
628
+ }
629
+ if (parts.length === 0) return null;
630
+ return <span className="flex min-w-0 items-center gap-2 text-xs leading-4 text-muted-foreground">{parts}</span>;
631
+ }