@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,855 @@
|
|
|
1
|
+
// The sidebar list: feeds the model from host hooks and the plugin
|
|
2
|
+
// server, and hands rows and headers what they need to act.
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
DndContext,
|
|
6
|
+
KeyboardSensor,
|
|
7
|
+
MouseSensor,
|
|
8
|
+
pointerWithin,
|
|
9
|
+
TouchSensor,
|
|
10
|
+
useSensor,
|
|
11
|
+
useSensors,
|
|
12
|
+
type CollisionDetection,
|
|
13
|
+
type DragEndEvent,
|
|
14
|
+
type DragMoveEvent,
|
|
15
|
+
} from "@dnd-kit/core";
|
|
16
|
+
import {
|
|
17
|
+
experimental_Icon as Icon,
|
|
18
|
+
experimental_useProviders as useProviders,
|
|
19
|
+
experimental_useSidebarThreadActions as useThreadActions,
|
|
20
|
+
experimental_useSidebarThreads as useSidebarThreads,
|
|
21
|
+
useEnvironmentProviders,
|
|
22
|
+
useSdk,
|
|
23
|
+
useSidebarThreadDraftIds,
|
|
24
|
+
} from "@get-bb/plugin-sdk/app";
|
|
25
|
+
import type { PluginSidebarThread, PluginThreadListProps } from "@get-bb/plugin-sdk/app";
|
|
26
|
+
import { toast } from "sonner";
|
|
27
|
+
import { Button } from "@/components/ui/button";
|
|
28
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
29
|
+
import { cn } from "@/lib/utils";
|
|
30
|
+
import { useAutoExpand } from "../data/useAutoExpand";
|
|
31
|
+
import { useClientPreferences } from "../data/useClientPreferences";
|
|
32
|
+
import { useNow } from "../data/useNow";
|
|
33
|
+
import { usePreferences } from "../data/usePreferences";
|
|
34
|
+
import { useScheduled } from "../data/useScheduled";
|
|
35
|
+
import { useStamps } from "../data/useStamps";
|
|
36
|
+
import { resolveDrop, type DraggedThread, type DropAction, type DropTarget } from "../model/drag";
|
|
37
|
+
import { buildForest } from "../model/families";
|
|
38
|
+
import { moveGroup, ORDER_PREFERENCE } from "../model/groups";
|
|
39
|
+
import { MARK_ALL_CONFIRM_ABOVE, type RowMenuAction } from "../model/menu";
|
|
40
|
+
import { assignProviderMarks, providerMark } from "../model/provider-mark";
|
|
41
|
+
import { isDoneUnseen } from "../model/state";
|
|
42
|
+
import { markAllReadPlan, toggleChip, toggleGroup, toggleOlder, type ToggleOutcome } from "../model/toggles";
|
|
43
|
+
import { buildListView, type GroupView, type ListView } from "../model/view";
|
|
44
|
+
import { shareView } from "../model/share";
|
|
45
|
+
import { ListLiveContext, type ListLive, type ModelInfo, type RowController } from "./controller";
|
|
46
|
+
import { ConfirmDialog, CustomizeDialog, DetailsDialog, MoveDialog, NewSectionDialog, type CustomizeItem } from "./Dialogs";
|
|
47
|
+
import { useNotes } from "../data/useNotes";
|
|
48
|
+
import { moveTargets } from "../model/move";
|
|
49
|
+
import { modelDisplayName } from "../model/details";
|
|
50
|
+
import { groupIdForRoot } from "../model/groups";
|
|
51
|
+
import { CounterStrip } from "./glyphs";
|
|
52
|
+
import { cancelPendingCards } from "./row-card";
|
|
53
|
+
import { GroupSection, type DropStates, type GroupController } from "./GroupSection";
|
|
54
|
+
import type { ProviderDisplay } from "./ProviderBadge";
|
|
55
|
+
import { ThreadDetails } from "./ThreadDetails";
|
|
56
|
+
import { Toolbar } from "./Toolbar";
|
|
57
|
+
|
|
58
|
+
const PLUGIN_ID = "thread-glance";
|
|
59
|
+
|
|
60
|
+
type Confirm = { title: string; description: string; confirmLabel: string; destructive?: boolean; run(): void };
|
|
61
|
+
|
|
62
|
+
function describeError(error: unknown): string {
|
|
63
|
+
return error instanceof Error ? error.message : String(error);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function copyText(text: string, message: string) {
|
|
67
|
+
try {
|
|
68
|
+
await navigator.clipboard.writeText(text);
|
|
69
|
+
toast.success(message);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
toast.error("Couldn't copy", { description: describeError(error) });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Constant, so dnd-kit's sensors, and with them its context, stay put: new
|
|
76
|
+
// options renew the context every draggable row reads, rendering every row.
|
|
77
|
+
const MOUSE_SENSOR = { activationConstraint: { distance: 4 } };
|
|
78
|
+
const TOUCH_SENSOR = { activationConstraint: { delay: 200, tolerance: 6 } };
|
|
79
|
+
|
|
80
|
+
/** Prefers a thread row's drop zone over the group that contains it. */
|
|
81
|
+
const collision: CollisionDetection = (args) => {
|
|
82
|
+
const kind = args.active.data.current?.kind;
|
|
83
|
+
const hits = pointerWithin(args);
|
|
84
|
+
if (kind === "group") return hits.filter((hit) => String(hit.id).startsWith("drop-group:"));
|
|
85
|
+
const rows = hits.filter((hit) => String(hit.id).startsWith("drop-thread:"));
|
|
86
|
+
return rows.length > 0 ? rows : hits;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function pointerY(event: DragMoveEvent | DragEndEvent): number | null {
|
|
90
|
+
const activator = event.activatorEvent;
|
|
91
|
+
let start: number | null = null;
|
|
92
|
+
if (activator instanceof MouseEvent) start = activator.clientY;
|
|
93
|
+
else if (typeof TouchEvent !== "undefined" && activator instanceof TouchEvent) start = activator.touches[0]?.clientY ?? null;
|
|
94
|
+
return start === null ? null : start + event.delta.y;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function ThreadList(props: PluginThreadListProps) {
|
|
98
|
+
const [attempt, setAttempt] = useState(0);
|
|
99
|
+
return <ThreadListBody key={attempt} {...props} attempt={attempt} onRetry={() => setAttempt((value) => value + 1)} />;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ThreadListBody({
|
|
103
|
+
activeThreadId,
|
|
104
|
+
isCompactViewport,
|
|
105
|
+
onNavigate,
|
|
106
|
+
attempt,
|
|
107
|
+
onRetry,
|
|
108
|
+
}: PluginThreadListProps & { attempt: number; onRetry(): void }) {
|
|
109
|
+
const { prefs, hydrated, update } = usePreferences();
|
|
110
|
+
const [client, updateClient] = useClientPreferences();
|
|
111
|
+
const sidebar = useSidebarThreads({ experimental_lifecycles: prefs.threadLifecycles });
|
|
112
|
+
const actions = useThreadActions();
|
|
113
|
+
const sdk = useSdk();
|
|
114
|
+
const { providers } = useProviders();
|
|
115
|
+
const { providers: environmentProviders } = useEnvironmentProviders();
|
|
116
|
+
const draftIds = useSidebarThreadDraftIds();
|
|
117
|
+
const { stamps, markSeen, clearSeen } = useStamps();
|
|
118
|
+
const scheduled = useScheduled();
|
|
119
|
+
const notes = useNotes();
|
|
120
|
+
const nextDeadline = useMemo(() => {
|
|
121
|
+
const future = Object.values(scheduled).filter((at) => at > Date.now());
|
|
122
|
+
return future.length > 0 ? Math.min(...future) : null;
|
|
123
|
+
}, [scheduled]);
|
|
124
|
+
const now = useNow(nextDeadline);
|
|
125
|
+
|
|
126
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
127
|
+
const [detailsId, setDetailsId] = useState<string | null>(null);
|
|
128
|
+
const [moveId, setMoveId] = useState<string | null>(null);
|
|
129
|
+
const [moveQuery, setMoveQuery] = useState("");
|
|
130
|
+
const catalogs = useRef(new Map<string, Promise<readonly { id: string; model: string; displayName: string }[]>>());
|
|
131
|
+
const [customizeOpen, setCustomizeOpen] = useState(false);
|
|
132
|
+
const [newSectionOpen, setNewSectionOpen] = useState(false);
|
|
133
|
+
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
|
134
|
+
const [dropStates, setDropStates] = useState<DropStates>(() => new Map());
|
|
135
|
+
const [dropGroupId, setDropGroupId] = useState<string | null>(null);
|
|
136
|
+
const [defaultBranches, setDefaultBranches] = useState<ReadonlyMap<string, string | null>>(() => new Map());
|
|
137
|
+
const branchRequests = useRef(new Set<string>());
|
|
138
|
+
const models = useRef(new Map<string, Promise<ModelInfo | null>>());
|
|
139
|
+
|
|
140
|
+
const ready = sidebar.status === "ready";
|
|
141
|
+
const threads = sidebar.threads;
|
|
142
|
+
const byId = useMemo(() => new Map(threads.map((thread) => [thread.id, thread])), [threads]);
|
|
143
|
+
|
|
144
|
+
const forest = useMemo(
|
|
145
|
+
() =>
|
|
146
|
+
ready
|
|
147
|
+
? buildForest({
|
|
148
|
+
threads,
|
|
149
|
+
activeThreadId,
|
|
150
|
+
finishedAt: stamps.finishedAt,
|
|
151
|
+
seenAt: stamps.seenAt,
|
|
152
|
+
draftIds,
|
|
153
|
+
scheduled,
|
|
154
|
+
now,
|
|
155
|
+
notes,
|
|
156
|
+
childAttention: prefs.childAttention,
|
|
157
|
+
})
|
|
158
|
+
: null,
|
|
159
|
+
[ready, threads, activeThreadId, stamps.finishedAt, stamps.seenAt, draftIds, scheduled, now, notes, prefs.childAttention],
|
|
160
|
+
);
|
|
161
|
+
const { targets, prune } = useAutoExpand(hydrated ? forest : null, activeThreadId);
|
|
162
|
+
// Rows and groups that did not change keep their objects, so their
|
|
163
|
+
// memoized components skip the render.
|
|
164
|
+
const previousView = useRef<ListView | null>(null);
|
|
165
|
+
const view = useMemo(
|
|
166
|
+
() =>
|
|
167
|
+
forest === null
|
|
168
|
+
? null
|
|
169
|
+
: shareView(previousView.current, buildListView({
|
|
170
|
+
forest,
|
|
171
|
+
threads,
|
|
172
|
+
projects: sidebar.projects,
|
|
173
|
+
sections: sidebar.sections,
|
|
174
|
+
prefs,
|
|
175
|
+
filter: client.filter,
|
|
176
|
+
activeThreadId,
|
|
177
|
+
targets,
|
|
178
|
+
})),
|
|
179
|
+
[forest, threads, sidebar.projects, sidebar.sections, prefs, client.filter, activeThreadId, targets],
|
|
180
|
+
);
|
|
181
|
+
useLayoutEffect(() => {
|
|
182
|
+
previousView.current = view;
|
|
183
|
+
}, [view]);
|
|
184
|
+
|
|
185
|
+
// Viewing a child stamps seenAt, on arrival and on leaving, so a child
|
|
186
|
+
// that finishes while you watch doesn't turn unread behind you.
|
|
187
|
+
const previousActive = useRef<string | null>(null);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
const ids = [previousActive.current, activeThreadId].filter(
|
|
190
|
+
(id): id is string => id !== null && byId.get(id)?.parentThreadId != null,
|
|
191
|
+
);
|
|
192
|
+
previousActive.current = activeThreadId;
|
|
193
|
+
cancelPendingCards();
|
|
194
|
+
if (ids.length > 0) markSeen([...new Set(ids)]);
|
|
195
|
+
// Only the active thread's changes matter here.
|
|
196
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
197
|
+
}, [activeThreadId]);
|
|
198
|
+
|
|
199
|
+
// Each project's default branch, fetched once per session.
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (!prefs.showPullRequests || forest === null) return;
|
|
202
|
+
for (const family of forest.families) {
|
|
203
|
+
const thread = family.root.thread;
|
|
204
|
+
const projectId = thread.projectId;
|
|
205
|
+
if (thread.environment?.branchName == null || thread.host === null) continue;
|
|
206
|
+
if (branchRequests.current.has(projectId)) continue;
|
|
207
|
+
branchRequests.current.add(projectId);
|
|
208
|
+
sdk.projects.branches({ projectId, hostId: thread.host.id, limit: "1" }).then(
|
|
209
|
+
(result) => setDefaultBranches((current) => new Map(current).set(projectId, result.defaultBranch)),
|
|
210
|
+
() => setDefaultBranches((current) => new Map(current).set(projectId, null)),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}, [prefs.showPullRequests, forest, sdk]);
|
|
214
|
+
|
|
215
|
+
const marks = useMemo(() => assignProviderMarks(providers), [providers]);
|
|
216
|
+
// One display per harness, built once: rows compare it by identity.
|
|
217
|
+
const providerDisplay = useMemo(() => {
|
|
218
|
+
const displays = new Map<string, ProviderDisplay>();
|
|
219
|
+
const byProvider = new Map(providers.map((info) => [info.id, info]));
|
|
220
|
+
return (providerId: string): ProviderDisplay => {
|
|
221
|
+
let display = displays.get(providerId);
|
|
222
|
+
if (display === undefined) {
|
|
223
|
+
const info = byProvider.get(providerId);
|
|
224
|
+
display = {
|
|
225
|
+
id: providerId,
|
|
226
|
+
name: info?.displayName ?? providerId,
|
|
227
|
+
provider: info?.logoUrl ? info : null,
|
|
228
|
+
mark: providerMark(providerId, marks),
|
|
229
|
+
};
|
|
230
|
+
displays.set(providerId, display);
|
|
231
|
+
}
|
|
232
|
+
return display;
|
|
233
|
+
};
|
|
234
|
+
}, [providers, marks]);
|
|
235
|
+
|
|
236
|
+
const applyToggle = useCallback(
|
|
237
|
+
(outcome: ToggleOutcome) => {
|
|
238
|
+
if (Object.keys(outcome.patch).length > 0) update(outcome.patch);
|
|
239
|
+
if (outcome.drop !== null) prune(outcome.drop);
|
|
240
|
+
},
|
|
241
|
+
[update, prune],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const runMenuAction = useCallback(
|
|
245
|
+
(action: RowMenuAction, thread: PluginSidebarThread, sectionId?: string | null) => {
|
|
246
|
+
const context = { activeThreadId, finishedAt: stamps.finishedAt, seenAt: stamps.seenAt };
|
|
247
|
+
const fail = (message: string) => (error: unknown) => toast.error(message, { description: describeError(error) });
|
|
248
|
+
switch (action) {
|
|
249
|
+
case "open-in-split":
|
|
250
|
+
actions.open(thread.id, { split: true });
|
|
251
|
+
onNavigate();
|
|
252
|
+
return;
|
|
253
|
+
case "copy-link":
|
|
254
|
+
void copyText(new URL(thread.href, window.location.origin).toString(), "Thread link copied");
|
|
255
|
+
return;
|
|
256
|
+
case "copy-id":
|
|
257
|
+
void copyText(thread.id, "Thread ID copied");
|
|
258
|
+
return;
|
|
259
|
+
case "mark-read":
|
|
260
|
+
if (isDoneUnseen(thread, context)) markSeen([thread.id]);
|
|
261
|
+
actions.setRead(thread.id, true).catch(fail("Couldn't mark read"));
|
|
262
|
+
return;
|
|
263
|
+
case "mark-unread":
|
|
264
|
+
clearSeen([thread.id]);
|
|
265
|
+
actions.setRead(thread.id, false).catch(fail("Couldn't mark unread"));
|
|
266
|
+
return;
|
|
267
|
+
case "pin":
|
|
268
|
+
case "unpin":
|
|
269
|
+
actions.setPinned(thread.id, action === "pin").catch(fail("Couldn't change the pin"));
|
|
270
|
+
return;
|
|
271
|
+
case "move-to-section":
|
|
272
|
+
sdk.threads.update({ threadId: thread.id, sectionId: sectionId ?? null }).catch(fail("Couldn't move the thread"));
|
|
273
|
+
return;
|
|
274
|
+
case "rename":
|
|
275
|
+
setEditingId(thread.id);
|
|
276
|
+
return;
|
|
277
|
+
case "details":
|
|
278
|
+
setDetailsId(thread.id);
|
|
279
|
+
return;
|
|
280
|
+
case "move":
|
|
281
|
+
setMoveQuery("");
|
|
282
|
+
setMoveId(thread.id);
|
|
283
|
+
return;
|
|
284
|
+
case "archive":
|
|
285
|
+
actions.archive(thread.id);
|
|
286
|
+
return;
|
|
287
|
+
case "unarchive":
|
|
288
|
+
sdk.threads.unarchive({ threadId: thread.id }).catch(fail("Couldn't unarchive"));
|
|
289
|
+
return;
|
|
290
|
+
case "delete":
|
|
291
|
+
// Let the menu close before bb's confirmation takes focus.
|
|
292
|
+
setTimeout(() => actions.requestDelete(thread.id), 0);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
[actions, activeThreadId, clearSeen, markSeen, onNavigate, sdk, stamps.finishedAt, stamps.seenAt],
|
|
297
|
+
);
|
|
298
|
+
const runMenuActionRef = useRef(runMenuAction);
|
|
299
|
+
useLayoutEffect(() => {
|
|
300
|
+
runMenuActionRef.current = runMenuAction;
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// The committed state for the controllers' callbacks: they read it when
|
|
304
|
+
// called, so the controllers themselves stay put while threads change.
|
|
305
|
+
const latest = useRef({ forest, view, prefs, byId, activeThreadId, stamps });
|
|
306
|
+
useLayoutEffect(() => {
|
|
307
|
+
latest.current = { forest, view, prefs, byId, activeThreadId, stamps };
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const loadModel = useCallback(
|
|
311
|
+
(threadId: string, status: string) => {
|
|
312
|
+
const key = `${threadId}:${status}`;
|
|
313
|
+
let pending = models.current.get(key);
|
|
314
|
+
if (pending === undefined) {
|
|
315
|
+
const thread = latest.current.byId.get(threadId);
|
|
316
|
+
const providerId = thread?.providerId ?? "";
|
|
317
|
+
// The catalog names the model as the composer does ("Haiku 4.5").
|
|
318
|
+
let catalog = catalogs.current.get(providerId);
|
|
319
|
+
if (catalog === undefined) {
|
|
320
|
+
catalog = sdk.providers
|
|
321
|
+
.models(thread?.host ? { providerId, hostId: thread.host.id } : { providerId })
|
|
322
|
+
.then(
|
|
323
|
+
(result) => result.models,
|
|
324
|
+
() => [],
|
|
325
|
+
);
|
|
326
|
+
catalogs.current.set(providerId, catalog);
|
|
327
|
+
}
|
|
328
|
+
const names = catalog;
|
|
329
|
+
pending = sdk.threads.defaultExecutionOptions({ threadId }).then(
|
|
330
|
+
async (options) =>
|
|
331
|
+
options === null
|
|
332
|
+
? null
|
|
333
|
+
: { model: modelDisplayName(options.model, await names), reasoningLevel: options.reasoningLevel },
|
|
334
|
+
() => null,
|
|
335
|
+
);
|
|
336
|
+
models.current.set(key, pending);
|
|
337
|
+
}
|
|
338
|
+
return pending;
|
|
339
|
+
},
|
|
340
|
+
[sdk],
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const multiHost = view?.multiHost ?? false;
|
|
344
|
+
const built = forest !== null && view !== null;
|
|
345
|
+
const rowController: RowController | null = useMemo(() => {
|
|
346
|
+
if (!built) return null;
|
|
347
|
+
return {
|
|
348
|
+
compact: isCompactViewport,
|
|
349
|
+
comfortable: client.density === "comfortable",
|
|
350
|
+
setEditingId,
|
|
351
|
+
showPullRequests: prefs.showPullRequests,
|
|
352
|
+
harnessIcon: prefs.harnessIcon,
|
|
353
|
+
defaultBranchOf: (thread) =>
|
|
354
|
+
defaultBranches.has(thread.projectId) ? defaultBranches.get(thread.projectId) ?? null : undefined,
|
|
355
|
+
multiHost,
|
|
356
|
+
provider: providerDisplay,
|
|
357
|
+
sections: sidebar.sections,
|
|
358
|
+
mode: prefs.organizationMode,
|
|
359
|
+
nesting: prefs.nesting,
|
|
360
|
+
onNavigate,
|
|
361
|
+
onToggleChip: (row) => {
|
|
362
|
+
const { prefs, forest } = latest.current;
|
|
363
|
+
applyToggle(toggleChip(row, prefs, forest!));
|
|
364
|
+
},
|
|
365
|
+
onToggleOlder: (row) => {
|
|
366
|
+
const { prefs, forest, view } = latest.current;
|
|
367
|
+
const group = view!.groups.find((candidate) => candidate.descriptor.id === row.scopeId) ?? null;
|
|
368
|
+
applyToggle(toggleOlder(row, prefs, group, forest!));
|
|
369
|
+
},
|
|
370
|
+
onToggleEnvironment: (environmentId) => {
|
|
371
|
+
const { collapsedEnvironments } = latest.current.prefs;
|
|
372
|
+
update({
|
|
373
|
+
collapsedEnvironments: collapsedEnvironments.includes(environmentId)
|
|
374
|
+
? collapsedEnvironments.filter((id) => id !== environmentId)
|
|
375
|
+
: [...collapsedEnvironments, environmentId],
|
|
376
|
+
});
|
|
377
|
+
},
|
|
378
|
+
onMenuAction: (action, thread, sectionId) => runMenuActionRef.current(action, thread, sectionId),
|
|
379
|
+
onRename: (threadId, title) => actions.rename(threadId, title),
|
|
380
|
+
loadModel,
|
|
381
|
+
openDetails: setDetailsId,
|
|
382
|
+
onNewThreadInEnvironment: (environmentId, projectId, sectionId) => {
|
|
383
|
+
actions.openNewThread({ projectId, environmentId, ...(sectionId ? { sectionId } : {}), focusPrompt: true });
|
|
384
|
+
onNavigate();
|
|
385
|
+
},
|
|
386
|
+
onRenameEnvironment: async (environmentId, name) => {
|
|
387
|
+
await sdk.environments.update({ environmentId, name });
|
|
388
|
+
},
|
|
389
|
+
onArchiveEnvironment: (environmentId) => {
|
|
390
|
+
sdk.environments.archiveThreads({ environmentId }).catch((error: unknown) =>
|
|
391
|
+
toast.error("Couldn't archive the environment", { description: describeError(error) }),
|
|
392
|
+
);
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}, [
|
|
396
|
+
built,
|
|
397
|
+
isCompactViewport,
|
|
398
|
+
client.density,
|
|
399
|
+
prefs.showPullRequests,
|
|
400
|
+
prefs.harnessIcon,
|
|
401
|
+
prefs.organizationMode,
|
|
402
|
+
prefs.nesting,
|
|
403
|
+
defaultBranches,
|
|
404
|
+
multiHost,
|
|
405
|
+
providerDisplay,
|
|
406
|
+
sidebar.sections,
|
|
407
|
+
onNavigate,
|
|
408
|
+
applyToggle,
|
|
409
|
+
update,
|
|
410
|
+
actions,
|
|
411
|
+
sdk,
|
|
412
|
+
loadModel,
|
|
413
|
+
]);
|
|
414
|
+
|
|
415
|
+
const live: ListLive = useMemo(
|
|
416
|
+
() => ({ now, stamps, notes, familyOf: (threadId) => forest?.familyOf.get(threadId) }),
|
|
417
|
+
[now, stamps, notes, forest],
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
const activeGroupId = useMemo(() => {
|
|
421
|
+
const family = activeThreadId === null || forest === null ? undefined : forest.familyOf.get(activeThreadId);
|
|
422
|
+
return family === undefined
|
|
423
|
+
? null
|
|
424
|
+
: groupIdForRoot(family.root.thread, { mode: prefs.organizationMode, projects: sidebar.projects });
|
|
425
|
+
}, [activeThreadId, forest, prefs.organizationMode, sidebar.projects]);
|
|
426
|
+
const showArchived = prefs.threadLifecycles.includes("archived");
|
|
427
|
+
|
|
428
|
+
const groupController: GroupController | null = useMemo(() => {
|
|
429
|
+
if (!built) return null;
|
|
430
|
+
const markAll = (group: GroupView) => {
|
|
431
|
+
const { forest, activeThreadId, stamps } = latest.current;
|
|
432
|
+
// Folded roots count too: every family bucketed in the group.
|
|
433
|
+
const families = group.rootIds.flatMap((id) => forest!.familyOf.get(id) ?? []);
|
|
434
|
+
const plan = markAllReadPlan(families, {
|
|
435
|
+
activeThreadId,
|
|
436
|
+
finishedAt: stamps.finishedAt,
|
|
437
|
+
seenAt: stamps.seenAt,
|
|
438
|
+
});
|
|
439
|
+
const run = () => {
|
|
440
|
+
if (plan.seen.length > 0) markSeen(plan.seen);
|
|
441
|
+
for (const id of plan.read) actions.setRead(id, true).catch(() => undefined);
|
|
442
|
+
};
|
|
443
|
+
if (plan.read.length === 0) {
|
|
444
|
+
toast(`Nothing unread in ${group.descriptor.label}`);
|
|
445
|
+
} else if (plan.read.length > MARK_ALL_CONFIRM_ABOVE) {
|
|
446
|
+
setConfirm({
|
|
447
|
+
title: `Mark ${plan.read.length} threads read?`,
|
|
448
|
+
description: `Every unread thread in ${group.descriptor.label}, child threads included, will be marked read.`,
|
|
449
|
+
confirmLabel: "Mark all read",
|
|
450
|
+
run,
|
|
451
|
+
});
|
|
452
|
+
} else {
|
|
453
|
+
run();
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
return {
|
|
457
|
+
compact: isCompactViewport,
|
|
458
|
+
activeGroupId,
|
|
459
|
+
showArchived,
|
|
460
|
+
onToggleArchived: () =>
|
|
461
|
+
update({
|
|
462
|
+
threadLifecycles: latest.current.prefs.threadLifecycles.includes("archived") ? ["active"] : ["active", "archived"],
|
|
463
|
+
}),
|
|
464
|
+
canCreateSections: prefs.organizationMode === "chronological",
|
|
465
|
+
onToggleCollapse: (group) => {
|
|
466
|
+
const { prefs, forest } = latest.current;
|
|
467
|
+
applyToggle(toggleGroup(group, prefs, forest!));
|
|
468
|
+
},
|
|
469
|
+
onNewThread: (group) => {
|
|
470
|
+
const descriptor = group.descriptor;
|
|
471
|
+
actions.openNewThread({
|
|
472
|
+
...(descriptor.newThreadProjectId ? { projectId: descriptor.newThreadProjectId } : {}),
|
|
473
|
+
...(descriptor.newThreadSectionId ? { sectionId: descriptor.newThreadSectionId } : {}),
|
|
474
|
+
focusPrompt: true,
|
|
475
|
+
});
|
|
476
|
+
onNavigate();
|
|
477
|
+
},
|
|
478
|
+
onHide: (group) => update({ hiddenGroups: [...latest.current.prefs.hiddenGroups, group.descriptor.id] }),
|
|
479
|
+
onShow: (group) =>
|
|
480
|
+
update({ hiddenGroups: latest.current.prefs.hiddenGroups.filter((id) => id !== group.descriptor.id) }),
|
|
481
|
+
onCustomize: () => setCustomizeOpen(true),
|
|
482
|
+
onRename: async (group, name) => {
|
|
483
|
+
if (group.descriptor.kind === "section") {
|
|
484
|
+
await sdk.threadSections.update({ id: group.descriptor.entityId!, name });
|
|
485
|
+
} else if (group.descriptor.kind === "machine") {
|
|
486
|
+
await sdk.hosts.update({ hostId: group.descriptor.entityId!, name });
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
onRemove: (group) =>
|
|
490
|
+
setConfirm({
|
|
491
|
+
title: "Remove section?",
|
|
492
|
+
description: "Threads in this section will move back to Threads.",
|
|
493
|
+
confirmLabel: "Remove",
|
|
494
|
+
destructive: true,
|
|
495
|
+
run: () => {
|
|
496
|
+
sdk.threadSections
|
|
497
|
+
.delete({ id: group.descriptor.entityId! })
|
|
498
|
+
.catch((error: unknown) => toast.error("Couldn't remove the section", { description: describeError(error) }));
|
|
499
|
+
},
|
|
500
|
+
}),
|
|
501
|
+
onMarkAllRead: markAll,
|
|
502
|
+
onNewSection: () => setNewSectionOpen(true),
|
|
503
|
+
};
|
|
504
|
+
}, [built, isCompactViewport, activeGroupId, showArchived, prefs.organizationMode, applyToggle, actions, onNavigate, update, sdk, markSeen]);
|
|
505
|
+
|
|
506
|
+
const sensors = useSensors(
|
|
507
|
+
useSensor(MouseSensor, MOUSE_SENSOR),
|
|
508
|
+
useSensor(TouchSensor, TOUCH_SENSOR),
|
|
509
|
+
useSensor(KeyboardSensor),
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
const dropTargetOf = useCallback((event: DragMoveEvent | DragEndEvent): DropTarget | null => {
|
|
513
|
+
const over = event.over;
|
|
514
|
+
if (over === null) return null;
|
|
515
|
+
const data = over.data.current as { kind: string; threadId?: string; groupId: string; inPinned?: boolean } | undefined;
|
|
516
|
+
if (data === undefined) return null;
|
|
517
|
+
if (data.kind === "group") return { kind: "group", groupId: data.groupId };
|
|
518
|
+
const y = pointerY(event);
|
|
519
|
+
const rect = over.rect;
|
|
520
|
+
const ratio = y === null ? 0.5 : (y - rect.top) / Math.max(rect.height, 1);
|
|
521
|
+
const zone = ratio < 0.25 ? "before" : ratio > 0.75 ? "after" : "middle";
|
|
522
|
+
return { kind: "thread", threadId: data.threadId!, zone, inPinned: data.inPinned ?? false };
|
|
523
|
+
}, []);
|
|
524
|
+
|
|
525
|
+
const dropContext = useMemo(() => {
|
|
526
|
+
const groupOfThread = new Map<string, string>();
|
|
527
|
+
for (const group of [...(view?.groups ?? []), ...(view?.more ?? [])]) {
|
|
528
|
+
for (const row of group.rows) if (row.type === "thread") groupOfThread.set(row.info.thread.id, group.descriptor.id);
|
|
529
|
+
}
|
|
530
|
+
const pinned = view?.groups.find((group) => group.descriptor.id === "pinned");
|
|
531
|
+
return {
|
|
532
|
+
mode: prefs.organizationMode,
|
|
533
|
+
parentOf: (id: string) => byId.get(id)?.parentThreadId ?? null,
|
|
534
|
+
pinnedOrder: (pinned?.rows ?? []).flatMap((row) => (row.type === "thread" && row.depth === 0 ? [row.info.thread.id] : [])),
|
|
535
|
+
groupOfThread: (id: string) => groupOfThread.get(id) ?? "threads",
|
|
536
|
+
};
|
|
537
|
+
}, [view, prefs.organizationMode, byId]);
|
|
538
|
+
|
|
539
|
+
const onDragMove = useCallback(
|
|
540
|
+
(event: DragMoveEvent) => {
|
|
541
|
+
const active = event.active.data.current as { kind: string; thread?: DraggedThread } | undefined;
|
|
542
|
+
const target = dropTargetOf(event);
|
|
543
|
+
if (active?.kind !== "thread" || target === null) {
|
|
544
|
+
setDropStates(new Map());
|
|
545
|
+
setDropGroupId(active?.kind === "group" && target?.kind === "group" ? target.groupId : null);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (target.kind === "group") {
|
|
549
|
+
setDropStates(new Map());
|
|
550
|
+
setDropGroupId(target.groupId);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
setDropGroupId(null);
|
|
554
|
+
const outcome = resolveDrop(active.thread!, target, dropContext);
|
|
555
|
+
const state =
|
|
556
|
+
outcome.type === "blocked"
|
|
557
|
+
? "blocked"
|
|
558
|
+
: outcome.type === "unchanged"
|
|
559
|
+
? "unchanged"
|
|
560
|
+
: target.zone === "middle"
|
|
561
|
+
? "valid"
|
|
562
|
+
: target.zone;
|
|
563
|
+
setDropStates(new Map([[target.threadId, state]]));
|
|
564
|
+
},
|
|
565
|
+
[dropTargetOf, dropContext],
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
const runDrop = useCallback(
|
|
569
|
+
async (action: DropAction) => {
|
|
570
|
+
switch (action.type) {
|
|
571
|
+
case "nest":
|
|
572
|
+
if (action.unpinFirst) await sdk.threads.unpin({ threadId: action.threadId });
|
|
573
|
+
await sdk.threads.update({ threadId: action.threadId, parentThreadId: action.parentThreadId });
|
|
574
|
+
return;
|
|
575
|
+
case "move":
|
|
576
|
+
await sdk.threads.update({
|
|
577
|
+
threadId: action.threadId,
|
|
578
|
+
sectionId: action.sectionId,
|
|
579
|
+
...(action.detach ? { parentThreadId: null } : {}),
|
|
580
|
+
});
|
|
581
|
+
return;
|
|
582
|
+
case "detach":
|
|
583
|
+
await sdk.threads.update({ threadId: action.threadId, parentThreadId: null });
|
|
584
|
+
return;
|
|
585
|
+
case "pin":
|
|
586
|
+
await actions.setPinned(action.threadId, true);
|
|
587
|
+
return;
|
|
588
|
+
case "unpin":
|
|
589
|
+
await actions.setPinned(action.threadId, false);
|
|
590
|
+
return;
|
|
591
|
+
case "reorder-pinned":
|
|
592
|
+
await sdk.threads.reorderPinned({
|
|
593
|
+
threadId: action.threadId,
|
|
594
|
+
previousThreadId: action.previousThreadId,
|
|
595
|
+
nextThreadId: action.nextThreadId,
|
|
596
|
+
});
|
|
597
|
+
return;
|
|
598
|
+
case "blocked":
|
|
599
|
+
case "unchanged":
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
[sdk, actions],
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
const onDragEnd = useCallback(
|
|
607
|
+
(event: DragEndEvent) => {
|
|
608
|
+
setDropStates(new Map());
|
|
609
|
+
setDropGroupId(null);
|
|
610
|
+
const active = event.active.data.current as { kind: string; thread?: DraggedThread; groupId?: string } | undefined;
|
|
611
|
+
const target = dropTargetOf(event);
|
|
612
|
+
if (active === undefined || target === null || view === null) return;
|
|
613
|
+
if (active.kind === "group") {
|
|
614
|
+
if (target.kind !== "group" || target.groupId === active.groupId) return;
|
|
615
|
+
const rect = event.over!.rect;
|
|
616
|
+
const y = pointerY(event);
|
|
617
|
+
const placement = y !== null && y > rect.top + rect.height / 2 ? "after" : "before";
|
|
618
|
+
const key = ORDER_PREFERENCE[prefs.organizationMode];
|
|
619
|
+
update({ [key]: moveGroup(view.order, active.groupId!, target.groupId, placement) });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const action = resolveDrop(active.thread!, target, dropContext);
|
|
623
|
+
runDrop(action).catch((error: unknown) => toast.error("Failed to move thread.", { description: describeError(error) }));
|
|
624
|
+
},
|
|
625
|
+
[dropTargetOf, view, prefs.organizationMode, update, dropContext, runDrop],
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
const onDragCancel = useCallback(() => setDropStates(new Map()), []);
|
|
629
|
+
|
|
630
|
+
const customizeItems: CustomizeItem[] = useMemo(() => {
|
|
631
|
+
if (view === null) return [];
|
|
632
|
+
const all = new Map([...view.groups, ...view.more].map((group) => [group.descriptor.id, group]));
|
|
633
|
+
return view.order.flatMap((id) => {
|
|
634
|
+
const group = all.get(id);
|
|
635
|
+
if (group === undefined) return [];
|
|
636
|
+
return [{ id, label: group.descriptor.label, hidden: group.hidden, hideable: id !== "pinned" }];
|
|
637
|
+
});
|
|
638
|
+
}, [view]);
|
|
639
|
+
|
|
640
|
+
// States.
|
|
641
|
+
if (sidebar.status === "error") {
|
|
642
|
+
return (
|
|
643
|
+
<div role="alert" className="flex flex-col items-start gap-2 px-3 py-2 text-sm">
|
|
644
|
+
<p className="text-muted-foreground">Threads couldn't load.</p>
|
|
645
|
+
{attempt === 0 ? (
|
|
646
|
+
<Button size="sm" variant="outline" onClick={onRetry}>
|
|
647
|
+
Retry
|
|
648
|
+
</Button>
|
|
649
|
+
) : (
|
|
650
|
+
<Button
|
|
651
|
+
size="sm"
|
|
652
|
+
variant="outline"
|
|
653
|
+
onClick={() => {
|
|
654
|
+
sdk.plugins.reload({ pluginId: PLUGIN_ID }).catch(() => window.location.reload());
|
|
655
|
+
}}
|
|
656
|
+
>
|
|
657
|
+
Reload plugin
|
|
658
|
+
</Button>
|
|
659
|
+
)}
|
|
660
|
+
</div>
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
if (sidebar.status === "loading" || !hydrated || view === null || rowController === null || groupController === null) {
|
|
664
|
+
return (
|
|
665
|
+
<div role="status" aria-label="Loading threads" className="flex flex-col gap-1 px-2 py-1">
|
|
666
|
+
{[0, 1, 2].map((index) => (
|
|
667
|
+
<div key={index} data-sidebar="navigation-loading-row" className="h-7 animate-pulse rounded-md bg-muted/60" />
|
|
668
|
+
))}
|
|
669
|
+
</div>
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const archived = sidebar.experimental_archived;
|
|
674
|
+
const details = detailsId === null ? null : forest?.infos.get(detailsId) ?? null;
|
|
675
|
+
const moveThread = moveId === null ? null : byId.get(moveId) ?? null;
|
|
676
|
+
|
|
677
|
+
return (
|
|
678
|
+
<ListLiveContext.Provider value={live}>
|
|
679
|
+
<div className="flex w-full min-w-0 flex-col px-1.5 pb-2">
|
|
680
|
+
<Toolbar prefs={prefs} client={client} attentionCount={view.attentionCount} compact={isCompactViewport} onPrefs={update} onClient={updateClient} />
|
|
681
|
+
{threads.length === 0 ? (
|
|
682
|
+
<div className="flex flex-col items-start gap-2 px-3 py-4 text-sm text-muted-foreground">
|
|
683
|
+
<p>No threads yet.</p>
|
|
684
|
+
<Button size="sm" variant="outline" onClick={() => actions.openNewThread({ focusPrompt: true })}>
|
|
685
|
+
<Icon name="Plus" aria-hidden />
|
|
686
|
+
New thread
|
|
687
|
+
</Button>
|
|
688
|
+
</div>
|
|
689
|
+
) : (
|
|
690
|
+
<DndContext sensors={sensors} collisionDetection={collision} onDragMove={onDragMove} onDragEnd={onDragEnd} onDragCancel={onDragCancel}>
|
|
691
|
+
{view.groups.length === 0 && client.filter === "attention" ? (
|
|
692
|
+
<p className="px-3 py-3 text-sm text-muted-foreground">Nothing needs your attention.</p>
|
|
693
|
+
) : null}
|
|
694
|
+
{view.groups.map((group) => (
|
|
695
|
+
<GroupSection
|
|
696
|
+
key={group.descriptor.id}
|
|
697
|
+
group={group}
|
|
698
|
+
rowController={rowController}
|
|
699
|
+
groupController={groupController}
|
|
700
|
+
environmentProviders={environmentProviders}
|
|
701
|
+
dropStates={dropStates}
|
|
702
|
+
dropTargetGroupId={dropGroupId}
|
|
703
|
+
activeThreadId={activeThreadId}
|
|
704
|
+
editingId={editingId}
|
|
705
|
+
now={now}
|
|
706
|
+
stamps={stamps}
|
|
707
|
+
/>
|
|
708
|
+
))}
|
|
709
|
+
{view.more.length > 0 ? (
|
|
710
|
+
<Popover>
|
|
711
|
+
<PopoverTrigger asChild>
|
|
712
|
+
<button
|
|
713
|
+
type="button"
|
|
714
|
+
data-testid="sidebar-thread-list-more-trigger"
|
|
715
|
+
aria-label={`More: ${view.more.length} hidden ${view.more.length === 1 ? "group" : "groups"}`}
|
|
716
|
+
className="mt-1 flex h-7 w-full items-center gap-1 rounded-md pl-2 pr-1 text-left text-xs text-muted-foreground outline-none hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
|
717
|
+
>
|
|
718
|
+
<span className="flex-1 font-medium">More</span>
|
|
719
|
+
<CounterStrip counters={view.moreCounters} />
|
|
720
|
+
<Icon name="ChevronRight" aria-hidden className="size-3" />
|
|
721
|
+
</button>
|
|
722
|
+
</PopoverTrigger>
|
|
723
|
+
<PopoverContent side="right" align="end" className="max-h-[70vh] w-72 overflow-y-auto p-1">
|
|
724
|
+
<div data-sidebar-overflow="true" className="flex flex-col">
|
|
725
|
+
{view.more.map((group) => (
|
|
726
|
+
<GroupSection
|
|
727
|
+
key={group.descriptor.id}
|
|
728
|
+
group={group}
|
|
729
|
+
rowController={rowController}
|
|
730
|
+
groupController={groupController}
|
|
731
|
+
environmentProviders={environmentProviders}
|
|
732
|
+
dropStates={dropStates}
|
|
733
|
+
dropTargetGroupId={dropGroupId}
|
|
734
|
+
activeThreadId={activeThreadId}
|
|
735
|
+
editingId={editingId}
|
|
736
|
+
now={now}
|
|
737
|
+
stamps={stamps}
|
|
738
|
+
inOverflow
|
|
739
|
+
/>
|
|
740
|
+
))}
|
|
741
|
+
</div>
|
|
742
|
+
</PopoverContent>
|
|
743
|
+
</Popover>
|
|
744
|
+
) : null}
|
|
745
|
+
</DndContext>
|
|
746
|
+
)}
|
|
747
|
+
{archived !== null ? (
|
|
748
|
+
<div className="flex flex-col items-start gap-1 px-2 pt-2">
|
|
749
|
+
{archived.status === "loading" ? (
|
|
750
|
+
<p role="status" className="text-xs text-muted-foreground">
|
|
751
|
+
Loading archived threads…
|
|
752
|
+
</p>
|
|
753
|
+
) : archived.status === "error" ? (
|
|
754
|
+
<p role="status" className="text-xs text-muted-foreground">
|
|
755
|
+
Archived threads unavailable
|
|
756
|
+
</p>
|
|
757
|
+
) : null}
|
|
758
|
+
{archived.hasNextPage || archived.isFetchNextPageError ? (
|
|
759
|
+
<Button
|
|
760
|
+
size="sm"
|
|
761
|
+
variant="ghost"
|
|
762
|
+
aria-label="Load more archived threads"
|
|
763
|
+
disabled={archived.isFetchingNextPage}
|
|
764
|
+
className={cn("h-7 text-xs text-muted-foreground")}
|
|
765
|
+
onClick={() => void archived.fetchNextPage()}
|
|
766
|
+
>
|
|
767
|
+
{archived.isFetchingNextPage ? "Loading…" : archived.isFetchNextPageError ? "Retry loading" : "Show more"}
|
|
768
|
+
</Button>
|
|
769
|
+
) : null}
|
|
770
|
+
</div>
|
|
771
|
+
) : null}
|
|
772
|
+
<CustomizeDialog
|
|
773
|
+
open={customizeOpen}
|
|
774
|
+
items={customizeItems}
|
|
775
|
+
onOpenChange={setCustomizeOpen}
|
|
776
|
+
onToggleHidden={(id) =>
|
|
777
|
+
update({
|
|
778
|
+
hiddenGroups: prefs.hiddenGroups.includes(id)
|
|
779
|
+
? prefs.hiddenGroups.filter((candidate) => candidate !== id)
|
|
780
|
+
: [...prefs.hiddenGroups, id],
|
|
781
|
+
})
|
|
782
|
+
}
|
|
783
|
+
onMove={(id, direction) => {
|
|
784
|
+
const index = view.order.indexOf(id);
|
|
785
|
+
const neighbour = view.order[index + direction];
|
|
786
|
+
if (neighbour === undefined) return;
|
|
787
|
+
update({
|
|
788
|
+
[ORDER_PREFERENCE[prefs.organizationMode]]: moveGroup(view.order, id, neighbour, direction < 0 ? "before" : "after"),
|
|
789
|
+
});
|
|
790
|
+
}}
|
|
791
|
+
/>
|
|
792
|
+
<NewSectionDialog
|
|
793
|
+
open={newSectionOpen}
|
|
794
|
+
onOpenChange={setNewSectionOpen}
|
|
795
|
+
onCreate={async (name) => {
|
|
796
|
+
try {
|
|
797
|
+
await sdk.threadSections.create({ name });
|
|
798
|
+
} catch (error) {
|
|
799
|
+
toast.error("Couldn't create the section", { description: describeError(error) });
|
|
800
|
+
throw error;
|
|
801
|
+
}
|
|
802
|
+
}}
|
|
803
|
+
/>
|
|
804
|
+
<ConfirmDialog
|
|
805
|
+
open={confirm !== null}
|
|
806
|
+
title={confirm?.title ?? ""}
|
|
807
|
+
description={confirm?.description ?? ""}
|
|
808
|
+
confirmLabel={confirm?.confirmLabel ?? "OK"}
|
|
809
|
+
destructive={confirm?.destructive}
|
|
810
|
+
onOpenChange={(open) => !open && setConfirm(null)}
|
|
811
|
+
onConfirm={() => {
|
|
812
|
+
confirm?.run();
|
|
813
|
+
setConfirm(null);
|
|
814
|
+
}}
|
|
815
|
+
/>
|
|
816
|
+
{details !== null && details !== undefined ? (
|
|
817
|
+
<DetailsDialog open title={details.thread.displayTitle} onOpenChange={(open) => !open && setDetailsId(null)}>
|
|
818
|
+
<ThreadDetails
|
|
819
|
+
info={details}
|
|
820
|
+
controller={rowController}
|
|
821
|
+
showPullRequest={prefs.showPullRequests}
|
|
822
|
+
actions={{
|
|
823
|
+
open: () => {
|
|
824
|
+
setDetailsId(null);
|
|
825
|
+
actions.open(details.thread.id);
|
|
826
|
+
onNavigate();
|
|
827
|
+
},
|
|
828
|
+
toggleRead: () => runMenuAction(details.unread ? "mark-read" : "mark-unread", details.thread),
|
|
829
|
+
}}
|
|
830
|
+
/>
|
|
831
|
+
</DetailsDialog>
|
|
832
|
+
) : null}
|
|
833
|
+
{moveThread !== null && forest !== null ? (
|
|
834
|
+
<MoveDialog
|
|
835
|
+
open
|
|
836
|
+
title={moveThread.displayTitle}
|
|
837
|
+
targets={moveTargets(moveThread.id, forest, moveQuery)}
|
|
838
|
+
query={moveQuery}
|
|
839
|
+
onQueryChange={setMoveQuery}
|
|
840
|
+
onOpenChange={(open) => !open && setMoveId(null)}
|
|
841
|
+
onMove={(parentThreadId) => {
|
|
842
|
+
const move = async () => {
|
|
843
|
+
if (parentThreadId !== null && (moveThread.pinnedAt !== null || moveThread.isPinned)) {
|
|
844
|
+
await sdk.threads.unpin({ threadId: moveThread.id });
|
|
845
|
+
}
|
|
846
|
+
await sdk.threads.update({ threadId: moveThread.id, parentThreadId });
|
|
847
|
+
};
|
|
848
|
+
move().catch((error: unknown) => toast.error("Failed to move thread.", { description: describeError(error) }));
|
|
849
|
+
}}
|
|
850
|
+
/>
|
|
851
|
+
) : null}
|
|
852
|
+
</div>
|
|
853
|
+
</ListLiveContext.Provider>
|
|
854
|
+
);
|
|
855
|
+
}
|