Skip to content
Source
Sign inSign up

Sidebar.logic.ts

1,477 lines49.1 KB

1import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection";
2import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
3import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models";
4import { isShownUnderParent } from "@t3tools/client-runtime/state/threads";
5import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
6import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
7import * as React from "react";
8import {
9 isAtomCommandInterrupted,
10 type AtomCommandResult,
11} from "@t3tools/client-runtime/state/runtime";
12import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";
13import {
14 threadSearchMatchKey,
15 threadSearchSubject,
16 type ThreadSearchShell,
17} from "@t3tools/client-runtime/state/thread-search";
18import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch";
19import {
20 isListedThreadLineage,
21 type ContextMenuItem,
22 type EnvironmentId,
23 type ResolvedKeybindingsConfig,
24 type ScopedThreadRef,
25 type ThreadId,
26} from "@t3tools/contracts";
27import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
28import type { AsyncResult } from "effect/reactivity";
29import {
30 effectiveSnoozed,
31 type ThreadSnoozeShell,
32} from "@t3tools/client-runtime/state/thread-settled";
33import {
34 getThreadSortTimestamp,
35 planPinnedReorder,
36 sortThreads,
37 toSortableTimestamp,
38 type ThreadSortInput,
39} from "@t3tools/client-runtime/state/thread-sort";
40import type { SidebarThreadSummary, Thread } from "../types";
41import { normalizeSearchText } from "../lib/utils";
42import { isLatestRunSettled } from "../session-logic";
43import { THREAD_STATUS_TEXT } from "@t3tools/client-runtime/state/thread-activity-announcement";
44import { observeIntersection } from "../lib/observeIntersection";
45import { deadlineAfter, deadlineStore, type ClockStore } from "@t3tools/client-runtime/clock";
46import { useDeadlinePassed } from "../hooks/useDeadline";
47import { legacyProjectCwdPreferenceKey } from "../uiStateStore";
48import { isElectron } from "../env";
49import { shouldShowThreadJumpHintsForModifiers } from "../keybindings";
50import { isTerminalFocused, subscribeToTerminalFocusChanges } from "../lib/terminalFocus";
51import { isPreviewFocused } from "../lib/previewFocus";
52import { isModelPickerOpen, subscribeToModelPickerVisibility } from "../modelPickerVisibility";
53import { shortcutModifierStore } from "../shortcutModifierState";
54import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
55import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
56​
57export function shouldNavigateAfterThreadPark(input: {
58 readonly threadKey: string;
59 readonly currentThreadKey: string | null;
60 readonly action: "settle" | "snooze";
61 readonly now: string;
62 readonly thread: (ThreadSnoozeShell & Pick<SidebarThreadSummary, "settledOverride">) | null;
63}): boolean {
64 return (
65 input.threadKey === input.currentThreadKey &&
66 input.thread !== null &&
67 (input.action === "settle"
68 ? input.thread.settledOverride === "settled"
69 : effectiveSnoozed(input.thread, { now: input.now }))
70 );
72​
73export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200;
74​
75export function resolveSidebarRowAccessibility(input: {
76 readonly title: string;
77 readonly statusLabel: string | null;
78 readonly projectDisplayName: string | null;
79 readonly isActive: boolean;
80}): { readonly label: string; readonly current: "page" | undefined } {
81 return {
82 label: [input.title, input.statusLabel, input.projectDisplayName].filter(Boolean).join(", "),
83 current: input.isActive ? "page" : undefined,
84 };
86​
87// Each prewarm retains growing thread detail and a live subscription, multiplying heap and server load.
88const SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX = 160;
89​
90export function useSidebarRowSubscriptionLease(isActive: boolean): {
91 readonly leaseLiveStatus: boolean;
92 readonly rowRef: (row: HTMLElement | null) => (() => void) | undefined;
93} {
94 const [isNearViewport, setIsNearViewport] = React.useState(isActive);
95 if (isActive && !isNearViewport) setIsNearViewport(true);
96 const canObserve = typeof IntersectionObserver !== "undefined";
97​
98 return {
99 leaseLiveStatus: isActive || isNearViewport || !canObserve,
100 rowRef: (row) => {
101 if (row === null || isActive || !canObserve) return undefined;
102 // The overscan has to apply to the sidebar's own scroller; a viewport-rooted
103 // margin is still clipped by it.
104 return observeIntersection(row, (entry) => setIsNearViewport(entry.isIntersecting), {
105 root: row.closest('[data-slot="scroll-area-viewport"]'),
106 rootMargin: `${SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX}px 0px`,
107 });
108 },
109 };
111​
112const fileDragClears = new Set<(active: false) => void>();
113function clearFileDrags() {
114 const clears = [...fileDragClears];
115 fileDragClears.clear();
116 for (const clear of clears) clear(false);
118​
119/**
120 * A file drag that ends outside every drop target (Escape, or a drop elsewhere)
121 * sends no dragleave to the row it last entered, so the window's dragend clears it.
122 */
123export function setFileDragActive(setActive: (active: boolean) => void, active: boolean) {
124 setActive(active);
125 if (typeof window === "undefined") return;
126 if (!active) {
127 if (fileDragClears.delete(setActive) && fileDragClears.size === 0) {
128 window.removeEventListener("dragend", clearFileDrags);
129 }
130 return;
131 }
132 if (fileDragClears.size === 0) window.addEventListener("dragend", clearFileDrags, { once: true });
133 fileDragClears.add(setActive);
135​
136/**
137 * A clock that advances exactly when a snooze wake time passes, so a woken
138 * thread leaves the shelf on time without a per-second clock.
139 */
140export function useSnoozeWakeClock(
141 threads: ReadonlyArray<{ readonly snoozedUntil?: string | null | undefined }>,
142): number {
143 const [clockMs, setClockMs] = React.useState(() => Date.now());
144 const wakeTimes: number[] = [];
145 let nextWakeAt: number | null = null;
146 for (const thread of threads) {
147 const wakeAt = thread.snoozedUntil == null ? Number.NaN : Date.parse(thread.snoozedUntil);
148 if (!(wakeAt > clockMs)) continue;
149 wakeTimes.push(wakeAt);
150 if (nextWakeAt === null || wakeAt < nextWakeAt) nextWakeAt = wakeAt;
151 }
152 const woke = useDeadlinePassed(nextWakeAt);
153 // Advancing to the latest wake that has passed skips every wake that elapsed
154 // meanwhile, e.g. across a laptop sleep. Stepping one wake at a time would
155 // re-render once per elapsed wake and hit React's limit.
156 if (woke && nextWakeAt !== null) {
157 let nextClockMs = nextWakeAt;
158 for (const wakeAt of wakeTimes) {
159 if (wakeAt > nextClockMs && deadlineStore(wakeAt).getSnapshot()) nextClockMs = wakeAt;
160 }
161 setClockMs(nextClockMs);
162 return nextClockMs;
163 }
164 return clockMs;
166​
167// Live status that says the cwd is not a repository means the checkout was removed.
168export function withoutRemovedCheckout<
169 T extends { readonly branch: string | null; readonly worktreePath: string | null },
170>(thread: T, gitStatus: { readonly isRepo?: boolean | undefined } | null): T {
171 return gitStatus?.isRepo === false ? { ...thread, branch: null, worktreePath: null } : thread;
173​
174export function useRetainedValue<T>(key: string | null, value: T | null): T | null {
175 const [retained, setRetained] = React.useState<{
176 readonly key: string;
177 readonly value: T;
178 } | null>(null);
179 if (key !== null && value !== null && (retained?.key !== key || retained.value !== value)) {
180 setRetained({ key, value });
181 }
182 if (value !== null) return value;
183 return key !== null && retained?.key === key ? retained.value : null;
185​
186// Sortable owns drag transforms; replaying committed order would animate drops twice.
187export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) =>
188 args.isSorting ? defaultAnimateLayoutChanges(args) : false;
189​
190// Snoozed rows cannot accept drops without a wake time. Working rows only drag out as chat context.
191​
192export type SidebarSection = "pinned" | "active" | "working" | "snoozed" | "settled";
193​
194export function resolveSidebarThreadSection(input: {
195 readonly snoozed: boolean;
196 readonly settled: boolean;
197 readonly pinned: boolean;
198}): SidebarSection {
199 if (input.snoozed) return "snoozed";
200 if (input.settled) return "settled";
201 if (input.pinned) return "pinned";
202 return "active";
204​
205const SIDEBAR_MARKER_PREFIX = "sidebar-marker-";
206​
207export type SidebarListMarker =
208 | "pinned-header"
209 | "active-placeholder"
210 | "settled-placeholder"
211 | "pinned-divider"
212 | "working-header"
213 | "snoozed-header"
214 | "settled-header";
215​
216export function sidebarMarkerId(marker: SidebarListMarker): string {
217 return `${SIDEBAR_MARKER_PREFIX}${marker}`;
219​
220export type SidebarListItem =
221 | { readonly kind: "thread"; readonly key: string; readonly section: SidebarSection }
222 | { readonly kind: "marker"; readonly marker: SidebarListMarker };
223​
224export function sidebarListItemId(item: SidebarListItem): string {
225 return item.kind === "thread" ? item.key : sidebarMarkerId(item.marker);
227​
228function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): SidebarSection {
229 let section: SidebarSection = "pinned";
230 for (let i = 0; i < index && i < items.length; i += 1) {
231 const item = items[i]!;
232 if (item.kind !== "marker") continue;
233 if (item.marker === "pinned-divider") section = "active";
234 else if (item.marker === "working-header") section = "working";
235 else if (item.marker === "snoozed-header") section = "snoozed";
236 else if (item.marker === "settled-header") section = "settled";
237 }
238 return section;
240​
241type SidebarDropTarget = {
242 readonly section: "pinned" | "active" | "settled";
243 readonly pinnedOrder: readonly string[];
244 readonly activeOrder: readonly string[];
245};
246​
247export function resolveSidebarDropTarget(
248 items: readonly SidebarListItem[],
249 activeKey: string,
250 overId: string,
251): SidebarDropTarget | null {
252 const activeIndex = items.findIndex((item) => sidebarListItemId(item) === activeKey);
253 const overIndex = items.findIndex((item) => sidebarListItemId(item) === overId);
254 if (activeIndex === -1 || overIndex === -1 || items[activeIndex]?.kind !== "thread") return null;
255 const moved = items.filter((_, index) => index !== activeIndex);
256 moved.splice(overIndex, 0, items[activeIndex]!);
257 const section = sectionAtSidebarSlot(moved, overIndex);
258 if (section === "working" || section === "snoozed") return null;
259 const pinnedOrder: string[] = [];
260 const activeOrder: string[] = [];
261 let currentSection: SidebarSection = "pinned";
262 for (const item of moved) {
263 if (item.kind === "marker") {
264 if (item.marker === "pinned-divider") currentSection = "active";
265 else if (
266 item.marker === "working-header" ||
267 item.marker === "snoozed-header" ||
268 item.marker === "settled-header"
269 )
270 break;
271 } else if (currentSection === "pinned") pinnedOrder.push(item.key);
272 else activeOrder.push(item.key);
273 }
274 return { section, pinnedOrder, activeOrder };
276​
277type SidebarThreadDropPlan =
278 | { readonly kind: "none" }
279 | {
280 readonly kind: "reorder-pinned";
281 readonly order: readonly string[];
282 readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
283 }
284 | {
285 readonly kind: "pin";
286 readonly order: readonly string[];
287 readonly orderKey: string | undefined;
288 readonly extraAssignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
289 }
290 | {
291 readonly kind: "move-active";
292 readonly order: readonly string[] | null;
293 readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
294 readonly unpin: boolean;
295 readonly unsettle: boolean;
296 readonly unsnooze: boolean;
297 }
298 | { readonly kind: "settle" };
299​
300export type SidebarDropVerb = "pin" | "unpin" | "settle" | "unsettle" | "wake";
301​
302export function resolveSidebarDropVerb(
303 from: SidebarSection,
304 to: SidebarSection | null,
305): SidebarDropVerb | null {
306 if (to === null || to === from || to === "working" || to === "snoozed") return null;
307 if (to === "pinned") return "pin";
308 if (to === "settled") return "settle";
309 if (from === "pinned") return "unpin";
310 if (from === "settled") return "unsettle";
311 return "wake";
313​
314export function resolveSidebarSweepKeys(
315 orderedKeys: readonly string[],
316 originKey: string,
317 targetKey: string,
318 canApply: (key: string) => boolean,
319): string[] {
320 const origin = orderedKeys.indexOf(originKey);
321 const target = orderedKeys.indexOf(targetKey);
322 if (origin === -1 || target === -1) return [];
323 return orderedKeys.slice(Math.min(origin, target), Math.max(origin, target) + 1).filter(canApply);
325​
326export function sidebarThreadKeyAtY(list: HTMLElement, y: number): string | null {
327 const viewport = list.closest('[data-slot="scroll-area-viewport"]')?.getBoundingClientRect();
328 const visibleY = viewport ? Math.min(Math.max(y, viewport.top), viewport.bottom - 1) : y;
329 let key: string | null = null;
330 for (const row of list.querySelectorAll<HTMLElement>("li[data-thread-item]")) {
331 if (key !== null && row.getBoundingClientRect().top > visibleY) break;
332 key = row.dataset.threadItem ?? null;
333 }
334 return key;
336​
337export function resolveSidebarReorderableThreadKeys(input: {
338 readonly threads: ReadonlyArray<Pick<SidebarThreadSummary, "environmentId" | "id">>;
339 readonly serverConfigs: ReadonlyMap<EnvironmentId, Pick<EnvironmentServerConfig, "environment">>;
340 readonly connectedEnvironmentIds: readonly EnvironmentId[];
341}) {
342 const connectedEnvironmentIds = new Set(input.connectedEnvironmentIds);
343 const draggableThreadKeys = new Set<string>();
344 const activeReorderableThreadKeys = new Set<string>();
345​
346 for (const thread of input.threads) {
347 // Config is cached for offline rendering, so its presence does not make a row writable.
348 if (
349 !connectedEnvironmentIds.has(thread.environmentId) ||
350 !input.serverConfigs.has(thread.environmentId)
351 ) {
352 continue;
353 }
354​
355 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
356 activeReorderableThreadKeys.add(threadKey);
357 draggableThreadKeys.add(threadKey);
358 }
359​
360 return { draggableThreadKeys, activeReorderableThreadKeys };
362​
363export function planSidebarThreadDrop(input: {
364 readonly activeKey: string;
365 readonly activeSection: SidebarSection;
366 readonly activePinned?: boolean;
367 readonly activeSettled?: boolean;
368 readonly supportsSettlement?: boolean;
369 readonly target: SidebarDropTarget;
370 readonly pinnedOrder: readonly string[];
371 readonly pinnedKeysById: ReadonlyMap<string, string | null | undefined>;
372 readonly reorderableKeys?: ReadonlySet<string>;
373 readonly activeOrder: readonly string[];
374 readonly activeKeysById: ReadonlyMap<string, string | null | undefined>;
375 readonly activeReorderableKeys?: ReadonlySet<string>;
376 readonly activeTimeOrdered?: boolean;
377}): SidebarThreadDropPlan {
378 const {
379 activeKey,
380 activeSection,
381 activePinned = activeSection === "pinned",
382 activeSettled = activeSection === "settled",
383 target,
384 pinnedOrder,
385 pinnedKeysById,
386 reorderableKeys,
387 activeOrder,
388 activeKeysById,
389 activeReorderableKeys,
390 } = input;
391 if (activeSection === "working") return { kind: "none" };
392 if (input.supportsSettlement === false && (target.section === "settled" || activeSettled)) {
393 return { kind: "none" };
394 }
395 const arrange = (
396 order: readonly string[],
397 keysById: ReadonlyMap<string, string | null | undefined>,
398 writable: ReadonlySet<string> | undefined,
399 ) => {
400 if (!writable) return planPinnedReorder({ orderedIds: order, keysById, movedId: activeKey });
401 if (!writable.has(activeKey)) return null;
402 const assignments = planPinnedReorder({
403 orderedIds: order.filter((key) => writable.has(key) || keysById.get(key) != null),
404 keysById,
405 movedId: activeKey,
406 });
407 if (assignments.some(({ id }) => !writable.has(id))) return null;
408​
409 // Keyless Active rows sort before keyed rows; keyless Pinned rows sort after them.
410 // Materializing any row across a frozen keyless boundary cannot persist that position.
411 const assignedIds = new Set(assignments.map(({ id }) => id));
412 let hasFrozenKeylessBefore = false;
413 let hasAssignedBefore = false;
414 for (const id of order) {
415 const isFrozenKeyless = !writable.has(id) && keysById.get(id) == null;
416 const isAssigned = assignedIds.has(id);
417 if (
418 (target.section === "active" && isFrozenKeyless && hasAssignedBefore) ||
419 (target.section === "pinned" && isAssigned && hasFrozenKeylessBefore)
420 ) {
421 return null;
422 }
423​
424 hasFrozenKeylessBefore ||= isFrozenKeyless;
425 hasAssignedBefore ||= isAssigned;
426 }
427​
428 return assignments;
429 };
430 switch (target.section) {
431 case "active": {
432 if (input.activeTimeOrdered) {
433 return activeSection === "active"
434 ? { kind: "none" }
435 : {
436 kind: "move-active",
437 order: null,
438 assignments: [],
439 unpin: activePinned,
440 unsettle: activeSettled,
441 unsnooze: activeSection === "snoozed",
442 };
443 }
444 const order = target.activeOrder;
445 if (
446 activeSection === "active" &&
447 order.length === activeOrder.length &&
448 order.every((key, index) => key === activeOrder[index])
449 ) {
450 return { kind: "none" };
451 }
452 const assignments = arrange(order, activeKeysById, activeReorderableKeys);
453 if (assignments === null) return { kind: "none" };
454 return {
455 kind: "move-active",
456 order,
457 assignments,
458 unpin: activePinned,
459 unsettle: activeSettled,
460 unsnooze: activeSection === "snoozed",
461 };
462 }
463 case "settled":
464 return activeSection === "settled" ? { kind: "none" } : { kind: "settle" };
465 case "pinned": {
466 const order = target.pinnedOrder;
467 if (
468 activeSection === "pinned" &&
469 order.length === pinnedOrder.length &&
470 order.every((key, index) => key === pinnedOrder[index])
471 ) {
472 return { kind: "none" };
473 }
474 const assignments = arrange(order, pinnedKeysById, reorderableKeys);
475 if (assignments === null) return { kind: "none" };
476 if (activeSection === "pinned") {
477 return assignments.length === 0
478 ? { kind: "none" }
479 : { kind: "reorder-pinned", order, assignments };
480 }
481 return {
482 kind: "pin",
483 order,
484 orderKey: assignments.find((assignment) => assignment.id === activeKey)?.orderKey,
485 extraAssignments: activePinned
486 ? assignments
487 : assignments.filter((assignment) => assignment.id !== activeKey),
488 };
489 }
490 }
492​
493export function applySidebarThreadDrop<
494 T extends Pick<
495 SidebarThreadSummary,
496 | "pinnedAt"
497 | "pinOrderKey"
498 | "activeOrderKey"
499 | "snoozedAt"
500 | "snoozedUntil"
501 | "settledAt"
502 | "settledOverride"
503 | "unsettledAt"
504 >,
505>(thread: T, section: "pinned" | "active" | "settled", now: string, orderKey?: string): T {
506 const wasSettled = thread.settledOverride === "settled";
507 const awake = { ...thread, snoozedAt: null, snoozedUntil: null };
508 if (section === "settled") {
509 return {
510 ...awake,
511 pinnedAt: null,
512 pinOrderKey: null,
513 activeOrderKey: null,
514 settledOverride: "settled",
515 settledAt: wasSettled ? (thread.settledAt ?? now) : now,
516 unsettledAt: null,
517 };
518 }
519 const resumed = wasSettled
520 ? { ...awake, settledOverride: "active" as const, settledAt: null, unsettledAt: now }
521 : awake;
522 return {
523 ...resumed,
524 pinnedAt: section === "pinned" ? (thread.pinnedAt ?? now) : null,
525 pinOrderKey: section === "pinned" ? (orderKey ?? thread.pinOrderKey) : null,
526 ...(section === "active" && orderKey !== undefined ? { activeOrderKey: orderKey } : {}),
527 };
529​
530type SidebarProject = {
531 id: string;
532 title: string;
533 createdAt?: string | undefined;
534 updatedAt?: string | undefined;
535};
536​
537type ScopedSidebarProject = SidebarProject & {
538 environmentId: string;
539};
540​
541type ScopedSidebarThread = ThreadSortInput & {
542 environmentId: string;
543 projectId: string;
544 archivedAt: string | null;
545};
546​
547type LogicalSidebarProject = SidebarProject & {
548 projectKey: string;
549 memberProjectRefs: readonly {
550 environmentId: string;
551 projectId: string;
552 }[];
553};
554​
555type ThreadTraversalDirection = "previous" | "next";
556​
557/** Exclude only successful deletions from shared-worktree checks. */
558export async function deleteSelectedThreadEntries<
559 TEntry extends { readonly threadKey: string },
560>(input: {
561 entries: readonly TEntry[];
562 delete: (
563 entry: TEntry,
564 deletedThreadKeys: ReadonlySet<string>,
565 ) => Promise<AtomCommandResult<unknown, unknown> | null>;
566}) {
567 const deletedThreadKeys = new Set<string>();
568 let firstFailure: AsyncResult.Failure<unknown, unknown> | null = null;
569​
570 for (const entry of input.entries) {
571 const result = await input.delete(entry, deletedThreadKeys);
572 if (result === null) continue;
573 if (result._tag === "Failure") {
574 if (isAtomCommandInterrupted(result)) break;
575 firstFailure ??= result;
576 continue;
577 }
578 deletedThreadKeys.add(entry.threadKey);
579 }
580​
581 return { deletedThreadKeys, firstFailure };
583​
584export function isSidebarHiddenThread(thread: Pick<SidebarThreadSummary, "lineage">): boolean {
585 return !isListedThreadLineage(thread.lineage);
587​
588// Subagent and side-chat threads have no sidebar row, so an open one is represented by
589// its nearest listed ancestor. A broken chain (missing parent, cycle,
590// other environment) resolves to the thread itself, which then matches no row.
591export function resolveSidebarRowThreadId(
592 threadId: ThreadId,
593 environmentId: EnvironmentId,
594 threads: readonly Pick<SidebarThreadSummary, "id" | "environmentId" | "lineage">[],
595): ThreadId {
596 const findThread = (id: ThreadId) =>
597 threads.find((thread) => thread.id === id && thread.environmentId === environmentId);
598 const visited = new Set<ThreadId>();
599 let thread = findThread(threadId);
600 while (thread !== undefined && isSidebarHiddenThread(thread)) {
601 if (thread.lineage.parentThreadId === null || visited.has(thread.id)) return threadId;
602 visited.add(thread.id);
603 thread = findThread(thread.lineage.parentThreadId);
604 }
605 return thread?.id ?? threadId;
607​
608/**
609 * `delegatedUnderParent` holds the tasks linked environments run for a thread
610 * the sidebar lists, by thread key; they show under that thread, as subagents do.
611 */
612export function filterSidebarV2VisibleThreads<
613 T extends Pick<SidebarThreadSummary, "archivedAt" | "environmentId" | "id" | "lineage"> & {
614 projectId: string;
615 },
616>(
617 threads: readonly T[],
618 scopedProjectKeys: ReadonlySet<string> | null,
619 delegatedUnderParent: ReadonlySet<string>,
620): T[] {
621 return threads.filter(
622 (thread) =>
623 thread.archivedAt === null &&
624 !isShownUnderParent(
625 { environmentId: thread.environmentId, threadId: thread.id },
626 thread.lineage,
627 delegatedUnderParent,
628 ) &&
629 (scopedProjectKeys === null ||
630 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
631 );
633​
634export function buildBulkTitleRegenerationContextMenuItem(input: {
635 supportedCount: number;
636 actionableCount: number;
637}): ContextMenuItem<"regenerate-title"> | null {
638 if (input.supportedCount === 0) return null;
639 if (input.actionableCount === 0) {
640 return {
641 id: "regenerate-title",
642 label: `Regenerating… (${input.supportedCount})`,
643 disabled: true,
644 };
645 }
646 return {
647 id: "regenerate-title",
648 label: `Regenerate titles (${input.actionableCount})`,
649 };
651​
652export function buildBulkUnpinContextMenuItem(input: {
653 pinnedCount: number;
654}): ContextMenuItem<"unpin"> | null {
655 if (input.pinnedCount === 0) return null;
656 return { id: "unpin", label: `Unpin (${input.pinnedCount})` };
658​
659export interface ThreadStatusPill {
660 label:
661 | "Working"
662 | "Connecting"
663 | "Completed"
664 | "Failed"
665 | "Pending Approval"
666 | "Awaiting Input"
667 | "Guest Request"
668 | "Waiting"
669 | "Plan Ready";
670 colorClass: string;
671 dotClass: string;
672 pulse: boolean;
674​
675type ThreadStatusInput = Pick<
676 SidebarThreadSummary,
677 | "hasActionableProposedPlan"
678 | "hasPendingApprovals"
679 | "hasPendingUserInput"
680 | "pendingSubagentRequest"
681 | "pendingGuestMessageCount"
682 | "interactionMode"
683 | "latestRun"
684 | "runtime"
685> & {
686 lastVisitedAt?: string | null | undefined;
687 pendingBackgroundTasks?: SidebarThreadSummary["pendingBackgroundTasks"] | undefined;
688 blockedOnThreadWait?: boolean | undefined;
689};
690​
691// Read at event time: subscribing Sidebar to terminal or modifier state re-renders every row.
692export function threadShortcutContext(routeThreadRef: ScopedThreadRef | null) {
693 return {
694 terminalFocus: isTerminalFocused(),
695 terminalOpen:
696 routeThreadRef !== null &&
697 selectThreadTerminalUiState(
698 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
699 routeThreadRef,
700 ).terminalOpen,
701 previewFocus: isPreviewFocused(),
702 previewOpen:
703 selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) ===
704 "preview",
705 modelPickerOpen: isModelPickerOpen(),
706 isWeb: !isElectron,
707 isDesktop: isElectron,
708 };
710​
711/** Whether the current modifier combination, including none, has been held unchanged for `delayMs`. */
712function createModifierHoldStore(delayMs: number): ClockStore<boolean> {
713 const listeners = new Set<() => void>();
714 let held = false;
715 let stopDeadline = () => {};
716 let stopModifiers: (() => void) | null = null;
717​
718 const setHeld = (next: boolean) => {
719 if (held === next) return;
720 held = next;
721 for (const listener of listeners) listener();
722 };
723 const onModifiersChange = () => {
724 stopDeadline();
725 const deadline = deadlineStore(deadlineAfter(delayMs));
726 stopDeadline = deadline.subscribe(() => setHeld(true));
727 // A deadline another subscriber already passed never notifies a late joiner.
728 setHeld(deadline.getSnapshot());
729 };
730​
731 return {
732 subscribe: (listener) => {
733 listeners.add(listener);
734 if (stopModifiers === null) {
735 stopModifiers = shortcutModifierStore.subscribe(onModifiersChange);
736 onModifiersChange();
737 }
738 return () => {
739 listeners.delete(listener);
740 if (listeners.size > 0) return;
741 stopModifiers?.();
742 stopModifiers = null;
743 stopDeadline();
744 stopDeadline = () => {};
745 held = false;
746 };
747 },
748 getSnapshot: () => held,
749 };
751​
752const threadJumpModifierHold = createModifierHoldStore(THREAD_JUMP_HINT_SHOW_DELAY_MS);
753​
754function subscribeToThreadJumpHintInputs(listener: () => void): () => void {
755 const unsubscribeHold = threadJumpModifierHold.subscribe(listener);
756 const unsubscribeFocus = subscribeToTerminalFocusChanges(listener);
757 const unsubscribeTerminals = useTerminalUiStateStore.subscribe(listener);
758 const unsubscribeRightPanels = useRightPanelStore.subscribe(listener);
759 const unsubscribeModelPicker = subscribeToModelPickerVisibility(listener);
760 return () => {
761 unsubscribeHold();
762 unsubscribeFocus();
763 unsubscribeTerminals();
764 unsubscribeRightPanels();
765 unsubscribeModelPicker();
766 };
768​
769/**
770 * Thread jump hints appear once the jump modifier has been held for a moment,
771 * so a quick shortcut never flashes them, and hide as soon as it is released.
772 * The snapshot is the visibility itself, so a quick shortcut never re-renders the caller.
773 */
774export function useThreadJumpHintsVisible(
775 keybindings: ResolvedKeybindingsConfig,
776 routeThreadRef: ScopedThreadRef | null,
777): boolean {
778 return React.useSyncExternalStore(
779 subscribeToThreadJumpHintInputs,
780 () =>
781 threadJumpModifierHold.getSnapshot() &&
782 shouldShowThreadJumpHintsForModifiers(shortcutModifierStore.getSnapshot(), keybindings, {
783 platform: navigator.platform,
784 context: threadShortcutContext(routeThreadRef),
785 }),
786 );
788​
789export function resolveThreadLastVisitedAt(
790 serverLastVisitedAt: string | null | undefined,
791 localLastVisitedAt: string | undefined,
792): string | undefined {
793 // Server visits override local watermarks, including mark-unread rewinds; use local values only on older
794 // servers.
795 if (serverLastVisitedAt === undefined) return localLastVisitedAt;
796 return serverLastVisitedAt ?? undefined;
798​
799export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
800 if (!thread.latestRun?.completedAt) return false;
801 const completedAt = Date.parse(thread.latestRun.completedAt);
802 if (Number.isNaN(completedAt)) return false;
803 if (!thread.lastVisitedAt) return false;
804​
805 const lastVisitedAt = Date.parse(thread.lastVisitedAt);
806 if (Number.isNaN(lastVisitedAt)) return true;
807 return completedAt > lastVisitedAt;
809​
810/** Unopened threads' failures still count as unseen, unlike completions. */
811function hasUnseenFailure(thread: ThreadStatusInput): boolean {
812 if (thread.latestRun?.status !== "failed") return false;
813 if (!thread.lastVisitedAt) return true;
814 const completedAt = Date.parse(thread.latestRun.completedAt ?? "");
815 const lastVisitedAt = Date.parse(thread.lastVisitedAt);
816 if (Number.isNaN(completedAt) || Number.isNaN(lastVisitedAt)) return true;
817 return completedAt > lastVisitedAt;
819​
820// Suppress trailing double-click navigation; keyboard and synthetic clicks use detail 0.
821export function isTrailingDoubleClick(detail: number): boolean {
822 return detail > 1;
824​
825function nodeClosest(node: object | null, selector: string): unknown {
826 if (node === null || !("closest" in node) || typeof node.closest !== "function") return null;
827 return node.closest(selector);
829​
830export function isSidebarNestedLinkClick(target: EventTarget | null): boolean {
831 if (target == null || typeof target !== "object") return false;
832 if (nodeClosest(target, "a[href]") !== null) return true;
833 const parent =
834 "parentElement" in target &&
835 target.parentElement !== null &&
836 typeof target.parentElement === "object"
837 ? target.parentElement
838 : null;
839 return nodeClosest(parent, "a[href]") !== null;
841​
842/** The "New thread in" picker opens unless there is one open project or none; then the current context decides. */
843export function shouldCreateNewThreadInCurrentProject(
844 shiftKey: boolean,
845 openProjectGroupCount: number,
846 projectGroupCount: number,
847): boolean {
848 return shiftKey || openProjectGroupCount === 1 || projectGroupCount === 0;
850​
851export function orderItemsByPreferredIds<TItem, TId>(input: {
852 items: readonly TItem[];
853 preferredIds: readonly TId[];
854 getId: (item: TItem) => TId;
855 getPreferenceIds?: (item: TItem) => readonly TId[];
856}): TItem[] {
857 const { getId, getPreferenceIds, items, preferredIds } = input;
858 if (preferredIds.length === 0) {
859 return [...items];
860 }
861​
862 const indexesByPreferenceId = new Map<TId, number[]>();
863 for (const [index, item] of items.entries()) {
864 const preferenceIds = getPreferenceIds?.(item) ?? [getId(item)];
865 for (const preferenceId of new Set(preferenceIds)) {
866 const indexes = indexesByPreferenceId.get(preferenceId);
867 if (indexes) {
868 indexes.push(index);
869 } else {
870 indexesByPreferenceId.set(preferenceId, [index]);
871 }
872 }
873 }
874​
875 const emittedIndexes = new Set<number>();
876 const ordered = preferredIds.flatMap((id) => {
877 const index = indexesByPreferenceId
878 .get(id)
879 ?.find((candidate) => !emittedIndexes.has(candidate));
880 if (index === undefined) {
881 return [];
882 }
883 emittedIndexes.add(index);
884 return [items[index]!];
885 });
886 const remaining = items.filter((_, index) => !emittedIndexes.has(index));
887 return [...ordered, ...remaining];
889​
890export function resolveAdjacentThreadId<T>(input: {
891 threadIds: readonly T[];
892 currentThreadId: T | null;
893 direction: ThreadTraversalDirection;
894}): T | null {
895 const { currentThreadId, direction, threadIds } = input;
896​
897 if (threadIds.length === 0) {
898 return null;
899 }
900​
901 if (currentThreadId === null) {
902 return direction === "previous" ? (threadIds.at(-1) ?? null) : (threadIds[0] ?? null);
903 }
904​
905 const currentIndex = threadIds.indexOf(currentThreadId);
906 if (currentIndex === -1) {
907 return null;
908 }
909​
910 if (direction === "previous") {
911 return currentIndex > 0 ? (threadIds[currentIndex - 1] ?? null) : null;
912 }
913​
914 return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null;
916​
917export type SidebarThreadStatus =
918 | "approval"
919 | "input"
920 | "request"
921 | "preparing"
922 | "working"
923 | "waiting"
924 | "failed"
925 | "limited"
926 | "ready";
927​
928export function shouldRecedeSidebarThread(input: {
929 status: SidebarThreadStatus;
930 isUnread: boolean;
931 isWoke: boolean;
932 isActive: boolean;
933 isSelected: boolean;
934}): boolean {
935 if (
936 input.isActive ||
937 input.isSelected ||
938 input.status === "input" ||
939 input.status === "request"
940 ) {
941 return false;
942 }
943 if (input.status === "preparing" || input.status === "working" || input.status === "waiting") {
944 return true;
945 }
946 if (input.status === "ready" || input.status === "approval") {
947 return !input.isUnread && !input.isWoke;
948 }
949 return false;
951​
952type SidebarThreadStatusInput = Pick<
953 SidebarThreadSummary,
954 | "hasPendingApprovals"
955 | "hasPendingUserInput"
956 | "pendingSubagentRequest"
957 | "pendingGuestMessageCount"
958 | "runtime"
959 | "blockedOnThreadWait"
960>;
961​
962export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus {
963 if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
964 return "approval";
965 }
966 if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
967 return "input";
968 }
969 // A guest is waiting on the owner; the agent's own requests come first.
970 if ((thread.pendingGuestMessageCount ?? 0) > 0) {
971 return "request";
972 }
973 if (thread.runtime?.status === "running" && thread.blockedOnThreadWait === true) {
974 return "waiting";
975 }
976 if (thread.runtime?.status === "preparing") {
977 return "preparing";
978 }
979 if (
980 thread.runtime !== null &&
981 ["queued", "starting", "running", "waiting"].includes(thread.runtime.status)
982 ) {
983 return "working";
984 }
985 if (thread.runtime?.status === "idle") {
986 return "waiting";
987 }
988 if (thread.runtime?.status === "failed") {
989 return thread.runtime.lastErrorClass === "usage_limit" ? "limited" : "failed";
990 }
991 return "ready";
993​
994/** What the window title says the open thread is doing, in the words its announcer uses. */
995export function resolveWindowTitleThreadStatus(thread: SidebarThreadStatusInput): string | null {
996 // A guest waiting doesn't hide that the agent is still working.
997 switch (resolveSidebarThreadStatus({ ...thread, pendingGuestMessageCount: 0 })) {
998 case "approval":
999 return THREAD_STATUS_TEXT.approval;
1000 case "input":
1001 return THREAD_STATUS_TEXT.input;
1002 case "preparing":
1003 case "working":
1004 return THREAD_STATUS_TEXT.working;
1005 case "waiting":
1006 // Waiting on a child thread is still this turn, so it must not read as finished.
1007 return thread.runtime?.status === "running" ? THREAD_STATUS_TEXT.working : null;
1008 default:
1009 return null;
1012​
1013/**
1014 * The dot on a closed boring-sidebar folder: the most urgent state among its
1015 * hidden threads. Waiting threads stay quiet, like their rows.
1016 */
1017export function resolveSidebarFolderDot(
1018 threads: ReadonlyArray<ThreadStatusInput & SidebarThreadStatusInput>,
1019): "attention" | "failed" | "unread" | "working" | null {
1020 const statuses = new Set(threads.map(resolveSidebarThreadStatus));
1021 if (
1022 statuses.has("input") ||
1023 statuses.has("approval") ||
1024 statuses.has("request") ||
1025 statuses.has("limited")
1026 ) {
1027 return "attention";
1029 if (statuses.has("failed")) return "failed";
1030 // Rows show Done only for ready threads, so the dot follows the same rule.
1031 const hasUnread = threads.some(
1032 (thread) => resolveSidebarThreadStatus(thread) === "ready" && hasUnseenCompletion(thread),
1033 );
1034 if (hasUnread) return "unread";
1035 if (statuses.has("working") || statuses.has("preparing")) return "working";
1036 return null;
1038​
1039/**
1040 * Keys the old per-project sidebar saved folder open state under, most
1041 * specific first, for `resolveProjectExpanded`. Boring folders read and write
1042 * the same keys, so a legacy user's closed folders stay closed after regrouping.
1043 */
1044export function sidebarFolderExpansionKeys(group: {
1045 readonly projectKey: string;
1046 readonly memberProjects: ReadonlyArray<{
1047 readonly physicalProjectKey: string;
1048 readonly workspaceRoot: string;
1049 }>;
1050}): string[] {
1051 return [
1052 group.projectKey,
1053 ...group.memberProjects.map((member) => member.physicalProjectKey),
1054 ...group.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)),
1055 ];
1057​
1058/**
1059 * Boring sidebar folders. Folders follow `groups` order and threads keep their
1060 * input order. A project with no threads gets a folder only when `keepEmpty` says so.
1061 * A thread whose project is in no group has no folder and is left out.
1062 */
1063export function groupSidebarThreadsByProject<
1064 TGroup extends {
1065 readonly memberProjectRefs: ReadonlyArray<{
1066 readonly environmentId: EnvironmentId;
1067 readonly projectId: string;
1068 }>;
1069 },
1070 TThread extends Pick<SidebarThreadSummary, "environmentId" | "projectId">,
1072 groups: readonly TGroup[],
1073 threads: readonly TThread[],
1074 keepEmpty: (group: TGroup) => boolean,
1075): Array<{ group: TGroup; threads: TThread[] }> {
1076 const folders = groups.map((group) => ({ group, threads: [] as TThread[] }));
1077 const folderByProject = new Map<string, (typeof folders)[number]>();
1078 for (const folder of folders) {
1079 for (const ref of folder.group.memberProjectRefs) {
1080 folderByProject.set(`${ref.environmentId}:${ref.projectId}`, folder);
1083 for (const thread of threads) {
1084 folderByProject.get(`${thread.environmentId}:${thread.projectId}`)?.threads.push(thread);
1086 return folders.filter((folder) => folder.threads.length > 0 || keepEmpty(folder.group));
1088​
1089export type SidebarV2TopStatusKind =
1090 | "approval"
1091 | "done"
1092 | "failed"
1093 | "limited"
1094 | "input"
1095 | "request"
1096 | "waiting"
1097 | "woke"
1098 | "working";
1099​
1100export function firstValidTimestampMs(
1101 ...candidates: ReadonlyArray<string | null | undefined>
1102): number {
1103 for (const candidate of candidates) {
1104 if (candidate == null) continue;
1105 const parsed = Date.parse(candidate);
1106 if (!Number.isNaN(parsed)) return parsed;
1108 return 0;
1110​
1111export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
1112export {
1113 sortInboxThreadsByReturn,
1114 sortWorkingThreadsBySend,
1115} from "@t3tools/client-runtime/state/thread-inbox";
1116​
1117export { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
1118export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
1119​
1120const EMPTY_CONTENT_MATCH_KEYS: ReadonlySet<string> = new Set<string>();
1121​
1122/**
1123 * Sidebar search results: title, branch, and pull request matches ranked as
1124 * the command palette ranks them, then threads listed only because their
1125 * messages match, in `contentMatchKeys` order.
1126 */
1127export function searchSidebarThreads<
1128 T extends ThreadSearchShell & {
1129 readonly environmentId: EnvironmentId;
1130 readonly id: ThreadId;
1131 },
1133 threads: readonly T[],
1134 query: string,
1135 contentMatchKeys: ReadonlySet<string> = EMPTY_CONTENT_MATCH_KEYS,
1136 now = Date.now(),
1137): { readonly titleMatches: T[]; readonly contentMatches: T[] } {
1138 const normalizedQuery = foldSearchQuery(query);
1139 if (normalizedQuery.length === 0) return { titleMatches: [], contentMatches: [] };
1140 const titleMatches = rankSearchItems(threads, (thread, options) =>
1141 scoreThreadSearch(normalizedQuery, threadSearchSubject(thread, null), now, {
1142 ...options,
1143 picks: false,
1144 }),
1145 ).map(({ item }) => item);
1146 const listed = new Set(titleMatches);
1147 // In the order the server ranked their messages, as the palette lists them.
1148 const threadByKey = new Map(
1149 threads.map((thread) => [
1150 threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }),
1151 thread,
1152 ]),
1153 );
1154 const contentMatches = [...contentMatchKeys].flatMap((key) => {
1155 const thread = threadByKey.get(key);
1156 return thread && !listed.has(thread) ? [thread] : [];
1157 });
1158 return { titleMatches, contentMatches };
1160​
1161export function pullRequestMatchesSearchQuery(
1162 pullRequest: Parameters<typeof threadPullRequestSearchTerms>[0],
1163 query: string,
1164): boolean {
1165 const normalizedQuery = normalizeSearchText(query);
1166 if (normalizedQuery.length === 0) return false;
1167 const haystack = normalizeSearchText(threadPullRequestSearchTerms(pullRequest).join(" "));
1168 return normalizedQuery.split(" ").every((token) => haystack.includes(token));
1170​
1171export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {
1172 items: readonly TItem[];
1173 query: string;
1174 matches: (item: TItem, query: string) => boolean;
1175}): readonly TItem[] {
1176 const query = input.query.trim();
1177 if (query.length === 0) return input.items;
1178 return input.items.filter((item) => item.value !== "all" && input.matches(item, query));
1180​
1181interface SidebarProjectScopeMenuState {
1182 readonly open: boolean;
1183 readonly query: string;
1185​
1186type SidebarProjectScopeMenuAction =
1187 | { readonly type: "query-changed"; readonly query: string }
1188 | { readonly type: "open-changed"; readonly open: boolean }
1189 | { readonly type: "project-settings-opened" };
1190​
1191export function reduceSidebarProjectScopeMenuState(
1192 state: SidebarProjectScopeMenuState,
1193 action: SidebarProjectScopeMenuAction,
1194): SidebarProjectScopeMenuState {
1195 switch (action.type) {
1196 case "query-changed":
1197 return { ...state, query: action.query };
1198 case "open-changed":
1199 return { open: action.open, query: "" };
1200 case "project-settings-opened":
1201 return { open: false, query: "" };
1204​
1205export function resolveWorkingStartedAt(
1206 thread: Pick<SidebarThreadSummary, "latestRun" | "runtime">,
1207): string | null {
1208 return resolveThreadWorkingStartedAt(thread);
1210​
1211export function resolveThreadStatusPill(input: {
1212 thread: ThreadStatusInput;
1213}): ThreadStatusPill | null {
1214 const { thread } = input;
1215​
1216 if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
1217 return {
1218 label: "Pending Approval",
1219 colorClass: "text-amber-600 dark:text-amber-300/90",
1220 dotClass: "bg-amber-500 dark:bg-amber-300/90",
1221 pulse: false,
1222 };
1224​
1225 if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
1226 return {
1227 label: "Awaiting Input",
1228 colorClass: "text-indigo-600 dark:text-indigo-300/90",
1229 dotClass: "bg-indigo-500 dark:bg-indigo-300/90",
1230 pulse: false,
1231 };
1233​
1234 if ((thread.pendingGuestMessageCount ?? 0) > 0) {
1235 return {
1236 label: "Guest Request",
1237 colorClass: "text-guest-foreground",
1238 dotClass: "bg-guest",
1239 pulse: false,
1240 };
1242​
1243 if (
1244 (thread.runtime?.status === "running" && thread.blockedOnThreadWait !== true) ||
1245 thread.runtime?.status === "waiting"
1246 ) {
1247 return {
1248 label: "Working",
1249 colorClass: "text-sky-600 dark:text-sky-300/80",
1250 dotClass: "bg-sky-500 dark:bg-sky-300/80",
1251 pulse: true,
1252 };
1254​
1255 if (
1256 thread.runtime?.status === "preparing" ||
1257 thread.runtime?.status === "starting" ||
1258 thread.runtime?.status === "queued"
1259 ) {
1260 return {
1261 label: "Connecting",
1262 colorClass: "text-sky-600 dark:text-sky-300/80",
1263 dotClass: "bg-sky-500 dark:bg-sky-300/80",
1264 pulse: true,
1265 };
1267​
1268 if (
1269 (thread.runtime?.status === "running" && thread.blockedOnThreadWait === true) ||
1270 backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])
1271 ) {
1272 return {
1273 label: "Waiting",
1274 colorClass: "text-sidebar-muted-foreground",
1275 dotClass: "bg-sidebar-muted-foreground",
1276 pulse: false,
1277 };
1279​
1280 if (hasUnseenFailure(thread)) {
1281 return {
1282 label: "Failed",
1283 colorClass: "text-red-600 dark:text-red-300/90",
1284 dotClass: "bg-red-500 dark:bg-red-300/90",
1285 pulse: false,
1286 };
1288​
1289 const hasPlanReadyPrompt =
1290 !thread.hasPendingUserInput &&
1291 thread.interactionMode === "plan" &&
1292 isLatestRunSettled(thread.latestRun, thread.runtime) &&
1293 thread.hasActionableProposedPlan;
1294 if (hasPlanReadyPrompt) {
1295 return {
1296 label: "Plan Ready",
1297 colorClass: "text-violet-600 dark:text-violet-300/90",
1298 dotClass: "bg-violet-500 dark:bg-violet-300/90",
1299 pulse: false,
1300 };
1302​
1303 if (hasUnseenCompletion(thread)) {
1304 return {
1305 label: "Completed",
1306 colorClass: "text-emerald-600 dark:text-emerald-300/90",
1307 dotClass: "bg-emerald-500 dark:bg-emerald-300/90",
1308 pulse: false,
1309 };
1311​
1312 return null;
1314​
1315export function getFallbackThreadIdAfterDelete<
1316 T extends Pick<
1317 Thread,
1318 "id" | "projectId" | "createdAt" | "updatedAt" | "settledOverride" | "lineage"
1319 > &
1320 ThreadSortInput &
1321 ThreadSnoozeShell,
1322>(input: {
1323 threads: readonly T[];
1324 deletedThreadId: T["id"];
1325 sortOrder: SidebarThreadSortOrder;
1326 now: string;
1327 deletedThreadIds?: ReadonlySet<T["id"]>;
1328}): T["id"] | null {
1329 const { deletedThreadId, deletedThreadIds, sortOrder, now, threads } = input;
1330 const deletedThread = threads.find((thread) => thread.id === deletedThreadId);
1331 if (!deletedThread) {
1332 return null;
1334​
1335 return (
1336 sortThreads(
1337 threads.filter(
1338 (thread) =>
1339 thread.projectId === deletedThread.projectId &&
1340 thread.id !== deletedThreadId &&
1341 !deletedThreadIds?.has(thread.id) &&
1342 thread.settledOverride !== "settled" &&
1343 !effectiveSnoozed(thread, { now }) &&
1344 !isSidebarHiddenThread(thread),
1345 ),
1346 sortOrder,
1347 )[0]?.id ?? null
1348 );
1350function getProjectSortTimestamp(
1351 project: SidebarProject,
1352 projectThreads: readonly ThreadSortInput[],
1353 sortOrder: Exclude<SidebarProjectSortOrder, "manual">,
1354): number {
1355 if (projectThreads.length > 0) {
1356 return projectThreads.reduce(
1357 (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)),
1358 Number.NEGATIVE_INFINITY,
1359 );
1361​
1362 if (sortOrder === "created_at") {
1363 return toSortableTimestamp(project.createdAt) ?? Number.NEGATIVE_INFINITY;
1365 return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY;
1367​
1368function sortProjectsByActivity<TProject extends SidebarProject>(
1369 projects: readonly TProject[],
1370 sortOrder: SidebarProjectSortOrder,
1371 getProjectThreads: (project: TProject) => readonly ThreadSortInput[],
1372 compareTies: (left: TProject, right: TProject) => number,
1373): TProject[] {
1374 if (sortOrder === "manual") {
1375 return [...projects];
1377​
1378 // Project timestamps scan all threads; compute once per project instead of per comparison.
1379 return projects
1380 .map((project) => ({
1381 project,
1382 timestamp: getProjectSortTimestamp(project, getProjectThreads(project), sortOrder),
1383 }))
1384 .sort((left, right) => {
1385 const byTimestamp =
1386 right.timestamp === left.timestamp ? 0 : right.timestamp > left.timestamp ? 1 : -1;
1387 return byTimestamp || compareTies(left.project, right.project);
1388 })
1389 .map(({ project }) => project);
1391​
1392export function sortLogicalProjectsForSidebar<
1393 TProject extends LogicalSidebarProject,
1394 TThread extends ScopedSidebarThread,
1396 projects: readonly TProject[],
1397 threads: readonly TThread[],
1398 sortOrder: SidebarProjectSortOrder,
1399): TProject[] {
1400 const groupKeyByProjectRef = new Map(
1401 projects.flatMap((project) =>
1402 project.memberProjectRefs.map(
1403 (projectRef) =>
1404 [`${projectRef.environmentId}\0${projectRef.projectId}`, project.projectKey] as const,
1405 ),
1406 ),
1407 );
1408 const threadsByProjectKey = new Map<string, TThread[]>();
1409 for (const thread of threads) {
1410 if (thread.archivedAt !== null) continue;
1411 const projectKey = groupKeyByProjectRef.get(`${thread.environmentId}\0${thread.projectId}`);
1412 if (!projectKey) continue;
1413 const existing = threadsByProjectKey.get(projectKey);
1414 if (existing) {
1415 existing.push(thread);
1416 } else {
1417 threadsByProjectKey.set(projectKey, [thread]);
1420​
1421 return sortProjectsByActivity(
1422 projects,
1423 sortOrder,
1424 (project) => threadsByProjectKey.get(project.projectKey) ?? [],
1425 (left, right) =>
1426 left.title.localeCompare(right.title) || left.projectKey.localeCompare(right.projectKey),
1427 );
1429​
1430export function sortSidebarV2ProjectGroups<
1431 TProject extends LogicalSidebarProject,
1432 TThread extends ScopedSidebarThread &
1433 Pick<SidebarThreadSummary, "environmentId" | "id" | "lineage">,
1435 projects: readonly TProject[],
1436 threads: readonly TThread[],
1437 sortOrder: SidebarProjectSortOrder,
1438 delegatedUnderParent: ReadonlySet<string>,
1439): TProject[] {
1440 return sortLogicalProjectsForSidebar(
1441 projects,
1442 filterSidebarV2VisibleThreads(threads, null, delegatedUnderParent),
1443 sortOrder,
1444 );
1446​
1447export function sortScopedProjectsForSidebar<
1448 TProject extends ScopedSidebarProject,
1449 TThread extends ScopedSidebarThread,
1451 projects: readonly TProject[],
1452 threads: readonly TThread[],
1453 sortOrder: SidebarProjectSortOrder,
1454): TProject[] {
1455 const scopedKey = (environmentId: string, projectId: string) =>
1456 `${environmentId}\u0000${projectId}`;
1457 const threadsByProject = new Map<string, TThread[]>();
1458 for (const thread of threads) {
1459 if (thread.archivedAt !== null) {
1460 continue;
1462 const key = scopedKey(thread.environmentId, thread.projectId);
1463 const existing = threadsByProject.get(key) ?? [];
1464 existing.push(thread);
1465 threadsByProject.set(key, existing);
1467​
1468 return sortProjectsByActivity(
1469 projects,
1470 sortOrder,
1471 (project) => threadsByProject.get(scopedKey(project.environmentId, project.id)) ?? [],
1472 (left, right) =>
1473 left.title.localeCompare(right.title) ||
1474 left.environmentId.localeCompare(right.environmentId) ||
1475 left.id.localeCompare(right.id),
1476 );