@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,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
|
+
}
|