@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.
- package/LICENSE +21 -0
- package/README.md +20 -0
- package/THIRD_PARTY_NOTICES.md +230 -0
- package/app.tsx +12 -0
- package/components/ui/alert-dialog.tsx +142 -0
- package/components/ui/button.tsx +59 -0
- package/components/ui/card.tsx +83 -0
- package/components/ui/coarse-pointer-sizing.ts +66 -0
- package/components/ui/context-menu.tsx +355 -0
- package/components/ui/dialog.tsx +416 -0
- package/components/ui/dropdown-menu.tsx +640 -0
- package/components/ui/hooks/use-compact-viewport.tsx +37 -0
- package/components/ui/hooks/use-media-query.ts +70 -0
- package/components/ui/hooks/use-pointer-coarse.ts +7 -0
- package/components/ui/hover-card.tsx +36 -0
- package/components/ui/icon-extended.tsx +333 -0
- package/components/ui/icon-registry.ts +189 -0
- package/components/ui/icon.tsx +444 -0
- package/components/ui/input.tsx +30 -0
- package/components/ui/menu-item-hover.tsx +93 -0
- package/components/ui/motion.ts +4 -0
- package/components/ui/overlay-trigger.ts +84 -0
- package/components/ui/popover.tsx +223 -0
- package/components/ui/responsive-overlay.tsx +823 -0
- package/components/ui/tooltip.tsx +63 -0
- package/dist/app.css +2 -0
- package/dist/app.js +66 -0
- package/dist/app.meta.json +11 -0
- package/dist/server.js +71 -0
- package/dist/server.js.map +6 -0
- package/dist/server.meta.json +11 -0
- package/features/thread-list/components/Dialogs.tsx +299 -0
- package/features/thread-list/components/FoldRows.tsx +147 -0
- package/features/thread-list/components/GroupSection.tsx +370 -0
- package/features/thread-list/components/ProviderBadge.tsx +57 -0
- package/features/thread-list/components/PullRequestBadge.tsx +50 -0
- package/features/thread-list/components/Rails.tsx +28 -0
- package/features/thread-list/components/RenameEditor.tsx +118 -0
- package/features/thread-list/components/RowMenu.tsx +168 -0
- package/features/thread-list/components/SplitMiniMap.tsx +55 -0
- package/features/thread-list/components/ThreadDetails.tsx +138 -0
- package/features/thread-list/components/ThreadList.tsx +855 -0
- package/features/thread-list/components/ThreadRowView.tsx +631 -0
- package/features/thread-list/components/Toolbar.tsx +296 -0
- package/features/thread-list/components/controller.ts +64 -0
- package/features/thread-list/components/glyphs.tsx +129 -0
- package/features/thread-list/components/row-card.ts +127 -0
- package/features/thread-list/data/storage.ts +18 -0
- package/features/thread-list/data/useAutoExpand.ts +53 -0
- package/features/thread-list/data/useClientPreferences.ts +20 -0
- package/features/thread-list/data/useNotes.ts +41 -0
- package/features/thread-list/data/useNow.ts +19 -0
- package/features/thread-list/data/usePreferences.ts +126 -0
- package/features/thread-list/data/useScheduled.ts +43 -0
- package/features/thread-list/data/useStamps.ts +68 -0
- package/features/thread-list/icons.ts +44 -0
- package/features/thread-list/index.ts +2 -0
- package/features/thread-list/model/attention.ts +89 -0
- package/features/thread-list/model/counters.ts +67 -0
- package/features/thread-list/model/details.ts +79 -0
- package/features/thread-list/model/drag.ts +104 -0
- package/features/thread-list/model/expansion.ts +71 -0
- package/features/thread-list/model/families.ts +297 -0
- package/features/thread-list/model/groups.ts +263 -0
- package/features/thread-list/model/labels.ts +50 -0
- package/features/thread-list/model/layout.ts +61 -0
- package/features/thread-list/model/menu.ts +78 -0
- package/features/thread-list/model/move.ts +46 -0
- package/features/thread-list/model/notes.ts +52 -0
- package/features/thread-list/model/provider-mark.ts +59 -0
- package/features/thread-list/model/share.ts +101 -0
- package/features/thread-list/model/sort.ts +78 -0
- package/features/thread-list/model/state.ts +330 -0
- package/features/thread-list/model/time.ts +107 -0
- package/features/thread-list/model/toggles.ts +99 -0
- package/features/thread-list/model/view.ts +669 -0
- package/features/thread-list/model/windowing.ts +27 -0
- package/features/thread-list/testing/fixtures.ts +162 -0
- package/hooks/useBrowserDimmingModal.ts +5 -0
- package/lib/portal-scope.ts +15 -0
- package/lib/utils.ts +17 -0
- package/package.json +96 -0
- package/server/cli.ts +107 -0
- package/server/failures.ts +136 -0
- package/server/import.ts +144 -0
- package/server/notes.ts +236 -0
- package/server/preference-store.ts +96 -0
- package/server/scheduled.ts +129 -0
- package/server/serial.ts +12 -0
- package/server/stamps.ts +142 -0
- package/server/startup.ts +121 -0
- package/server.ts +126 -0
- package/shared/contract.ts +73 -0
- package/shared/preferences.ts +241 -0
- package/shared/signals.ts +71 -0
- package/skills/thread-glance/SKILL.md +69 -0
- package/tsconfig.json +36 -0
- 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
|
+
}
|