@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,370 @@
1
+ // A top-level group: its header with counters, and its rows, windowed
2
+ // in chunks.
3
+ import { memo, useEffect, useRef, useState } from "react";
4
+ import { useDraggable, useDroppable } from "@dnd-kit/core";
5
+ import { experimental_Icon as Icon } from "@get-bb/plugin-sdk/app";
6
+ import type { PluginEnvironmentProvider } from "@get-bb/plugin-sdk/app";
7
+ import type { Stamps } from "@/shared/contract";
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuContent,
11
+ DropdownMenuItem,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuTrigger,
14
+ } from "@/components/ui/dropdown-menu";
15
+ import { cn } from "@/lib/utils";
16
+ import { ICONS } from "../icons";
17
+ import type { GroupView, Row } from "../model/view";
18
+ import { chunk, windowedNavValue } from "../model/windowing";
19
+ import type { RowController } from "./controller";
20
+ import { EmptyRowView, EnvironmentRowView, OlderRowView } from "./FoldRows";
21
+ import { CounterStrip } from "./glyphs";
22
+ import { visibleCounters } from "../model/counters";
23
+ import { RenameEditor } from "./RenameEditor";
24
+ import { ROW_ICON_BUTTON, ThreadRowView } from "./ThreadRowView";
25
+
26
+ export interface GroupController {
27
+ compact: boolean;
28
+ /** The group holding the active thread: on phones only it shows `+`. */
29
+ activeGroupId: string | null;
30
+ showArchived: boolean;
31
+ onToggleArchived(): void;
32
+ canCreateSections: boolean;
33
+ onToggleCollapse(group: GroupView): void;
34
+ onNewThread(group: GroupView): void;
35
+ onHide(group: GroupView): void;
36
+ onShow(group: GroupView): void;
37
+ onCustomize(): void;
38
+ onRename(group: GroupView, name: string): Promise<void>;
39
+ onRemove(group: GroupView): void;
40
+ onMarkAllRead(group: GroupView): void;
41
+ onNewSection(): void;
42
+ }
43
+
44
+ export type DropStates = ReadonlyMap<string, "valid" | "blocked" | "unchanged" | "before" | "after">;
45
+
46
+ /** The sticky toolbar's height; headers stick below it. */
47
+ export const TOOLBAR_HEIGHT = 32;
48
+ const HEADER_HEIGHT = 28;
49
+ const ROW_HEIGHT = { compact: 28, comfortable: 44 };
50
+
51
+ function canRename(group: GroupView): boolean {
52
+ return group.descriptor.kind === "section" || group.descriptor.kind === "machine";
53
+ }
54
+
55
+ const GroupHeader = memo(function GroupHeader({
56
+ group,
57
+ controller,
58
+ inOverflow,
59
+ dropActive,
60
+ }: {
61
+ group: GroupView;
62
+ controller: GroupController;
63
+ inOverflow: boolean;
64
+ dropActive: boolean;
65
+ }) {
66
+ const [renaming, setRenaming] = useState(false);
67
+ const label = group.descriptor.label;
68
+ const draggable = useDraggable({
69
+ id: `group:${group.descriptor.id}`,
70
+ data: { kind: "group", groupId: group.descriptor.id },
71
+ disabled: controller.compact || inOverflow || renaming,
72
+ });
73
+ const actionsClass = controller.compact
74
+ ? ""
75
+ : "opacity-0 group-hover/header:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100";
76
+ return (
77
+ <div
78
+ ref={draggable.setNodeRef}
79
+ {...draggable.attributes}
80
+ {...draggable.listeners}
81
+ role={undefined}
82
+ tabIndex={undefined}
83
+ data-sidebar="group-label"
84
+ style={{ top: inOverflow ? 0 : TOOLBAR_HEIGHT }}
85
+ className={cn(
86
+ "group/header sticky z-20 flex h-7 items-center gap-1 rounded-md bg-sidebar pl-2 pr-1 text-xs text-muted-foreground max-md:pointer-coarse:h-9",
87
+ dropActive && "bg-sidebar-accent",
88
+ draggable.isDragging && "opacity-50",
89
+ !controller.compact && !inOverflow && "select-none",
90
+ )}
91
+ >
92
+ {renaming ? (
93
+ <RenameEditor
94
+ initial={label}
95
+ label={group.descriptor.kind === "section" ? "Section name" : "Machine name"}
96
+ onSave={(name) => controller.onRename(group, name)}
97
+ onDone={() => setRenaming(false)}
98
+ className="text-xs"
99
+ />
100
+ ) : (
101
+ <button
102
+ type="button"
103
+ aria-expanded={!group.collapsed}
104
+ aria-label={`${group.collapsed ? "Expand" : "Collapse"} ${label} section`}
105
+ onClick={() => controller.onToggleCollapse(group)}
106
+ onPointerDown={(event) => event.stopPropagation()}
107
+ onDoubleClick={() => canRename(group) && setRenaming(true)}
108
+ className="flex min-w-0 flex-1 items-center gap-1 rounded-md py-1 text-left font-medium outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
109
+ >
110
+ <span className="min-w-0 truncate" title={label}>
111
+ {label}
112
+ </span>
113
+ <Icon
114
+ name={ICONS.expand}
115
+ aria-hidden
116
+ className={cn("size-3 shrink-0 transition-transform duration-150", !group.collapsed && "rotate-90")}
117
+ />
118
+ </button>
119
+ )}
120
+ {!renaming ? (
121
+ <CounterStrip
122
+ counters={visibleCounters(group.counters, { collapsed: group.collapsed, more: false })}
123
+ className="ml-auto"
124
+ />
125
+ ) : null}
126
+ {!renaming &&
127
+ group.descriptor.kind !== "pinned" &&
128
+ (group.descriptor.newThreadProjectId !== null || group.descriptor.kind === "machine") &&
129
+ (!controller.compact || controller.activeGroupId === group.descriptor.id) ? (
130
+ <button
131
+ type="button"
132
+ aria-label={`New thread in ${label}`}
133
+ title={`New thread in ${label}`}
134
+ data-sidebar-hover-actions-mobile={controller.compact ? "always" : undefined}
135
+ className={cn(ROW_ICON_BUTTON, actionsClass)}
136
+ onPointerDown={(event) => event.stopPropagation()}
137
+ onClick={() => controller.onNewThread(group)}
138
+ >
139
+ <Icon name={ICONS.newThread} aria-hidden className="size-4" />
140
+ </button>
141
+ ) : !renaming && !controller.compact ? (
142
+ // Keeps every header's counters on one edge.
143
+ <span aria-hidden className="size-6 shrink-0" />
144
+ ) : null}
145
+ {!renaming ? (
146
+ <DropdownMenu>
147
+ <DropdownMenuTrigger asChild>
148
+ <button
149
+ type="button"
150
+ aria-label={`${label} actions`}
151
+ className={cn(ROW_ICON_BUTTON, actionsClass)}
152
+ onPointerDown={(event) => event.stopPropagation()}
153
+ >
154
+ <Icon name={ICONS.more} aria-hidden className="size-4" />
155
+ </button>
156
+ </DropdownMenuTrigger>
157
+ <DropdownMenuContent align="end" className="min-w-48">
158
+ <DropdownMenuItem onSelect={() => controller.onMarkAllRead(group)}>
159
+ <Icon name={ICONS.markRead} aria-hidden className="size-4" />
160
+ Mark all read
161
+ </DropdownMenuItem>
162
+ {group.descriptor.kind !== "pinned" && group.descriptor.newThreadProjectId !== null ? (
163
+ <DropdownMenuItem onSelect={() => controller.onNewThread(group)}>
164
+ <Icon name={ICONS.newThread} aria-hidden className="size-4" />
165
+ New thread
166
+ </DropdownMenuItem>
167
+ ) : null}
168
+ {controller.canCreateSections ? (
169
+ <DropdownMenuItem onSelect={() => controller.onNewSection()}>
170
+ <Icon name={ICONS.newSection} aria-hidden className="size-4" />
171
+ New section
172
+ </DropdownMenuItem>
173
+ ) : null}
174
+ {canRename(group) ? (
175
+ <DropdownMenuItem onSelect={() => setRenaming(true)}>
176
+ <Icon name={ICONS.rename} aria-hidden className="size-4" />
177
+ Rename
178
+ </DropdownMenuItem>
179
+ ) : null}
180
+ <DropdownMenuSeparator />
181
+ {group.descriptor.kind !== "pinned" ? (
182
+ group.hidden ? (
183
+ <DropdownMenuItem onSelect={() => controller.onShow(group)}>
184
+ <Icon name={ICONS.show} aria-hidden className="size-4" />
185
+ Show in list
186
+ </DropdownMenuItem>
187
+ ) : (
188
+ <DropdownMenuItem onSelect={() => controller.onHide(group)}>
189
+ <Icon name={ICONS.hidden} aria-hidden className="size-4" />
190
+ Hide from list
191
+ </DropdownMenuItem>
192
+ )
193
+ ) : null}
194
+ <DropdownMenuItem onSelect={() => controller.onToggleArchived()}>
195
+ <Icon name={controller.showArchived ? ICONS.check : ICONS.archive} aria-hidden className="size-4" />
196
+ Show archived threads
197
+ </DropdownMenuItem>
198
+ <DropdownMenuItem onSelect={() => controller.onCustomize()}>
199
+ <Icon name={ICONS.customize} aria-hidden className="size-4" />
200
+ Customize list
201
+ </DropdownMenuItem>
202
+ {group.descriptor.kind === "section" ? (
203
+ <DropdownMenuItem
204
+ className="text-destructive focus:text-destructive"
205
+ onSelect={() => controller.onRemove(group)}
206
+ >
207
+ <Icon name={ICONS.remove} aria-hidden className="size-4" />
208
+ Remove section
209
+ </DropdownMenuItem>
210
+ ) : null}
211
+ </DropdownMenuContent>
212
+ </DropdownMenu>
213
+ ) : null}
214
+ </div>
215
+ );
216
+ });
217
+
218
+ function rowKey(row: Row): string {
219
+ return row.key;
220
+ }
221
+
222
+ /** Mounts its rows while near the viewport; otherwise a sized placeholder. */
223
+ function WindowedChunk({
224
+ rows,
225
+ forceMount,
226
+ rowHeight,
227
+ children,
228
+ }: {
229
+ rows: readonly Row[];
230
+ forceMount: boolean;
231
+ rowHeight: number;
232
+ children: React.ReactNode;
233
+ }) {
234
+ const ref = useRef<HTMLDivElement>(null);
235
+ const [visible, setVisible] = useState(true);
236
+ const measured = useRef<number | null>(null);
237
+ useEffect(() => {
238
+ const node = ref.current;
239
+ if (node === null || typeof IntersectionObserver === "undefined") return;
240
+ const observer = new IntersectionObserver(
241
+ (entries) => {
242
+ for (const entry of entries) {
243
+ if (entry.isIntersecting === false && node.firstElementChild !== null && visible) {
244
+ measured.current = node.getBoundingClientRect().height;
245
+ }
246
+ setVisible(entry.isIntersecting);
247
+ }
248
+ },
249
+ { rootMargin: "240px 0px" },
250
+ );
251
+ observer.observe(node);
252
+ return () => observer.disconnect();
253
+ }, [visible]);
254
+ const mounted = visible || forceMount;
255
+ const nav = rows
256
+ .filter((row) => row.type === "thread")
257
+ .map((row) => ({ threadId: row.info.thread.id, projectId: row.projectId }));
258
+ return (
259
+ <div
260
+ ref={ref}
261
+ data-sidebar-windowed-item=""
262
+ data-sidebar-windowed-nav={mounted || nav.length === 0 ? undefined : windowedNavValue(nav)}
263
+ style={mounted ? undefined : { height: measured.current ?? rows.length * rowHeight }}
264
+ >
265
+ {mounted ? children : null}
266
+ </div>
267
+ );
268
+ }
269
+
270
+ export const GroupSection = memo(function GroupSection({
271
+ group,
272
+ rowController,
273
+ groupController,
274
+ environmentProviders,
275
+ dropStates,
276
+ dropTargetGroupId,
277
+ activeThreadId,
278
+ editingId,
279
+ now,
280
+ stamps,
281
+ inOverflow = false,
282
+ }: {
283
+ group: GroupView;
284
+ rowController: RowController;
285
+ groupController: GroupController;
286
+ environmentProviders: readonly PluginEnvironmentProvider[];
287
+ dropStates: DropStates;
288
+ dropTargetGroupId: string | null;
289
+ activeThreadId: string | null;
290
+ editingId: string | null;
291
+ now: number;
292
+ /** Rows get their own stamps as props, so a stamp renders one row. */
293
+ stamps: Stamps;
294
+ inOverflow?: boolean;
295
+ }) {
296
+ const droppable = useDroppable({
297
+ id: `drop-group:${group.descriptor.id}`,
298
+ data: { kind: "group", groupId: group.descriptor.id },
299
+ });
300
+ const rowHeight = rowController.comfortable ? ROW_HEIGHT.comfortable : ROW_HEIGHT.compact;
301
+ const inPinned = group.descriptor.id === "pinned";
302
+ return (
303
+ <section
304
+ ref={droppable.setNodeRef}
305
+ aria-label={group.descriptor.label}
306
+ data-sidebar-visibility-group={group.descriptor.id}
307
+ data-sidebar-section-id={group.descriptor.kind === "section" ? group.descriptor.entityId ?? undefined : undefined}
308
+ className="relative flex w-full min-w-0 flex-col"
309
+ >
310
+ <GroupHeader
311
+ group={group}
312
+ controller={groupController}
313
+ inOverflow={inOverflow}
314
+ dropActive={dropTargetGroupId === group.descriptor.id}
315
+ />
316
+ {group.collapsed ? null : group.rows.length === 0 ? (
317
+ <p className="py-1 pl-8 text-xs text-muted-foreground">No threads</p>
318
+ ) : (
319
+ <div data-sidebar="group-content" className="flex w-full flex-col text-sm">
320
+ {chunk(group.rows).map((rows) => (
321
+ <WindowedChunk
322
+ key={rowKey(rows[0]!)}
323
+ rows={rows}
324
+ rowHeight={rowHeight}
325
+ forceMount={
326
+ inOverflow ||
327
+ rows.some(
328
+ (row) =>
329
+ row.type === "thread" &&
330
+ (row.info.thread.id === activeThreadId || row.info.thread.id === editingId),
331
+ )
332
+ }
333
+ >
334
+ {rows.map((row) =>
335
+ row.type === "thread" ? (
336
+ <ThreadRowView
337
+ key={row.key}
338
+ row={row}
339
+ controller={rowController}
340
+ groupId={group.descriptor.id}
341
+ inPinned={inPinned}
342
+ dropState={dropStates.get(row.info.thread.id) ?? null}
343
+ stickyTop={row.stickyLevel !== null ? TOOLBAR_HEIGHT + HEADER_HEIGHT + row.stickyLevel * rowHeight : null}
344
+ active={row.info.thread.id === activeThreadId}
345
+ editing={row.info.thread.id === editingId}
346
+ now={now}
347
+ startedAt={stamps.startedAt[row.info.thread.id]}
348
+ finishedAt={stamps.finishedAt[row.info.thread.id]}
349
+ pendingAt={stamps.pendingAt[row.info.thread.id]}
350
+ />
351
+ ) : row.type === "older" ? (
352
+ <OlderRowView key={row.key} row={row} controller={rowController} />
353
+ ) : row.type === "empty" ? (
354
+ <EmptyRowView key={row.key} row={row} controller={rowController} />
355
+ ) : (
356
+ <EnvironmentRowView
357
+ key={row.key}
358
+ row={row}
359
+ controller={rowController}
360
+ environmentProviders={environmentProviders}
361
+ />
362
+ ),
363
+ )}
364
+ </WindowedChunk>
365
+ ))}
366
+ </div>
367
+ )}
368
+ </section>
369
+ );
370
+ });
@@ -0,0 +1,57 @@
1
+ // The harness hint: the provider's logo, or a unique two-letter mark.
2
+ // Muted by default: a monochrome mask at 60%.
3
+ import { experimental_ProviderIcon as ProviderIcon } from "@get-bb/plugin-sdk/app";
4
+ import { cn } from "@/lib/utils";
5
+ import type { HarnessIcon } from "@/shared/preferences";
6
+
7
+ export interface ProviderDisplay {
8
+ id: string;
9
+ name: string;
10
+ /** The roster entry, when the provider is known and has a logo. */
11
+ provider: {
12
+ id: string;
13
+ logoUrl?: string | null;
14
+ icon?: { glyph: string } | null;
15
+ strings?: { iconTint?: { light: string; dark: string } | null } | null;
16
+ } | null;
17
+ mark: string;
18
+ }
19
+
20
+ export function ProviderBadge({
21
+ display,
22
+ mode = "muted",
23
+ className,
24
+ }: {
25
+ display: ProviderDisplay;
26
+ mode?: HarnessIcon;
27
+ className?: string;
28
+ }) {
29
+ if (mode === "hidden") return null;
30
+ const muted = mode === "muted";
31
+ if (display.provider !== null) {
32
+ // Without its tint the logo draws as a currentColor mask.
33
+ const provider = muted ? { id: display.provider.id, logoUrl: display.provider.logoUrl, icon: display.provider.icon } : display.provider;
34
+ return (
35
+ <span
36
+ title={display.name}
37
+ className={cn("inline-flex size-3.5 shrink-0 text-muted-foreground", muted && "opacity-60", className)}
38
+ >
39
+ <ProviderIcon providerKind="agent" provider={provider} aria-label={display.name} className="size-3.5" />
40
+ </span>
41
+ );
42
+ }
43
+ return (
44
+ <span
45
+ role="img"
46
+ aria-label={display.name}
47
+ title={display.name}
48
+ className={cn(
49
+ "inline-flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded-[3px] border border-border px-px text-[8px] font-semibold leading-none tracking-tight text-muted-foreground",
50
+ muted && "opacity-60",
51
+ className,
52
+ )}
53
+ >
54
+ {display.mark}
55
+ </span>
56
+ );
57
+ }
@@ -0,0 +1,50 @@
1
+ // Pull request badge. Mounted only for eligible rows, so the per-row
2
+ // lookup runs only where it can show something.
3
+ import { experimental_useSidebarThreadPullRequest as usePullRequest } from "@get-bb/plugin-sdk/app";
4
+ import type { PluginSidebarPullRequest } from "@get-bb/plugin-sdk/app";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ export function pullRequestTone(attention: PluginSidebarPullRequest["attention"]): string {
8
+ switch (attention) {
9
+ case "checks_failed":
10
+ case "conflicts":
11
+ case "changes_requested":
12
+ return "text-destructive";
13
+ case "ready_to_merge":
14
+ return "text-success";
15
+ default:
16
+ return "text-muted-foreground";
17
+ }
18
+ }
19
+
20
+ const ATTENTION_LABEL: Partial<Record<PluginSidebarPullRequest["attention"], string>> = {
21
+ checks_failed: "checks failed",
22
+ conflicts: "has conflicts",
23
+ changes_requested: "changes requested",
24
+ ready_to_merge: "ready to merge",
25
+ checks_pending: "checks pending",
26
+ review_requested: "review requested",
27
+ draft: "draft",
28
+ merged: "merged",
29
+ closed: "closed",
30
+ blocked: "blocked",
31
+ };
32
+
33
+ export function pullRequestLabel(pullRequest: PluginSidebarPullRequest): string {
34
+ const detail = ATTENTION_LABEL[pullRequest.attention];
35
+ return `Pull request #${pullRequest.number}${detail ? `, ${detail}` : ""}: ${pullRequest.title}`;
36
+ }
37
+
38
+ export function PullRequestBadge({ threadId }: { threadId: string }) {
39
+ const { pullRequest } = usePullRequest(threadId);
40
+ if (pullRequest === null) return null;
41
+ return (
42
+ <span
43
+ title={pullRequestLabel(pullRequest)}
44
+ aria-label={pullRequestLabel(pullRequest)}
45
+ className={cn("shrink-0 text-[11px] tabular-nums", pullRequestTone(pullRequest.attention))}
46
+ >
47
+ #{pullRequest.number}
48
+ </span>
49
+ );
50
+ }
@@ -0,0 +1,28 @@
1
+ // The guide rails of a nested row: one hairline per nesting level,
2
+ // drawn inside the row so they join up from row to row.
3
+ import type { Nesting } from "@/shared/preferences";
4
+ import { railLeft, type Rail } from "../model/layout";
5
+
6
+ // `start` begins just under the 16px status slot, which is centred in the row.
7
+ const SPAN: Record<Rail, { top: string; bottom: string }> = {
8
+ start: { top: "calc(50% + 10px)", bottom: "0px" },
9
+ full: { top: "0px", bottom: "0px" },
10
+ end: { top: "0px", bottom: "50%" },
11
+ };
12
+
13
+ export function RowRails({ rails, nesting }: { rails: readonly (Rail | null)[]; nesting: Nesting }) {
14
+ if (rails.every((rail) => rail === null)) return null;
15
+ return (
16
+ <span aria-hidden data-rails="" className="pointer-events-none absolute inset-0">
17
+ {rails.map((rail, level) =>
18
+ rail === null ? null : (
19
+ <span
20
+ key={level}
21
+ className="absolute w-px bg-border"
22
+ style={{ left: railLeft(level, nesting), ...SPAN[rail] }}
23
+ />
24
+ ),
25
+ )}
26
+ </span>
27
+ );
28
+ }
@@ -0,0 +1,118 @@
1
+ // Inline rename: Enter or blur saves, Escape cancels, IME-safe.
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { toast } from "sonner";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const SETTLE_MS = 600;
7
+
8
+ export function isEditable(target: EventTarget | null): boolean {
9
+ if (!(target instanceof HTMLElement)) return false;
10
+ return target.isContentEditable || target.tagName === "TEXTAREA" || target.tagName === "INPUT";
11
+ }
12
+
13
+ export function RenameEditor({
14
+ initial,
15
+ label,
16
+ onSave,
17
+ onDone,
18
+ className,
19
+ }: {
20
+ initial: string;
21
+ label: string;
22
+ onSave(value: string): Promise<void>;
23
+ onDone(): void;
24
+ className?: string;
25
+ }) {
26
+ const [value, setValue] = useState(initial);
27
+ const [saving, setSaving] = useState(false);
28
+ const input = useRef<HTMLInputElement>(null);
29
+ const finished = useRef(false);
30
+ const settled = useRef(false);
31
+ const userPressedElsewhere = useRef(false);
32
+
33
+ useEffect(() => {
34
+ const onPointerDown = (event: PointerEvent) => {
35
+ if (event.target !== input.current) userPressedElsewhere.current = true;
36
+ };
37
+ window.addEventListener("pointerdown", onPointerDown, true);
38
+ return () => window.removeEventListener("pointerdown", onPointerDown, true);
39
+ }, []);
40
+
41
+ useEffect(() => {
42
+ input.current?.focus();
43
+ input.current?.select();
44
+ // Opening a thread moves focus to its composer just after the click
45
+ // that started the rename; blurs before this settle aren't the user's.
46
+ const timer = setTimeout(() => {
47
+ settled.current = true;
48
+ }, SETTLE_MS);
49
+ return () => clearTimeout(timer);
50
+ }, []);
51
+
52
+ const finish = async (save: boolean) => {
53
+ if (finished.current) return;
54
+ finished.current = true;
55
+ const next = value.trim();
56
+ if (!save || next === "" || next === initial) {
57
+ onDone();
58
+ return;
59
+ }
60
+ setSaving(true);
61
+ try {
62
+ await onSave(next);
63
+ } catch (error) {
64
+ toast.error("Couldn't rename", {
65
+ description: error instanceof Error ? error.message : String(error),
66
+ });
67
+ } finally {
68
+ onDone();
69
+ }
70
+ };
71
+
72
+ return (
73
+ <>
74
+ <input
75
+ ref={input}
76
+ aria-label={label}
77
+ value={value}
78
+ disabled={saving}
79
+ onChange={(event) => setValue(event.target.value)}
80
+ onKeyDown={(event) => {
81
+ event.stopPropagation();
82
+ if (event.nativeEvent.isComposing) return;
83
+ if (event.key === "Enter") {
84
+ event.preventDefault();
85
+ void finish(true);
86
+ } else if (event.key === "Escape") {
87
+ event.preventDefault();
88
+ void finish(false);
89
+ }
90
+ }}
91
+ onBlur={(event) => {
92
+ // Only the thread opening can take focus back: bb moves it into
93
+ // the composer (an editable element) just after the click that
94
+ // started the rename. Any other blur is the user leaving.
95
+ if (!settled.current && !userPressedElsewhere.current && isEditable(event.relatedTarget)) {
96
+ setTimeout(() => {
97
+ input.current?.focus();
98
+ input.current?.select();
99
+ }, 0);
100
+ return;
101
+ }
102
+ void finish(true);
103
+ }}
104
+ onClick={(event) => event.stopPropagation()}
105
+ onPointerDown={(event) => event.stopPropagation()}
106
+ className={cn(
107
+ "pointer-events-auto relative z-10 h-6 min-w-0 flex-1 rounded-sm border border-input bg-background px-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
108
+ className,
109
+ )}
110
+ />
111
+ {saving ? (
112
+ <span role="status" className="sr-only">
113
+ Saving name
114
+ </span>
115
+ ) : null}
116
+ </>
117
+ );
118
+ }