@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,296 @@
1
+ // The top of the scroll area: the filter toggle and the settings
2
+ // popover.
3
+ import { useState } from "react";
4
+ import { experimental_Icon as Icon } from "@get-bb/plugin-sdk/app";
5
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
6
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
7
+ import { cn } from "@/lib/utils";
8
+ import type { ClientPreferences, Lifecycle, Preferences } from "@/shared/preferences";
9
+ import { ATTENTION_EXPLANATION, ATTENTION_SUMMARY } from "../model/counters";
10
+ import { ICONS } from "../icons";
11
+ import { ROW_ICON_BUTTON } from "./ThreadRowView";
12
+
13
+ type Page = "organize" | "sort" | "display" | "filter";
14
+
15
+ const PAGES: readonly { id: Page; label: string }[] = [
16
+ { id: "organize", label: "Organize" },
17
+ { id: "sort", label: "Sort" },
18
+ { id: "display", label: "Display" },
19
+ { id: "filter", label: "Filter" },
20
+ ];
21
+
22
+ // bb's own segmented controls (the Reasoning picker, the diff view toggle)
23
+ // mark the chosen item with the state-active token and no shadow. The track
24
+ // is a translucent tint, so the chosen item reads as raised in both themes.
25
+ const SEGMENT_TRACK = "flex gap-0.5 rounded-md bg-surface-recessed p-0.5";
26
+ const SEGMENT_ITEM = "flex items-center justify-center rounded-sm text-xs outline-none focus-visible:ring-2";
27
+
28
+ function segmentClass(selected: boolean): string {
29
+ return cn(
30
+ SEGMENT_ITEM,
31
+ selected
32
+ ? "bg-state-active text-foreground"
33
+ : "text-muted-foreground hover:bg-state-hover hover:text-foreground",
34
+ );
35
+ }
36
+
37
+ function Choice<T extends string | boolean>({
38
+ label,
39
+ value,
40
+ current,
41
+ onSelect,
42
+ description,
43
+ }: {
44
+ label: string;
45
+ value: T;
46
+ current: T;
47
+ onSelect(value: T): void;
48
+ description?: string;
49
+ }) {
50
+ const checked = value === current;
51
+ return (
52
+ <button
53
+ type="button"
54
+ role="menuitemradio"
55
+ aria-checked={checked}
56
+ onClick={() => onSelect(value)}
57
+ className="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none hover:bg-accent focus-visible:bg-accent"
58
+ >
59
+ <span className="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center">
60
+ {checked ? <Icon name={ICONS.check} aria-hidden className="size-4" /> : null}
61
+ </span>
62
+ <span className="flex min-w-0 flex-col">
63
+ <span>{label}</span>
64
+ {description ? <span className="text-xs text-muted-foreground">{description}</span> : null}
65
+ </span>
66
+ </button>
67
+ );
68
+ }
69
+
70
+ function Toggle({
71
+ label,
72
+ checked,
73
+ onChange,
74
+ description,
75
+ }: {
76
+ label: string;
77
+ checked: boolean;
78
+ onChange(value: boolean): void;
79
+ description?: string;
80
+ }) {
81
+ return (
82
+ <button
83
+ type="button"
84
+ role="menuitemcheckbox"
85
+ aria-checked={checked}
86
+ onClick={() => onChange(!checked)}
87
+ className="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none hover:bg-accent focus-visible:bg-accent"
88
+ >
89
+ <span
90
+ aria-hidden
91
+ className={cn(
92
+ "mt-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded-sm border border-input",
93
+ checked && "border-primary bg-primary text-primary-foreground",
94
+ )}
95
+ >
96
+ {checked ? <Icon name={ICONS.check} aria-hidden className="size-3" /> : null}
97
+ </span>
98
+ <span className="flex min-w-0 flex-col">
99
+ <span>{label}</span>
100
+ {description ? <span className="text-xs text-muted-foreground">{description}</span> : null}
101
+ </span>
102
+ </button>
103
+ );
104
+ }
105
+
106
+ function Heading({ children }: { children: React.ReactNode }) {
107
+ return <p className="px-2 pb-0.5 pt-2 text-xs font-medium text-muted-foreground first:pt-0">{children}</p>;
108
+ }
109
+
110
+ export function SettingsPanel({
111
+ prefs,
112
+ client,
113
+ onPrefs,
114
+ onClient,
115
+ }: {
116
+ prefs: Preferences;
117
+ client: ClientPreferences;
118
+ onPrefs(patch: Partial<Preferences>): void;
119
+ onClient(patch: Partial<ClientPreferences>): void;
120
+ }) {
121
+ const [page, setPage] = useState<Page>("organize");
122
+ const sortField = prefs.chronologicalSort === "none" ? "updated" : prefs.chronologicalSort;
123
+ const toggleLifecycle = (lifecycle: Lifecycle) => {
124
+ const has = prefs.threadLifecycles.includes(lifecycle);
125
+ const next = has
126
+ ? prefs.threadLifecycles.filter((value) => value !== lifecycle)
127
+ : [...prefs.threadLifecycles, lifecycle];
128
+ if (next.length > 0) onPrefs({ threadLifecycles: next });
129
+ };
130
+ return (
131
+ <div className="flex flex-col gap-2">
132
+ <div role="tablist" aria-label="Settings pages" className={SEGMENT_TRACK}>
133
+ {PAGES.map((candidate) => (
134
+ <button
135
+ key={candidate.id}
136
+ type="button"
137
+ role="tab"
138
+ aria-selected={page === candidate.id}
139
+ onClick={() => setPage(candidate.id)}
140
+ className={cn(segmentClass(page === candidate.id), "flex-1 px-1.5 py-1 focus-visible:ring-ring")}
141
+ >
142
+ {candidate.label}
143
+ </button>
144
+ ))}
145
+ </div>
146
+ <div role="tabpanel" aria-label={PAGES.find((candidate) => candidate.id === page)?.label} className="flex flex-col">
147
+ {page === "organize" ? (
148
+ <>
149
+ <Heading>Group by</Heading>
150
+ <Choice label="By project" value="project" current={prefs.organizationMode} onSelect={(value) => onPrefs({ organizationMode: value })} />
151
+ <Choice label="Custom" description="Your own sections" value="chronological" current={prefs.organizationMode} onSelect={(value) => onPrefs({ organizationMode: value })} />
152
+ <Choice label="By machine" value="machine" current={prefs.organizationMode} onSelect={(value) => onPrefs({ organizationMode: value })} />
153
+ <Heading>Child threads</Heading>
154
+ <Choice label="Folded" description="One flat level behind a chip" value="folded" current={prefs.nesting} onSelect={(value) => onPrefs({ nesting: value })} />
155
+ <Choice label="Tree" description="bb's indented tree" value="tree" current={prefs.nesting} onSelect={(value) => onPrefs({ nesting: value })} />
156
+ <Heading>Groups</Heading>
157
+ <Toggle
158
+ label="By environment"
159
+ description="Fold threads sharing a worktree into a folder"
160
+ checked={prefs.environmentGrouping}
161
+ onChange={(value) => onPrefs({ environmentGrouping: value })}
162
+ />
163
+ </>
164
+ ) : null}
165
+ {page === "sort" ? (
166
+ <>
167
+ <Heading>Sort by</Heading>
168
+ <Choice label="Updated" value="updated" current={sortField} onSelect={(value) => onPrefs({ chronologicalSort: value })} />
169
+ <Choice label="Created" value="created" current={sortField} onSelect={(value) => onPrefs({ chronologicalSort: value })} />
170
+ <Choice label="Alphabetical" value="alpha" current={sortField} onSelect={(value) => onPrefs({ chronologicalSort: value })} />
171
+ <Heading>Direction</Heading>
172
+ <Choice label="Default" value="default" current={prefs.sortDirection} onSelect={(value) => onPrefs({ sortDirection: value })} />
173
+ <Choice label="Ascending" value="ascending" current={prefs.sortDirection} onSelect={(value) => onPrefs({ sortDirection: value })} />
174
+ <Choice label="Descending" value="descending" current={prefs.sortDirection} onSelect={(value) => onPrefs({ sortDirection: value })} />
175
+ <Heading>Order</Heading>
176
+ <Toggle
177
+ label="Working first"
178
+ description="Running threads on top under Updated"
179
+ checked={prefs.workingFirst}
180
+ onChange={(value) => onPrefs({ workingFirst: value })}
181
+ />
182
+ <Toggle
183
+ label="Fold older threads"
184
+ description="Keep the 5 most recent quiet threads per group"
185
+ checked={prefs.foldOlder}
186
+ onChange={(value) => onPrefs({ foldOlder: value })}
187
+ />
188
+ </>
189
+ ) : null}
190
+ {page === "display" ? (
191
+ <>
192
+ <Heading>Density</Heading>
193
+ <Choice label="Compact" description="One line per thread" value="compact" current={client.density} onSelect={(value) => onClient({ density: value })} />
194
+ <Choice label="Comfortable" description="Adds branch and machine" value="comfortable" current={client.density} onSelect={(value) => onClient({ density: value })} />
195
+ <Heading>Harness icon</Heading>
196
+ <Choice label="Muted" description="Monochrome, beside the age" value="muted" current={prefs.harnessIcon} onSelect={(value) => onPrefs({ harnessIcon: value })} />
197
+ <Choice label="Colour" description="Each provider's own tint" value="colour" current={prefs.harnessIcon} onSelect={(value) => onPrefs({ harnessIcon: value })} />
198
+ <Choice label="Hidden" description="Shown in the hover card only" value="hidden" current={prefs.harnessIcon} onSelect={(value) => onPrefs({ harnessIcon: value })} />
199
+ <Heading>Rows</Heading>
200
+ <Toggle label="Pull requests" checked={prefs.showPullRequests} onChange={(value) => onPrefs({ showPullRequests: value })} />
201
+ </>
202
+ ) : null}
203
+ {page === "filter" ? (
204
+ <>
205
+ <Heading>Show</Heading>
206
+ <Toggle label="Active" checked={prefs.threadLifecycles.includes("active")} onChange={() => toggleLifecycle("active")} />
207
+ <Toggle label="Archived" checked={prefs.threadLifecycles.includes("archived")} onChange={() => toggleLifecycle("archived")} />
208
+ <Heading>Child threads in Needs attention</Heading>
209
+ <Choice
210
+ label="Blocked and orphaned failures"
211
+ description="A child that waits on you, is offline, or failed while its manager is idle"
212
+ value="blocked"
213
+ current={prefs.childAttention}
214
+ onSelect={(value) => onPrefs({ childAttention: value })}
215
+ />
216
+ <Choice
217
+ label="Everything"
218
+ description="Also every failed or unread child"
219
+ value="everything"
220
+ current={prefs.childAttention}
221
+ onSelect={(value) => onPrefs({ childAttention: value })}
222
+ />
223
+ </>
224
+ ) : null}
225
+ </div>
226
+ </div>
227
+ );
228
+ }
229
+
230
+ export function Toolbar({
231
+ prefs,
232
+ client,
233
+ attentionCount,
234
+ compact,
235
+ onPrefs,
236
+ onClient,
237
+ }: {
238
+ prefs: Preferences;
239
+ client: ClientPreferences;
240
+ /** Threads that need attention, for the filter's badge. */
241
+ attentionCount: number;
242
+ /** Phone width: the filter's long label would truncate. */
243
+ compact: boolean;
244
+ onPrefs(patch: Partial<Preferences>): void;
245
+ onClient(patch: Partial<ClientPreferences>): void;
246
+ }) {
247
+ return (
248
+ // Sticky with the group headers, so the filter stays in reach.
249
+ <div className="sticky top-0 z-30 flex h-8 items-center gap-1 bg-sidebar pb-1">
250
+ <TooltipProvider delayDuration={400}>
251
+ <div role="radiogroup" aria-label="Filter threads" className={cn(SEGMENT_TRACK, "min-w-0 flex-1")}>
252
+ {(
253
+ [
254
+ ["all", "All"],
255
+ ["attention", "Needs attention"],
256
+ ] as const
257
+ ).map(([value, label]) => {
258
+ const button = (
259
+ <button
260
+ key={value}
261
+ type="button"
262
+ role="radio"
263
+ aria-checked={client.filter === value}
264
+ aria-description={value === "attention" ? ATTENTION_EXPLANATION : undefined}
265
+ onClick={() => onClient({ filter: value })}
266
+ className={cn(segmentClass(client.filter === value), "min-w-0 flex-1 gap-1 truncate px-1.5 py-0.5 focus-visible:ring-sidebar-ring")}
267
+ >
268
+ <span className="truncate">{value === "attention" && compact ? "Attention" : label}</span>
269
+ {value === "attention" && attentionCount > 0 ? (
270
+ <span className="shrink-0 tabular-nums text-muted-foreground">{attentionCount}</span>
271
+ ) : null}
272
+ </button>
273
+ );
274
+ if (value !== "attention") return button;
275
+ return (
276
+ <Tooltip key={value}>
277
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
278
+ <TooltipContent side="bottom">{ATTENTION_SUMMARY}</TooltipContent>
279
+ </Tooltip>
280
+ );
281
+ })}
282
+ </div>
283
+ </TooltipProvider>
284
+ <Popover>
285
+ <PopoverTrigger asChild>
286
+ <button type="button" aria-label="Thread Glance settings" title="List settings" className={ROW_ICON_BUTTON}>
287
+ <Icon name={ICONS.settings} aria-hidden className="size-4" />
288
+ </button>
289
+ </PopoverTrigger>
290
+ <PopoverContent align="end" className="w-64 p-2">
291
+ <SettingsPanel prefs={prefs} client={client} onPrefs={onPrefs} onClient={onClient} />
292
+ </PopoverContent>
293
+ </Popover>
294
+ </div>
295
+ );
296
+ }
@@ -0,0 +1,64 @@
1
+ // What rows and headers are given to act with. The list keeps one object
2
+ // while the settings it carries hold still; its callbacks read the latest
3
+ // state, so a thread changing never renews it.
4
+ import { createContext } from "react";
5
+ import type { PluginSidebarSection, PluginSidebarThread } from "@get-bb/plugin-sdk/app";
6
+ import type { Stamps } from "@/shared/contract";
7
+ import type { HarnessIcon, Nesting, OrganizationMode } from "@/shared/preferences";
8
+ import type { ThreadNotes } from "@/shared/contract";
9
+ import type { Family } from "../model/families";
10
+ import type { RowMenuAction } from "../model/menu";
11
+ import type { OlderRow, ThreadRow } from "../model/view";
12
+ import type { ProviderDisplay } from "./ProviderBadge";
13
+
14
+ export interface ModelInfo {
15
+ /** The catalog's display name ("Haiku 4.5"), or the raw id when unknown. */
16
+ model: string;
17
+ reasoningLevel: string;
18
+ }
19
+
20
+ export interface RowController {
21
+ compact: boolean;
22
+ comfortable: boolean;
23
+ setEditingId(id: string | null): void;
24
+ showPullRequests: boolean;
25
+ harnessIcon: HarnessIcon;
26
+ /** Undefined while unknown; the lookup starts on first ask. */
27
+ defaultBranchOf(thread: PluginSidebarThread): string | null | undefined;
28
+ multiHost: boolean;
29
+ /** One object per harness, the same one on every call. */
30
+ provider(providerId: string): ProviderDisplay;
31
+ sections: readonly PluginSidebarSection[];
32
+ mode: OrganizationMode;
33
+ nesting: Nesting;
34
+ onNavigate(): void;
35
+ onToggleChip(row: ThreadRow): void;
36
+ onToggleOlder(row: OlderRow): void;
37
+ onToggleEnvironment(environmentId: string): void;
38
+ onMenuAction(action: RowMenuAction, thread: PluginSidebarThread, sectionId?: string | null): void;
39
+ onRename(threadId: string, title: string): Promise<void>;
40
+ loadModel(threadId: string, status: string): Promise<ModelInfo | null>;
41
+ openDetails(threadId: string): void;
42
+ /** Opens a new thread in an environment (folder menu). */
43
+ onNewThreadInEnvironment(environmentId: string, projectId: string, sectionId: string | null): void;
44
+ onRenameEnvironment(environmentId: string, name: string): Promise<void>;
45
+ onArchiveEnvironment(environmentId: string): void;
46
+ }
47
+
48
+ /**
49
+ * What changes with every event and only open details read: rows get their
50
+ * own slice as props instead, so one thread changing renders one row.
51
+ */
52
+ export interface ListLive {
53
+ now: number;
54
+ stamps: Stamps;
55
+ notes: Readonly<Record<string, ThreadNotes>>;
56
+ familyOf(threadId: string): Family | undefined;
57
+ }
58
+
59
+ export const ListLiveContext = createContext<ListLive>({
60
+ now: 0,
61
+ stamps: { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {} },
62
+ notes: {},
63
+ familyOf: () => undefined,
64
+ });
@@ -0,0 +1,129 @@
1
+ // State glyphs, chip glyphs and header counters. They draw what the model
2
+ // decided; tone and animation come from host token classes only.
3
+ import { experimental_Icon as Icon } from "@get-bb/plugin-sdk/app";
4
+ import type { PluginSidebarThreadRowStatus } from "@get-bb/plugin-sdk/app";
5
+ import { cn } from "@/lib/utils";
6
+ import type { Counters } from "../model/counters";
7
+ import { FLAG_GLYPHS, type ChipTone, type Flag, type Glyph, type Tone } from "../model/state";
8
+
9
+ export const TONE_CLASS: Record<Tone, string> = {
10
+ attention: "text-attention",
11
+ destructive: "text-destructive",
12
+ // bb's timeline accent: blue, readable on the sidebar in both themes.
13
+ working: "text-[var(--timeline-accent)]",
14
+ background: "text-muted-foreground/50",
15
+ "muted-strong": "text-muted-foreground/75",
16
+ muted: "text-muted-foreground",
17
+ none: "",
18
+ };
19
+
20
+ // The tone colour is one of bb's tokens, mixed toward the foreground for the
21
+ // text so it clears 4.5:1 on the sidebar in both themes; the border and fill
22
+ // are the same colour, thinned. Written out in full so the class scan sees them.
23
+ export const CHIP_TONE_CLASS: Record<ChipTone, string> = {
24
+ attention:
25
+ "border-[color:color-mix(in_oklch,var(--attention)_45%,transparent)] bg-[color-mix(in_oklch,var(--attention)_10%,transparent)] text-[color:color-mix(in_oklch,var(--attention),var(--foreground)_55%)] hover:bg-[color-mix(in_oklch,var(--attention)_20%,transparent)]",
26
+ destructive:
27
+ "border-[color:color-mix(in_oklch,var(--destructive-text)_45%,transparent)] bg-[color-mix(in_oklch,var(--destructive-text)_10%,transparent)] text-[color:color-mix(in_oklch,var(--destructive-text),var(--foreground)_55%)] hover:bg-[color-mix(in_oklch,var(--destructive-text)_20%,transparent)]",
28
+ working:
29
+ "border-[color:color-mix(in_oklch,var(--timeline-accent)_45%,transparent)] bg-[color-mix(in_oklch,var(--timeline-accent)_10%,transparent)] text-[color:color-mix(in_oklch,var(--timeline-accent),var(--foreground)_55%)] hover:bg-[color-mix(in_oklch,var(--timeline-accent)_20%,transparent)]",
30
+ neutral: "border-border/70 text-muted-foreground hover:bg-state-hover hover:text-foreground",
31
+ };
32
+
33
+ const SHINE = "animate-shine-icon motion-reduce:animate-none";
34
+ const SPIN = "animate-spin motion-reduce:animate-none";
35
+
36
+ // The unread dot takes bb's timeline accent, the one blue bb has, so unread
37
+ // reads against the neutral list; bb's own list draws it in the grey primary.
38
+ export function UnreadDot({ label, className }: { label?: string; className?: string }) {
39
+ return (
40
+ <span
41
+ role={label ? "img" : undefined}
42
+ aria-label={label}
43
+ aria-hidden={label ? undefined : true}
44
+ className={cn("inline-block size-[6px] shrink-0 rounded-full bg-[var(--timeline-accent)]", className)}
45
+ />
46
+ );
47
+ }
48
+
49
+ export function GlyphIcon({
50
+ glyph,
51
+ label,
52
+ className,
53
+ }: {
54
+ glyph: Glyph;
55
+ label?: string;
56
+ className?: string;
57
+ }) {
58
+ if (glyph.icon === null) return null;
59
+ if (glyph.icon === "dot") return <UnreadDot label={label} />;
60
+ return (
61
+ <Icon
62
+ name={glyph.icon}
63
+ aria-label={label}
64
+ aria-hidden={label ? undefined : true}
65
+ className={cn(
66
+ "size-4 shrink-0",
67
+ TONE_CLASS[glyph.tone],
68
+ glyph.spin && SPIN,
69
+ glyph.shine && SHINE,
70
+ className,
71
+ )}
72
+ />
73
+ );
74
+ }
75
+
76
+ /** A plugin row status in place of the state glyph. */
77
+ export function PluginStatusGlyph({ status }: { status: PluginSidebarThreadRowStatus }) {
78
+ const tone =
79
+ status.tone === "error"
80
+ ? "text-destructive"
81
+ : status.tone === "success"
82
+ ? "text-success-foreground"
83
+ : status.tone === "running"
84
+ ? "text-success motion-safe:animate-pulse"
85
+ : "text-muted-foreground";
86
+ return (
87
+ <Icon
88
+ name={status.icon}
89
+ aria-label={status.label}
90
+ className={cn("size-4 shrink-0", tone, status.tone === "running" && SHINE)}
91
+ />
92
+ );
93
+ }
94
+
95
+ export function FlagGlyph({ flag, className }: { flag: Flag; className?: string }) {
96
+ return <GlyphIcon glyph={FLAG_GLYPHS[flag]} className={className} />;
97
+ }
98
+
99
+ const COUNTER_ITEMS: readonly {
100
+ key: keyof Counters;
101
+ flag: Flag;
102
+ label: (count: number) => string;
103
+ }[] = [
104
+ { key: "needsYou", flag: "needs-you", label: (n) => `${n} need${n === 1 ? "s" : ""} you` },
105
+ { key: "failed", flag: "unread-failed", label: (n) => `${n} failed` },
106
+ { key: "offline", flag: "offline", label: (n) => `${n} waiting for an offline machine` },
107
+ { key: "working", flag: "working", label: (n) => `${n} working` },
108
+ { key: "unread", flag: "unread", label: (n) => `${n} unread` },
109
+ ];
110
+
111
+ /** Header counters: zero counters are not drawn. */
112
+ export function CounterStrip({ counters, className }: { counters: Counters; className?: string }) {
113
+ const items = COUNTER_ITEMS.filter((item) => counters[item.key] > 0);
114
+ if (items.length === 0) return null;
115
+ return (
116
+ <span
117
+ role="group"
118
+ aria-label={items.map((item) => item.label(counters[item.key])).join(", ")}
119
+ className={cn("inline-flex shrink-0 items-center gap-1.5 text-xs tabular-nums", className)}
120
+ >
121
+ {items.map((item) => (
122
+ <span key={item.key} aria-hidden className="inline-flex items-center gap-0.5 text-muted-foreground">
123
+ <FlagGlyph flag={item.flag} className="size-3.5" />
124
+ {counters[item.key]}
125
+ </span>
126
+ ))}
127
+ </span>
128
+ );
129
+ }
@@ -0,0 +1,127 @@
1
+ // When a row's hover card opens: after the pointer really moves over the
2
+ // row, or when a key moved focus onto it. Rows sliding under a still
3
+ // pointer, focus put back after navigation, and the row you just left
4
+ // open nothing.
5
+ import { useEffect, useRef, useState, type FocusEvent, type PointerEvent } from "react";
6
+
7
+ const OPEN_DELAY = 500;
8
+ /** Keys that move focus, so the focus they bring may show a card. */
9
+ const FOCUS_KEYS = new Set(["Tab", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"]);
10
+
11
+ /** The last input was a key that moves focus: `:focus-visible`, as far as the card is concerned. */
12
+ let keyed = false;
13
+ /** Bumped by every press and every navigation; an open scheduled before a bump never happens. */
14
+ let epoch = 0;
15
+ let listeners = 0;
16
+
17
+ function onKeyDown(event: KeyboardEvent) {
18
+ keyed = FOCUS_KEYS.has(event.key);
19
+ }
20
+
21
+ function onPress() {
22
+ keyed = false;
23
+ epoch += 1;
24
+ }
25
+
26
+ /** Drops every pending card open, for a navigation. */
27
+ export function cancelPendingCards(): void {
28
+ epoch += 1;
29
+ }
30
+
31
+ export interface RowCard {
32
+ open: boolean;
33
+ /** For the HoverCard: it may close the card, never open it. */
34
+ onOpenChange: (open: boolean) => void;
35
+ rowProps: {
36
+ onPointerEnter: (event: PointerEvent) => void;
37
+ onPointerMove: (event: PointerEvent) => void;
38
+ onPointerLeave: () => void;
39
+ onPointerDown: () => void;
40
+ onFocus: (event: FocusEvent) => void;
41
+ onBlur: () => void;
42
+ };
43
+ }
44
+
45
+ /** The card state for one row. Off for the active row and while `enabled` is false. */
46
+ export function useRowCard(isActive: boolean, enabled: boolean): RowCard {
47
+ const [open, setOpen] = useState(false);
48
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
49
+ // Where the pointer entered: a move must leave this spot.
50
+ const entry = useRef<{ x: number; y: number } | null>(null);
51
+ // A press holds the card shut until the pointer leaves the row.
52
+ const pressed = useRef(false);
53
+
54
+ const cancel = () => {
55
+ if (timer.current !== null) clearTimeout(timer.current);
56
+ timer.current = null;
57
+ };
58
+ const schedule = () => {
59
+ if (timer.current !== null || !enabled || pressed.current) return;
60
+ const at = epoch;
61
+ timer.current = setTimeout(() => {
62
+ timer.current = null;
63
+ if (epoch === at) setOpen(true);
64
+ }, OPEN_DELAY);
65
+ };
66
+
67
+ useEffect(() => {
68
+ if (listeners++ === 0) {
69
+ // Keys pressed while no row listened say nothing about the next focus.
70
+ keyed = false;
71
+ document.addEventListener("keydown", onKeyDown, true);
72
+ document.addEventListener("pointerdown", onPress, true);
73
+ }
74
+ return () => {
75
+ cancel();
76
+ if (--listeners === 0) {
77
+ document.removeEventListener("keydown", onKeyDown, true);
78
+ document.removeEventListener("pointerdown", onPress, true);
79
+ }
80
+ };
81
+ }, []);
82
+
83
+ // Becoming or ceasing to be the open thread, or a menu or rename taking
84
+ // the row, closes the card and drops a pending open.
85
+ useEffect(() => {
86
+ cancel();
87
+ setOpen(false);
88
+ }, [isActive, enabled]);
89
+
90
+ return {
91
+ open: open && enabled && !isActive,
92
+ onOpenChange: (next) => {
93
+ if (next) return;
94
+ cancel();
95
+ setOpen(false);
96
+ },
97
+ rowProps: {
98
+ onPointerEnter: (event) => {
99
+ entry.current = { x: event.clientX, y: event.clientY };
100
+ },
101
+ onPointerMove: (event) => {
102
+ if (event.pointerType === "touch") return;
103
+ const from = entry.current;
104
+ if (from !== null && from.x === event.clientX && from.y === event.clientY) return;
105
+ entry.current = null;
106
+ schedule();
107
+ },
108
+ onPointerLeave: () => {
109
+ cancel();
110
+ entry.current = null;
111
+ pressed.current = false;
112
+ },
113
+ onPointerDown: () => {
114
+ pressed.current = true;
115
+ cancel();
116
+ setOpen(false);
117
+ },
118
+ onFocus: () => {
119
+ // One key, one focus: focus moved by code afterwards opens nothing.
120
+ if (!keyed) return;
121
+ keyed = false;
122
+ schedule();
123
+ },
124
+ onBlur: cancel,
125
+ },
126
+ };
127
+ }
@@ -0,0 +1,18 @@
1
+ // localStorage access that never throws (private mode, quota, bad JSON).
2
+
3
+ export function readJson(key: string): unknown {
4
+ try {
5
+ const raw = globalThis.localStorage?.getItem(key);
6
+ return raw == null ? null : (JSON.parse(raw) as unknown);
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+
12
+ export function writeJson(key: string, value: unknown): void {
13
+ try {
14
+ globalThis.localStorage?.setItem(key, JSON.stringify(value));
15
+ } catch {
16
+ // The mirror is only for first paint; losing it costs one flash.
17
+ }
18
+ }