1import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models";
2import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
3import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
4import * as React from "react";
5import {
6 isAtomCommandInterrupted,
7 type AtomCommandResult,
8} from "@t3tools/client-runtime/state/runtime";
9import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";
10import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
11import {
12 OrchestrationV2DispatchCommandError,
13 type ContextMenuItem,
14 type EnvironmentId,
15 type ThreadId,
16} from "@t3tools/contracts";
17import * as Cause from "effect/Cause";
18import * as Schema from "effect/Schema";
19import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
20import type { AsyncResult } from "effect/reactivity";
21import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
22import {
23 effectiveSnoozed,
24 type ThreadSnoozeShell,
25} from "@t3tools/client-runtime/state/thread-settled";
26import {
27 getThreadSortTimestamp,
28 sortThreads,
29 toSortableTimestamp,
30 type ThreadSortInput,
31} from "../lib/threadSort";
32import type { SidebarThreadSummary, Thread } from "../types";
33import { cn, normalizeSearchText } from "../lib/utils";
34import { isLatestRunSettled } from "../session-logic";
35import { resolveServerBackedAppStageLabel } from "../branding.logic";
36
37export function shouldNavigateAfterThreadPark(input: {
38 readonly threadKey: string;
39 readonly currentThreadKey: string | null;
40 readonly action: "settle" | "snooze";
41 readonly now: string;
42 readonly thread: (ThreadSnoozeShell & Pick<SidebarThreadSummary, "settledOverride">) | null;
43}): boolean {
44 return (
45 input.threadKey === input.currentThreadKey &&
46 input.thread !== null &&
47 (input.action === "settle"
48 ? input.thread.settledOverride === "settled"
49 : effectiveSnoozed(input.thread, { now: input.now }))
50 );
51}
52
53const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
54export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200;
55
56export function resolveSidebarRowAccessibility(input: {
57 readonly title: string;
58 readonly statusLabel: string | null;
59 readonly projectDisplayName: string | null;
60 readonly isActive: boolean;
61}): { readonly label: string; readonly current: "page" | undefined } {
62 return {
63 // The title is the row's identity and must lead when users scan tasks.
64 // Only static context belongs here; nested action labels remain separate controls.
65 label: [input.title, input.statusLabel, input.projectDisplayName].filter(Boolean).join(", "),
66 current: input.isActive ? "page" : undefined,
67 };
68}
69
70// Visible sidebar rows are prewarmed into the thread-detail cache so opening a
71// nearby thread usually reuses an already-hot subscription. Each prewarmed
72// thread holds a live, fully hydrated detail subscription (all messages and
73// activities, growing as agents work) for as long as the row stays visible,
74// so this limit is a direct renderer-heap and server-load multiplier — keep
75// it small; cold opens still render instantly from the cached snapshot.
76const SIDEBAR_THREAD_PREWARM_LIMIT = 3;
77// A small buffer keeps the next few rows warm without leasing every row that
78// content-visibility leaves mounted below the scroll viewport.
79const SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX = 160;
80
81export function useSidebarRowSubscriptionLease(isActive: boolean): {
82 readonly leaseLiveStatus: boolean;
83 readonly rowRef: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
84} {
85 const [row, setRow] = React.useState<HTMLElement | null>(null);
86 const [isNearViewport, setIsNearViewport] = React.useState(isActive);
87
88 React.useEffect(() => {
89 if (isActive) {
90 setIsNearViewport(true);
91 return;
92 }
93 if (row === null) return;
94 if (typeof IntersectionObserver === "undefined") {
95 setIsNearViewport(true);
96 return;
97 }
98
99 const scrollRoot = row.closest<HTMLElement>('[data-slot="scroll-area-viewport"]');
100 const observer = new IntersectionObserver(
101 ([entry]) => setIsNearViewport(entry?.isIntersecting === true),
102 {
103 root: scrollRoot,
104 rootMargin: `${SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX}px 0px`,
105 },
106 );
107 observer.observe(row);
108 return () => observer.disconnect();
109 }, [isActive, row]);
110
111 return {
112 leaseLiveStatus: isActive || isNearViewport,
113 rowRef: setRow,
114 };
115}
116
117// A row keeps the last live value it rendered so a released lease never
118// blanks its badge. The value is bound to `key`, so a different worktree or
119// linked pull request cannot reuse the previous one.
120export function useRetainedValue<T>(key: string | null, value: T | null): T | null {
121 const retained = React.useRef<{ readonly key: string; readonly value: T } | null>(null);
122 if (key !== null && value !== null) {
123 retained.current = { key, value };
124 }
125 if (value !== null) return value;
126 return key !== null && retained.current?.key === key ? retained.current.value : null;
127}
128
129// Sidebar.motion handles ordinary section changes. Sortable transforms own
130// dragging; replaying their committed DOM order would animate the drop twice.
131export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) =>
132 args.isSorting ? defaultAnimateLayoutChanges(args) : false;
133
134// Rows and section markers share one sortable list. The separators resolve
135// the lifecycle action; Sidebar.drag previews the resulting layout. Pinned
136// and active threads keep the dragged position; settled threads use time
137// order. Snoozed rows can leave the shelf, but dropping into it is not
138// supported because snoozing requires a wake time. The Working shelf (beta)
139// follows live status, so it is neither a drag source nor a destination.
140
141export type SidebarSection = "pinned" | "active" | "working" | "snoozed" | "settled";
142
143/** Resolve the shelf a visible thread belongs to. Snooze is temporary and
144 * wins until its wake boundary; settlement then wins over a stale pin. */
145export function resolveSidebarThreadSection(input: {
146 readonly snoozed: boolean;
147 readonly settled: boolean;
148 readonly pinned: boolean;
149}): SidebarSection {
150 if (input.snoozed) return "snoozed";
151 if (input.settled) return "settled";
152 if (input.pinned) return "pinned";
153 return "active";
154}
155
156/** Sortable ids: thread rows use their scoped key; structural items use a
157 colon-free prefix: scoped thread keys always contain a colon. */
158const SIDEBAR_MARKER_PREFIX = "sidebar-marker-";
159
160export type SidebarListMarker =
161 /** The top boundary is also a landing target when there are no pins. */
162 | "pinned-header"
163 /** Stand-in rows so an empty section has somewhere for the gap to open. */
164 | "active-placeholder"
165 | "settled-placeholder"
166 /** The boundary between pinned and active rows. */
167 | "pinned-divider"
168 | "working-header"
169 | "snoozed-header"
170 | "settled-header";
171
172export function sidebarMarkerId(marker: SidebarListMarker): string {
173 return `${SIDEBAR_MARKER_PREFIX}${marker}`;
174}
175
176export type SidebarListItem =
177 | { readonly kind: "thread"; readonly key: string; readonly section: SidebarSection }
178 | { readonly kind: "marker"; readonly marker: SidebarListMarker };
179
180export function sidebarListItemId(item: SidebarListItem): string {
181 return item.kind === "thread" ? item.key : sidebarMarkerId(item.marker);
182}
183
184/** The section a slot belongs to, read off the markers around it: from
185 the top down, everything before the pinned divider is pinned, then the
186 inbox until the first shelf header, each shelf until the next header,
187 then settled. */
188function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): SidebarSection {
189 let section: SidebarSection = "pinned";
190 for (let i = 0; i < index && i < items.length; i += 1) {
191 const item = items[i]!;
192 if (item.kind !== "marker") continue;
193 if (item.marker === "pinned-divider") section = "active";
194 else if (item.marker === "working-header") section = "working";
195 else if (item.marker === "snoozed-header") section = "snoozed";
196 else if (item.marker === "settled-header") section = "settled";
197 }
198 return section;
199}
200
201/** Resolve the destination section and manual order from an arrayMove across
202 * the separators. The working and snoozed shelves are never destinations. */
203export type SidebarDropTarget = {
204 readonly section: "pinned" | "active" | "settled";
205 readonly pinnedOrder: readonly string[];
206 readonly activeOrder: readonly string[];
207};
208
209export function resolveSidebarDropTarget(
210 items: readonly SidebarListItem[],
211 activeKey: string,
212 overId: string,
213): SidebarDropTarget | null {
214 const activeIndex = items.findIndex((item) => sidebarListItemId(item) === activeKey);
215 const overIndex = items.findIndex((item) => sidebarListItemId(item) === overId);
216 if (activeIndex === -1 || overIndex === -1 || items[activeIndex]?.kind !== "thread") return null;
217 const moved = items.filter((_, index) => index !== activeIndex);
218 moved.splice(overIndex, 0, items[activeIndex]!);
219 const section = sectionAtSidebarSlot(moved, overIndex);
220 if (section === "working" || section === "snoozed") return null;
221 const pinnedOrder: string[] = [];
222 const activeOrder: string[] = [];
223 let currentSection: SidebarSection = "pinned";
224 for (const item of moved) {
225 if (item.kind === "marker") {
226 if (item.marker === "pinned-divider") currentSection = "active";
227 else if (
228 item.marker === "working-header" ||
229 item.marker === "snoozed-header" ||
230 item.marker === "settled-header"
231 )
232 break;
233 } else if (currentSection === "pinned") pinnedOrder.push(item.key);
234 else activeOrder.push(item.key);
235 }
236 return { section, pinnedOrder, activeOrder };
237}
238
239export type SidebarThreadDropPlan =
240 | { readonly kind: "none" }
241 /** Within the pinned block: the existing key writes. */
242 | {
243 readonly kind: "reorder-pinned";
244 readonly order: readonly string[];
245 readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
246 }
247 /** From another section into the pinned block. Fresh pins take `orderKey`
248 on the pin command. `extraAssignments` land afterward, including the
249 moved row when it was already pinned beneath a snooze. */
250 | {
251 readonly kind: "pin";
252 readonly order: readonly string[];
253 readonly orderKey: string | undefined;
254 readonly extraAssignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
255 }
256 | {
257 readonly kind: "move-active";
258 /** Null when the inbox is time-ordered: the drop has no placement. */
259 readonly order: readonly string[] | null;
260 readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
261 readonly unpin: boolean;
262 readonly unsettle: boolean;
263 readonly unsnooze: boolean;
264 }
265 | { readonly kind: "settle" };
266
267/** What dropping in `to` does to a thread lifted from `from`, for the badge
268 on the lifted row. Null while reordering inside one section and for the
269 working and snoozed shelves, which cannot be drop targets. */
270export type SidebarDropVerb = "pin" | "unpin" | "settle" | "unsettle" | "wake";
271
272export function resolveSidebarDropVerb(
273 from: SidebarSection,
274 to: SidebarSection | null,
275): SidebarDropVerb | null {
276 if (to === null || to === from || to === "working" || to === "snoozed") return null;
277 if (to === "pinned") return "pin";
278 if (to === "settled") return "settle";
279 if (from === "pinned") return "unpin";
280 if (from === "settled") return "unsettle";
281 return "wake";
282}
283
284/** Eligible rows between the pressed action and the pointer, in sidebar order. */
285export function resolveSidebarSweepKeys(
286 orderedKeys: readonly string[],
287 originKey: string,
288 targetKey: string,
289 canApply: (key: string) => boolean,
290): string[] {
291 const origin = orderedKeys.indexOf(originKey);
292 const target = orderedKeys.indexOf(targetKey);
293 if (origin === -1 || target === -1) return [];
294 return orderedKeys.slice(Math.min(origin, target), Math.max(origin, target) + 1).filter(canApply);
295}
296
297/** The thread row at a pointer height, clamped to the rows visible in the
298 sidebar's scroll viewport. A gap between rows resolves to the row above
299 it. Rows carry their key in data-thread-item, which departing motion
300 clones drop. */
301export function sidebarThreadKeyAtY(list: HTMLElement, y: number): string | null {
302 const viewport = list.closest('[data-slot="scroll-area-viewport"]')?.getBoundingClientRect();
303 const visibleY = viewport ? Math.min(Math.max(y, viewport.top), viewport.bottom - 1) : y;
304 let key: string | null = null;
305 for (const row of list.querySelectorAll<HTMLElement>("li[data-thread-item]")) {
306 if (key !== null && row.getBoundingClientRect().top > visibleY) break;
307 key = row.dataset.threadItem ?? null;
308 }
309 return key;
310}
311
312export function planSidebarThreadDrop(input: {
313 readonly activeKey: string;
314 readonly activeSection: SidebarSection;
315 /** Snoozed threads can retain pinning and settlement beneath the shelf. */
316 readonly activePinned?: boolean;
317 readonly activeSettled?: boolean;
318 readonly supportsSettlement?: boolean;
319 readonly target: SidebarDropTarget;
320 /** All pinned keys in displayed order before the drop. */
321 readonly pinnedOrder: readonly string[];
322 readonly pinnedKeysById: ReadonlyMap<string, string | null | undefined>;
323 readonly reorderableKeys?: ReadonlySet<string>;
324 readonly activeOrder: readonly string[];
325 readonly activeKeysById: ReadonlyMap<string, string | null | undefined>;
326 readonly activeReorderableKeys?: ReadonlySet<string>;
327 /** Working beta: the inbox sorts by time, so drops only change lifecycle. */
328 readonly activeTimeOrdered?: boolean;
329}): SidebarThreadDropPlan {
330 const {
331 activeKey,
332 activeSection,
333 activePinned = activeSection === "pinned",
334 activeSettled = activeSection === "settled",
335 target,
336 pinnedOrder,
337 pinnedKeysById,
338 reorderableKeys,
339 activeOrder,
340 activeKeysById,
341 activeReorderableKeys,
342 } = input;
343 if (input.supportsSettlement === false && (target.section === "settled" || activeSettled)) {
344 return { kind: "none" };
345 }
346 // Rows whose server cannot store an order (an older server, or a machine
347 // that is offline) are never written. Keyless ones sort outside the keyed
348 // run, so they leave the plan; keyed ones stay as bounds. Before, one keyless row
349 // refused every drop that needed fresh keys for its neighbors.
350 const arrange = (
351 order: readonly string[],
352 keysById: ReadonlyMap<string, string | null | undefined>,
353 writable: ReadonlySet<string> | undefined,
354 ) => {
355 if (!writable) return planPinnedReorder({ orderedIds: order, keysById, movedId: activeKey });
356 if (!writable.has(activeKey)) return null;
357 const assignments = planPinnedReorder({
358 orderedIds: order.filter((key) => writable.has(key) || keysById.get(key) != null),
359 keysById,
360 movedId: activeKey,
361 });
362 return assignments.every(({ id }) => writable.has(id)) ? assignments : null;
363 };
364 switch (target.section) {
365 case "active": {
366 // Like the settled tail: threads can enter a time-ordered inbox, but
367 // not be arranged inside it.
368 if (input.activeTimeOrdered) {
369 return activeSection === "active"
370 ? { kind: "none" }
371 : {
372 kind: "move-active",
373 order: null,
374 assignments: [],
375 unpin: activePinned,
376 unsettle: activeSettled,
377 unsnooze: activeSection === "snoozed",
378 };
379 }
380 const order = target.activeOrder;
381 if (
382 activeSection === "active" &&
383 order.length === activeOrder.length &&
384 order.every((key, index) => key === activeOrder[index])
385 ) {
386 return { kind: "none" };
387 }
388 const assignments = arrange(order, activeKeysById, activeReorderableKeys);
389 if (assignments === null) return { kind: "none" };
390 return {
391 kind: "move-active",
392 order,
393 assignments,
394 unpin: activePinned,
395 unsettle: activeSettled,
396 unsnooze: activeSection === "snoozed",
397 };
398 }
399 case "settled":
400 return activeSection === "settled" ? { kind: "none" } : { kind: "settle" };
401 case "pinned": {
402 const order = target.pinnedOrder;
403 // Dropped back where it started: nothing to write.
404 if (
405 activeSection === "pinned" &&
406 order.length === pinnedOrder.length &&
407 order.every((key, index) => key === pinnedOrder[index])
408 ) {
409 return { kind: "none" };
410 }
411 const assignments = arrange(order, pinnedKeysById, reorderableKeys);
412 if (assignments === null) return { kind: "none" };
413 if (activeSection === "pinned") {
414 return assignments.length === 0
415 ? { kind: "none" }
416 : { kind: "reorder-pinned", order, assignments };
417 }
418 return {
419 kind: "pin",
420 order,
421 orderKey: assignments.find((assignment) => assignment.id === activeKey)?.orderKey,
422 extraAssignments: activePinned
423 ? assignments
424 : assignments.filter((assignment) => assignment.id !== activeKey),
425 };
426 }
427 }
428}
429
430/** Project a drop's lifecycle fields before sorting its destination. Reusing
431 the server's re-entry rules keeps the preview in place when events arrive. */
432export function applySidebarThreadDrop<
433 T extends Pick<
434 SidebarThreadSummary,
435 | "pinnedAt"
436 | "pinOrderKey"
437 | "activeOrderKey"
438 | "snoozedAt"
439 | "snoozedUntil"
440 | "settledAt"
441 | "settledOverride"
442 | "unsettledAt"
443 >,
444>(thread: T, section: "pinned" | "active" | "settled", now: string, orderKey?: string): T {
445 const wasSettled = thread.settledOverride === "settled";
446 const awake = { ...thread, snoozedAt: null, snoozedUntil: null };
447 if (section === "settled") {
448 return {
449 ...awake,
450 pinnedAt: null,
451 pinOrderKey: null,
452 activeOrderKey: null,
453 settledOverride: "settled",
454 settledAt: wasSettled ? (thread.settledAt ?? now) : now,
455 unsettledAt: null,
456 };
457 }
458 const resumed = wasSettled
459 ? { ...awake, settledOverride: "active" as const, settledAt: null, unsettledAt: now }
460 : awake;
461 return {
462 ...resumed,
463 pinnedAt: section === "pinned" ? (thread.pinnedAt ?? now) : null,
464 pinOrderKey: section === "pinned" ? (orderKey ?? thread.pinOrderKey) : null,
465 ...(section === "active" && orderKey !== undefined ? { activeOrderKey: orderKey } : {}),
466 };
467}
468
469type SidebarProject = {
470 id: string;
471 title: string;
472 createdAt?: string | undefined;
473 updatedAt?: string | undefined;
474};
475
476type ScopedSidebarProject = SidebarProject & {
477 environmentId: string;
478};
479
480type ScopedSidebarThread = ThreadSortInput & {
481 environmentId: string;
482 projectId: string;
483 archivedAt: string | null;
484};
485
486type LogicalSidebarProject = SidebarProject & {
487 projectKey: string;
488 memberProjectRefs: readonly {
489 environmentId: string;
490 projectId: string;
491 }[];
492};
493
494export type ThreadTraversalDirection = "previous" | "next";
495
496/**
497 * Shared-worktree checks must exclude only successful deletions, never the
498 * whole batch. A null result skips an entry that the caller can no longer find.
499 */
500export async function deleteSelectedThreadEntries<
501 TEntry extends { readonly threadKey: string },
502>(input: {
503 entries: readonly TEntry[];
504 delete: (
505 entry: TEntry,
506 deletedThreadKeys: ReadonlySet<string>,
507 ) => Promise<AtomCommandResult<unknown, unknown> | null>;
508}) {
509 const deletedThreadKeys = new Set<string>();
510 let firstFailure: AsyncResult.Failure<unknown, unknown> | null = null;
511
512 for (const entry of input.entries) {
513 const result = await input.delete(entry, deletedThreadKeys);
514 if (result === null) continue;
515 if (result._tag === "Failure") {
516 if (isAtomCommandInterrupted(result)) break;
517 firstFailure ??= result;
518 continue;
519 }
520 deletedThreadKeys.add(entry.threadKey);
521 }
522
523 return { deletedThreadKeys, firstFailure };
524}
525
526const isDispatchCommandError = Schema.is(OrchestrationV2DispatchCommandError);
527
528export async function archiveSelectedThreadEntries<
529 TEntry extends { readonly threadKey: string },
530>(input: {
531 entries: readonly TEntry[];
532 archive: (
533 entry: TEntry,
534 onArchived: () => void,
535 ) => Promise<AtomCommandResult<unknown, unknown> | null>;
536}): Promise<{
537 archivedThreadKeys: readonly string[];
538 mutationFailure: AsyncResult.Failure<unknown, unknown> | null;
539 followupFailures: readonly AsyncResult.Failure<unknown, unknown>[];
540}> {
541 const archivedThreadKeys: string[] = [];
542 const followupFailures: AsyncResult.Failure<unknown, unknown>[] = [];
543
544 for (const entry of input.entries) {
545 let didArchive = false;
546 const result = await input.archive(entry, () => {
547 didArchive = true;
548 });
549 if (result === null) continue;
550 if (didArchive || result._tag === "Success") archivedThreadKeys.push(entry.threadKey);
551 if (result._tag === "Success") continue;
552 if (didArchive) {
553 followupFailures.push(result);
554 continue;
555 }
556 const error = Cause.squash(result.cause);
557 if (isDispatchCommandError(error) && error.reason === "archive-condition-changed") {
558 continue;
559 }
560 return { archivedThreadKeys, mutationFailure: result, followupFailures };
561 }
562
563 return { archivedThreadKeys, mutationFailure: null, followupFailures };
564}
565
566export function buildMultiSelectThreadContextMenuItems(input: {
567 count: number;
568 hasRunningThread: boolean;
569}): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] {
570 return [
571 { id: "mark-unread", label: `Mark unread (${input.count})` },
572 {
573 id: "archive",
574 label: `Archive (${input.count})`,
575 disabled: input.hasRunningThread,
576 },
577 { id: "delete", label: `Delete (${input.count})`, destructive: true },
578 ];
579}
580
581export function isSidebarSubagentThread(thread: Pick<SidebarThreadSummary, "lineage">): boolean {
582 return thread.lineage.relationshipToParent === "subagent";
583}
584
585export function filterSidebarV2VisibleThreads<
586 T extends Pick<SidebarThreadSummary, "archivedAt" | "lineage"> & {
587 environmentId: string;
588 projectId: string;
589 },
590>(threads: readonly T[], scopedProjectKeys: ReadonlySet<string> | null): T[] {
591 return threads.filter(
592 (thread) =>
593 thread.archivedAt === null &&
594 !isSidebarSubagentThread(thread) &&
595 (scopedProjectKeys === null ||
596 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
597 );
598}
599
600export function getSidebarForkParentThreadId(
601 thread: Pick<SidebarThreadSummary, "forkedFrom" | "lineage">,
602) {
603 if (thread.lineage.relationshipToParent !== "fork") {
604 return null;
605 }
606 return thread.forkedFrom?.type === "run"
607 ? thread.forkedFrom.threadId
608 : thread.lineage.parentThreadId;
609}
610
611export function buildBulkTitleRegenerationContextMenuItem(input: {
612 supportedCount: number;
613 actionableCount: number;
614}): ContextMenuItem<"regenerate-title"> | null {
615 if (input.supportedCount === 0) return null;
616 if (input.actionableCount === 0) {
617 return {
618 id: "regenerate-title",
619 label: `Regenerating… (${input.supportedCount})`,
620 disabled: true,
621 };
622 }
623 return {
624 id: "regenerate-title",
625 label: `Regenerate titles (${input.actionableCount})`,
626 };
627}
628
629/**
630 * Bulk unpin follows the same "count only what the action will touch" rule
631 * as title regeneration: on a mixed selection the label counts the pinned
632 * rows alone, and the item disappears when nothing selected is pinned.
633 */
634export function buildBulkUnpinContextMenuItem(input: {
635 pinnedCount: number;
636}): ContextMenuItem<"unpin"> | null {
637 if (input.pinnedCount === 0) return null;
638 return { id: "unpin", label: `Unpin (${input.pinnedCount})` };
639}
640
641export interface ThreadStatusPill {
642 label:
643 | "Working"
644 | "Connecting"
645 | "Completed"
646 | "Pending Approval"
647 | "Awaiting Input"
648 | "Waiting"
649 | "Plan Ready";
650 colorClass: string;
651 dotClass: string;
652 pulse: boolean;
653}
654
655const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = {
656 "Pending Approval": 5,
657 "Awaiting Input": 4,
658 Working: 3,
659 Connecting: 3,
660 Waiting: 2.5,
661 "Plan Ready": 2,
662 Completed: 1,
663};
664
665type ThreadStatusInput = Pick<
666 SidebarThreadSummary,
667 | "hasActionableProposedPlan"
668 | "hasPendingApprovals"
669 | "hasPendingUserInput"
670 | "pendingSubagentRequest"
671 | "interactionMode"
672 | "latestRun"
673 | "runtime"
674> & {
675 lastVisitedAt?: string | null | undefined;
676 pendingBackgroundTasks?: SidebarThreadSummary["pendingBackgroundTasks"] | undefined;
677};
678
679export interface ThreadJumpHintVisibilityController {
680 sync: (shouldShow: boolean) => void;
681 dispose: () => void;
682}
683
684export function resolveSidebarStageBadgeLabel(input: {
685 primaryServerVersion: string | null | undefined;
686 fallbackStageLabel: string;
687}): string {
688 return resolveServerBackedAppStageLabel(input);
689}
690
691export function createThreadJumpHintVisibilityController(input: {
692 delayMs: number;
693 onVisibilityChange: (visible: boolean) => void;
694 setTimeoutFn?: typeof globalThis.setTimeout;
695 clearTimeoutFn?: typeof globalThis.clearTimeout;
696}): ThreadJumpHintVisibilityController {
697 const setTimeoutFn = input.setTimeoutFn ?? globalThis.setTimeout;
698 const clearTimeoutFn = input.clearTimeoutFn ?? globalThis.clearTimeout;
699 let isVisible = false;
700 let timeoutId: NodeJS.Timeout | null = null;
701
702 const clearPendingShow = () => {
703 if (timeoutId === null) {
704 return;
705 }
706 clearTimeoutFn(timeoutId);
707 timeoutId = null;
708 };
709
710 return {
711 sync: (shouldShow) => {
712 if (!shouldShow) {
713 clearPendingShow();
714 if (isVisible) {
715 isVisible = false;
716 input.onVisibilityChange(false);
717 }
718 return;
719 }
720
721 if (isVisible || timeoutId !== null) {
722 return;
723 }
724
725 timeoutId = setTimeoutFn(() => {
726 timeoutId = null;
727 isVisible = true;
728 input.onVisibilityChange(true);
729 }, input.delayMs);
730 },
731 dispose: () => {
732 clearPendingShow();
733 },
734 };
735}
736
737export function useThreadJumpHintVisibility(): {
738 showThreadJumpHints: boolean;
739 updateThreadJumpHintsVisibility: (shouldShow: boolean) => void;
740} {
741 const [showThreadJumpHints, setShowThreadJumpHints] = React.useState(false);
742 const controllerRef = React.useRef<ThreadJumpHintVisibilityController | null>(null);
743
744 React.useEffect(() => {
745 const controller = createThreadJumpHintVisibilityController({
746 delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS,
747 onVisibilityChange: (visible) => {
748 setShowThreadJumpHints(visible);
749 },
750 setTimeoutFn: window.setTimeout.bind(window),
751 clearTimeoutFn: window.clearTimeout.bind(window),
752 });
753 controllerRef.current = controller;
754
755 return () => {
756 controller.dispose();
757 controllerRef.current = null;
758 };
759 }, []);
760
761 const updateThreadJumpHintsVisibility = React.useCallback((shouldShow: boolean) => {
762 controllerRef.current?.sync(shouldShow);
763 }, []);
764
765 return {
766 showThreadJumpHints,
767 updateThreadJumpHintsVisibility,
768 };
769}
770
771/**
772 * Effective visited watermark for a thread. Servers with visited tracking
773 * project `lastVisitedAt` on the shell and are authoritative — that value is
774 * shared across every device connected to the environment. Pre-tracking
775 * servers omit the field, and the browser's locally persisted watermark keeps
776 * working as before.
777 */
778export function resolveThreadLastVisitedAt(
779 serverLastVisitedAt: string | null | undefined,
780 localLastVisitedAt: string | undefined,
781): string | undefined {
782 // When the server tracks visits it is authoritative — including explicit
783 // rewinds from mark-unread, which a newer browser-local watermark must not
784 // mask. The local value only carries servers without visited tracking.
785 if (serverLastVisitedAt === undefined) return localLastVisitedAt;
786 return serverLastVisitedAt ?? undefined;
787}
788
789export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
790 if (!thread.latestRun?.completedAt) return false;
791 const completedAt = Date.parse(thread.latestRun.completedAt);
792 if (Number.isNaN(completedAt)) return false;
793 if (!thread.lastVisitedAt) return false;
794
795 const lastVisitedAt = Date.parse(thread.lastVisitedAt);
796 if (Number.isNaN(lastVisitedAt)) return true;
797 return completedAt > lastVisitedAt;
798}
799
800export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean {
801 if (target === null) return true;
802 return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);
803}
804
805// A double-click dispatches two `click` events before `dblclick`: the first has
806// `detail === 1`, the second `detail === 2`. The second click must not run the
807// row's single-click navigation, otherwise double-click-to-rename would also
808// navigate. `MouseEvent.detail` is 0 for synthetic/keyboard activations, which
809// still count as a normal single activation.
810export function isTrailingDoubleClick(detail: number): boolean {
811 return detail > 1;
812}
813
814function nodeClosest(node: object | null, selector: string): unknown {
815 if (node === null || !("closest" in node) || typeof node.closest !== "function") return null;
816 return node.closest(selector);
817}
818
819/** Clicks on a nested link keep the link's meaning. The row must not treat them as multi-select. */
820export function isSidebarNestedLinkClick(target: EventTarget | null): boolean {
821 if (target == null || typeof target !== "object") return false;
822 if (nodeClosest(target, "a[href]") !== null) return true;
823 const parent =
824 "parentElement" in target &&
825 target.parentElement !== null &&
826 typeof target.parentElement === "object"
827 ? target.parentElement
828 : null;
829 return nodeClosest(parent, "a[href]") !== null;
830}
831
832// Shift+click on the new thread button creates directly in the current
833// project, skipping the command palette's project picker. With a single
834// project there is nothing to pick, so a plain click already creates
835// immediately and the modifier changes nothing.
836export function shouldCreateNewThreadInCurrentProject(
837 shiftKey: boolean,
838 projectGroupCount: number,
839): boolean {
840 return shiftKey || projectGroupCount <= 1;
841}
842
843export function orderItemsByPreferredIds<TItem, TId>(input: {
844 items: readonly TItem[];
845 preferredIds: readonly TId[];
846 getId: (item: TItem) => TId;
847 getPreferenceIds?: (item: TItem) => readonly TId[];
848}): TItem[] {
849 const { getId, getPreferenceIds, items, preferredIds } = input;
850 if (preferredIds.length === 0) {
851 return [...items];
852 }
853
854 const indexesByPreferenceId = new Map<TId, number[]>();
855 for (const [index, item] of items.entries()) {
856 const preferenceIds = getPreferenceIds?.(item) ?? [getId(item)];
857 for (const preferenceId of new Set(preferenceIds)) {
858 const indexes = indexesByPreferenceId.get(preferenceId);
859 if (indexes) {
860 indexes.push(index);
861 } else {
862 indexesByPreferenceId.set(preferenceId, [index]);
863 }
864 }
865 }
866
867 const emittedIndexes = new Set<number>();
868 const ordered = preferredIds.flatMap((id) => {
869 const index = indexesByPreferenceId
870 .get(id)
871 ?.find((candidate) => !emittedIndexes.has(candidate));
872 if (index === undefined) {
873 return [];
874 }
875 emittedIndexes.add(index);
876 return [items[index]!];
877 });
878 const remaining = items.filter((_, index) => !emittedIndexes.has(index));
879 return [...ordered, ...remaining];
880}
881
882export function getSidebarThreadIdsToPrewarm<TThreadId>(
883 visibleThreadIds: readonly TThreadId[],
884 limit = SIDEBAR_THREAD_PREWARM_LIMIT,
885): TThreadId[] {
886 return visibleThreadIds.slice(0, Math.max(0, limit));
887}
888
889export function resolveAdjacentThreadId<T>(input: {
890 threadIds: readonly T[];
891 currentThreadId: T | null;
892 direction: ThreadTraversalDirection;
893}): T | null {
894 const { currentThreadId, direction, threadIds } = input;
895
896 if (threadIds.length === 0) {
897 return null;
898 }
899
900 if (currentThreadId === null) {
901 return direction === "previous" ? (threadIds.at(-1) ?? null) : (threadIds[0] ?? null);
902 }
903
904 const currentIndex = threadIds.indexOf(currentThreadId);
905 if (currentIndex === -1) {
906 return null;
907 }
908
909 if (direction === "previous") {
910 return currentIndex > 0 ? (threadIds[currentIndex - 1] ?? null) : null;
911 }
912
913 return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null;
914}
915
916export function isContextMenuPointerDown(input: {
917 button: number;
918 ctrlKey: boolean;
919 isMac: boolean;
920}): boolean {
921 if (input.button === 2) return true;
922 return input.isMac && input.button === 0 && input.ctrlKey;
923}
924
925export function resolveThreadRowClassName(input: {
926 isActive: boolean;
927 isSelected: boolean;
928}): string {
929 const baseClassName =
930 "h-8 w-full translate-x-0 cursor-pointer justify-start rounded-md px-2 text-left text-sm select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring";
931
932 if (input.isSelected && input.isActive) {
933 return cn(
934 baseClassName,
935 "bg-sidebar-row-active text-sidebar-foreground font-medium hover:bg-sidebar-row-active hover:text-sidebar-foreground",
936 );
937 }
938
939 if (input.isSelected) {
940 return cn(
941 baseClassName,
942 "bg-sidebar-row-selected text-sidebar-foreground hover:bg-sidebar-row-active hover:text-sidebar-foreground",
943 );
944 }
945
946 if (input.isActive) {
947 return cn(
948 baseClassName,
949 "bg-sidebar-row-active text-sidebar-foreground font-medium hover:bg-sidebar-row-active hover:text-sidebar-foreground",
950 );
951 }
952
953 return cn(
954 baseClassName,
955 "text-sidebar-muted-foreground/80 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
956 );
957}
958
959// ── Sidebar v2 status model ─────────────────────────────────────────
960// Six visual states, three colors: color is reserved for "act now"
961// (approval), "in motion" (working), and "broken" (failed). Ready is the
962// unlabeled resting state — the agent stopped and is waiting on the user,
963// whether it finished, asked a question, or proposed a plan. Waiting
964// (runtime status "idle") is the agent stopped with background work that will
965// wake it (subagents, monitors): not the user's turn yet, so it renders grey
966// like working, not as a false Done. Commands it left running, such as a dev
967// server, do not hold the thread; it reads as ready.
968// Unread completion is tracked separately: it describes whether a ready
969// thread needs attention, not what the thread is currently doing.
970export type SidebarThreadStatus =
971 | "approval"
972 | "input"
973 | "working"
974 | "waiting"
975 | "failed"
976 | "limited"
977 | "ready";
978
979export function shouldRecedeSidebarThread(input: {
980 status: SidebarThreadStatus;
981 isUnread: boolean;
982 isWoke: boolean;
983 isActive: boolean;
984 isSelected: boolean;
985}): boolean {
986 if (input.isActive || input.isSelected || input.status === "input") return false;
987 if (input.status === "working" || input.status === "waiting") return true;
988 if (input.status === "ready" || input.status === "approval") {
989 return !input.isUnread && !input.isWoke;
990 }
991 return false;
992}
993
994type SidebarThreadStatusInput = Pick<
995 SidebarThreadSummary,
996 "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" | "runtime"
997>;
998
999export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus {
1000 if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
1001 return "approval";
1002 }
1003 if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
1004 return "input";
1005 }
1006 if (
1007 thread.runtime !== null &&
1008 ["preparing", "queued", "starting", "running", "waiting"].includes(thread.runtime.status)
1009 ) {
1010 return "working";
1011 }
1012 if (thread.runtime?.status === "idle") {
1013 return "waiting";
1014 }
1015 if (thread.runtime?.status === "failed") {
1016 return thread.runtime.lastErrorClass === "usage_limit" ? "limited" : "failed";
1017 }
1018 return "ready";
1019}
1020
1021export type SidebarV2TopStatusKind =
1022 | "approval"
1023 | "done"
1024 | "failed"
1025 | "limited"
1026 | "input"
1027 | "waiting"
1028 | "woke"
1029 | "working";
1030
1031export function resolveSidebarV2TopStatus(input: {
1032 readonly status: SidebarThreadStatus;
1033 readonly isUnread: boolean;
1034 readonly isWoke: boolean;
1035}): SidebarV2TopStatusKind | null {
1036 if (input.status === "working") {
1037 return "working";
1038 }
1039 if (input.status === "waiting") {
1040 return "waiting";
1041 }
1042 if (input.status === "approval") {
1043 return "approval";
1044 }
1045 if (input.status === "input") {
1046 return "input";
1047 }
1048 if (input.status === "failed" || input.status === "limited") {
1049 return input.status;
1050 }
1051 if (input.isWoke) {
1052 return "woke";
1053 }
1054 return input.isUnread ? "done" : null;
1055}
1056
1057export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolean {
1058 return status === "working";
1059}
1060
1061/** First VALID timestamp wins: `a ?? b` falls through on null, but a present-
1062 yet-malformed string must also fall through to the next candidate rather
1063 than sink the row to the epoch. */
1064export function firstValidTimestampMs(
1065 ...candidates: ReadonlyArray<string | null | undefined>
1066): number {
1067 for (const candidate of candidates) {
1068 if (candidate == null) continue;
1069 const parsed = Date.parse(candidate);
1070 if (!Number.isNaN(parsed)) return parsed;
1071 }
1072 return 0;
1073}
1074
1075export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
1076// The Working section beta folds and orders the inbox the same way on mobile.
1077export {
1078 isThreadWorking as isSidebarThreadWorking,
1079 sortInboxThreadsByReturn,
1080 sortWorkingThreadsBySend,
1081} from "@t3tools/client-runtime/state/thread-inbox";
1082
1083// Pinned-reorder key math and the keyed sort live in client-runtime
1084// (state/thread-sort) so web and mobile compute identical pinned orders.
1085export { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
1086export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
1087
1088const EMPTY_CONTENT_MATCH_KEYS: ReadonlySet<string> = new Set<string>();
1089
1090/**
1091 * Search the already-ordered sidebar thread collection by title or linked PR,
1092 * plus any thread whose messages the server matched (`contentMatchKeys`, keyed
1093 * by `threadSearchMatchKey`). Keeping the input order means lifecycle ordering
1094 * (active, snoozed, settled) remains stable while the user narrows the list.
1095 */
1096export function searchSidebarThreads<
1097 T extends {
1098 readonly environmentId: EnvironmentId;
1099 readonly id: ThreadId;
1100 readonly title: string;
1101 } & Parameters<typeof threadPullRequestSearchTerms>[0],
1102>(
1103 threads: readonly T[],
1104 query: string,
1105 contentMatchKeys: ReadonlySet<string> = EMPTY_CONTENT_MATCH_KEYS,
1106): T[] {
1107 const normalizedQuery = query.trim().toLowerCase();
1108 if (normalizedQuery.length === 0) return [];
1109 const titleMatches: T[] = [];
1110 const contentMatches: T[] = [];
1111 for (const thread of threads) {
1112 const matchesTitle = [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) =>
1113 term.toLowerCase().includes(normalizedQuery),
1114 );
1115 if (matchesTitle) {
1116 titleMatches.push(thread);
1117 } else if (
1118 contentMatchKeys.size > 0 &&
1119 contentMatchKeys.has(
1120 threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }),
1121 )
1122 ) {
1123 contentMatches.push(thread);
1124 }
1125 }
1126 return [...titleMatches, ...contentMatches];
1127}
1128
1129/**
1130 * Whether a PR's search terms contain every whitespace-separated token of the
1131 * query. Search results use this to pick the PR number to show, so it has to
1132 * accept the multi-token queries the command palette matches on.
1133 */
1134export function pullRequestMatchesSearchQuery(
1135 pullRequest: Parameters<typeof threadPullRequestSearchTerms>[0],
1136 query: string,
1137): boolean {
1138 const normalizedQuery = normalizeSearchText(query);
1139 if (normalizedQuery.length === 0) return false;
1140 const haystack = normalizeSearchText(threadPullRequestSearchTerms(pullRequest).join(" "));
1141 return normalizedQuery.split(" ").every((token) => haystack.includes(token));
1142}
1143
1144export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {
1145 items: readonly TItem[];
1146 query: string;
1147 matches: (item: TItem, query: string) => boolean;
1148}): readonly TItem[] {
1149 const query = input.query.trim();
1150 if (query.length === 0) return input.items;
1151 return input.items.filter((item) => item.value !== "all" && input.matches(item, query));
1152}
1153
1154export interface SidebarProjectScopeMenuState {
1155 readonly open: boolean;
1156 readonly query: string;
1157}
1158
1159export type SidebarProjectScopeMenuAction =
1160 | { readonly type: "query-changed"; readonly query: string }
1161 | { readonly type: "open-changed"; readonly open: boolean }
1162 | { readonly type: "project-settings-opened" };
1163
1164export function reduceSidebarProjectScopeMenuState(
1165 state: SidebarProjectScopeMenuState,
1166 action: SidebarProjectScopeMenuAction,
1167): SidebarProjectScopeMenuState {
1168 switch (action.type) {
1169 case "query-changed":
1170 return { ...state, query: action.query };
1171 case "open-changed":
1172 return { open: action.open, query: "" };
1173 case "project-settings-opened":
1174 return { open: false, query: "" };
1175 }
1176}
1177
1178/** The timestamp a working thread's elapsed label counts from: when its
1179 current work started (request time until adoption). Background wakes do
1180 not reset it. Malformed timestamps fall through to the next candidate. */
1181export function resolveWorkingStartedAt(
1182 thread: Pick<SidebarThreadSummary, "latestRun" | "runtime">,
1183): string | null {
1184 return resolveThreadWorkingStartedAt(thread);
1185}
1186
1187export function formatWorkingDurationLabel(elapsedMs: number): string {
1188 const seconds = Number.isFinite(elapsedMs) ? Math.max(0, Math.floor(elapsedMs / 1000)) : 0;
1189 if (seconds < 60) return `${seconds}s`;
1190 const minutes = Math.floor(seconds / 60);
1191 if (minutes < 60) return `${minutes}m`;
1192 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
1193}
1194
1195export function resolveThreadStatusPill(input: {
1196 thread: ThreadStatusInput;
1197}): ThreadStatusPill | null {
1198 const { thread } = input;
1199
1200 if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
1201 return {
1202 label: "Pending Approval",
1203 colorClass: "text-amber-600 dark:text-amber-300/90",
1204 dotClass: "bg-amber-500 dark:bg-amber-300/90",
1205 pulse: false,
1206 };
1207 }
1208
1209 if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
1210 return {
1211 label: "Awaiting Input",
1212 colorClass: "text-indigo-600 dark:text-indigo-300/90",
1213 dotClass: "bg-indigo-500 dark:bg-indigo-300/90",
1214 pulse: false,
1215 };
1216 }
1217
1218 if (thread.runtime?.status === "running" || thread.runtime?.status === "waiting") {
1219 return {
1220 label: "Working",
1221 colorClass: "text-sky-600 dark:text-sky-300/80",
1222 dotClass: "bg-sky-500 dark:bg-sky-300/80",
1223 pulse: true,
1224 };
1225 }
1226
1227 if (
1228 thread.runtime?.status === "preparing" ||
1229 thread.runtime?.status === "starting" ||
1230 thread.runtime?.status === "queued"
1231 ) {
1232 return {
1233 label: "Connecting",
1234 colorClass: "text-sky-600 dark:text-sky-300/80",
1235 dotClass: "bg-sky-500 dark:bg-sky-300/80",
1236 pulse: true,
1237 };
1238 }
1239
1240 if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) {
1241 return {
1242 label: "Waiting",
1243 colorClass: "text-sidebar-muted-foreground",
1244 dotClass: "bg-sidebar-muted-foreground",
1245 pulse: false,
1246 };
1247 }
1248
1249 const hasPlanReadyPrompt =
1250 !thread.hasPendingUserInput &&
1251 thread.interactionMode === "plan" &&
1252 isLatestRunSettled(thread.latestRun, thread.runtime) &&
1253 thread.hasActionableProposedPlan;
1254 if (hasPlanReadyPrompt) {
1255 return {
1256 label: "Plan Ready",
1257 colorClass: "text-violet-600 dark:text-violet-300/90",
1258 dotClass: "bg-violet-500 dark:bg-violet-300/90",
1259 pulse: false,
1260 };
1261 }
1262
1263 if (hasUnseenCompletion(thread)) {
1264 return {
1265 label: "Completed",
1266 colorClass: "text-emerald-600 dark:text-emerald-300/90",
1267 dotClass: "bg-emerald-500 dark:bg-emerald-300/90",
1268 pulse: false,
1269 };
1270 }
1271
1272 return null;
1273}
1274
1275export function resolveProjectStatusIndicator(
1276 statuses: ReadonlyArray<ThreadStatusPill | null>,
1277): ThreadStatusPill | null {
1278 let highestPriorityStatus: ThreadStatusPill | null = null;
1279
1280 for (const status of statuses) {
1281 if (status === null) continue;
1282 if (
1283 highestPriorityStatus === null ||
1284 THREAD_STATUS_PRIORITY[status.label] > THREAD_STATUS_PRIORITY[highestPriorityStatus.label]
1285 ) {
1286 highestPriorityStatus = status;
1287 }
1288 }
1289
1290 return highestPriorityStatus;
1291}
1292
1293export function getFallbackThreadIdAfterDelete<
1294 T extends Pick<Thread, "id" | "projectId" | "createdAt" | "updatedAt"> & ThreadSortInput,
1295>(input: {
1296 threads: readonly T[];
1297 deletedThreadId: T["id"];
1298 sortOrder: SidebarThreadSortOrder;
1299 deletedThreadIds?: ReadonlySet<T["id"]>;
1300}): T["id"] | null {
1301 const { deletedThreadId, deletedThreadIds, sortOrder, threads } = input;
1302 const deletedThread = threads.find((thread) => thread.id === deletedThreadId);
1303 if (!deletedThread) {
1304 return null;
1305 }
1306
1307 return (
1308 sortThreads(
1309 threads.filter(
1310 (thread) =>
1311 thread.projectId === deletedThread.projectId &&
1312 thread.id !== deletedThreadId &&
1313 !deletedThreadIds?.has(thread.id),
1314 ),
1315 sortOrder,
1316 )[0]?.id ?? null
1317 );
1318}
1319export function getProjectSortTimestamp(
1320 project: SidebarProject,
1321 projectThreads: readonly ThreadSortInput[],
1322 sortOrder: Exclude<SidebarProjectSortOrder, "manual">,
1323): number {
1324 if (projectThreads.length > 0) {
1325 return projectThreads.reduce(
1326 (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)),
1327 Number.NEGATIVE_INFINITY,
1328 );
1329 }
1330
1331 if (sortOrder === "created_at") {
1332 return toSortableTimestamp(project.createdAt) ?? Number.NEGATIVE_INFINITY;
1333 }
1334 return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY;
1335}
1336
1337function sortProjectsByActivity<TProject extends SidebarProject>(
1338 projects: readonly TProject[],
1339 sortOrder: SidebarProjectSortOrder,
1340 getProjectThreads: (project: TProject) => readonly ThreadSortInput[],
1341 compareTies: (left: TProject, right: TProject) => number,
1342): TProject[] {
1343 if (sortOrder === "manual") {
1344 return [...projects];
1345 }
1346
1347 // Each project's timestamp walks all of its threads, so compute it once
1348 // per project instead of once per comparison.
1349 return projects
1350 .map((project) => ({
1351 project,
1352 timestamp: getProjectSortTimestamp(project, getProjectThreads(project), sortOrder),
1353 }))
1354 .sort((left, right) => {
1355 const byTimestamp =
1356 right.timestamp === left.timestamp ? 0 : right.timestamp > left.timestamp ? 1 : -1;
1357 return byTimestamp || compareTies(left.project, right.project);
1358 })
1359 .map(({ project }) => project);
1360}
1361
1362export function sortProjectsForSidebar<
1363 TProject extends SidebarProject,
1364 TThread extends Pick<Thread, "projectId" | "createdAt" | "updatedAt"> & ThreadSortInput,
1365>(
1366 projects: readonly TProject[],
1367 threads: readonly TThread[],
1368 sortOrder: SidebarProjectSortOrder,
1369): TProject[] {
1370 const threadsByProjectId = new Map<string, TThread[]>();
1371 for (const thread of threads) {
1372 const existing = threadsByProjectId.get(thread.projectId) ?? [];
1373 existing.push(thread);
1374 threadsByProjectId.set(thread.projectId, existing);
1375 }
1376
1377 return sortProjectsByActivity(
1378 projects,
1379 sortOrder,
1380 (project) => threadsByProjectId.get(project.id) ?? [],
1381 (left, right) => left.title.localeCompare(right.title) || left.id.localeCompare(right.id),
1382 );
1383}
1384
1385export function sortLogicalProjectsForSidebar<
1386 TProject extends LogicalSidebarProject,
1387 TThread extends ScopedSidebarThread,
1388>(
1389 projects: readonly TProject[],
1390 threads: readonly TThread[],
1391 sortOrder: SidebarProjectSortOrder,
1392): TProject[] {
1393 const groupKeyByProjectRef = new Map(
1394 projects.flatMap((project) =>
1395 project.memberProjectRefs.map(
1396 (projectRef) =>
1397 [`${projectRef.environmentId}\0${projectRef.projectId}`, project.projectKey] as const,
1398 ),
1399 ),
1400 );
1401 const threadsByProjectKey = new Map<string, TThread[]>();
1402 for (const thread of threads) {
1403 if (thread.archivedAt !== null) continue;
1404 const projectKey = groupKeyByProjectRef.get(`${thread.environmentId}\0${thread.projectId}`);
1405 if (!projectKey) continue;
1406 const existing = threadsByProjectKey.get(projectKey);
1407 if (existing) {
1408 existing.push(thread);
1409 } else {
1410 threadsByProjectKey.set(projectKey, [thread]);
1411 }
1412 }
1413
1414 return sortProjectsByActivity(
1415 projects,
1416 sortOrder,
1417 (project) => threadsByProjectKey.get(project.projectKey) ?? [],
1418 (left, right) =>
1419 left.title.localeCompare(right.title) || left.projectKey.localeCompare(right.projectKey),
1420 );
1421}
1422
1423export function sortSidebarV2ProjectGroups<
1424 TProject extends LogicalSidebarProject,
1425 TThread extends ScopedSidebarThread & Pick<SidebarThreadSummary, "lineage">,
1426>(
1427 projects: readonly TProject[],
1428 threads: readonly TThread[],
1429 sortOrder: SidebarProjectSortOrder,
1430): TProject[] {
1431 return sortLogicalProjectsForSidebar(
1432 projects,
1433 filterSidebarV2VisibleThreads(threads, null),
1434 sortOrder,
1435 );
1436}
1437
1438/**
1439 * Sorts the cross-environment project collection used by landing surfaces.
1440 * Project ids are only unique within an environment, and archived threads
1441 * must not make a project appear recently active.
1442 */
1443export function sortScopedProjectsForSidebar<
1444 TProject extends ScopedSidebarProject,
1445 TThread extends ScopedSidebarThread,
1446>(
1447 projects: readonly TProject[],
1448 threads: readonly TThread[],
1449 sortOrder: SidebarProjectSortOrder,
1450): TProject[] {
1451 const scopedKey = (environmentId: string, projectId: string) =>
1452 `${environmentId}\u0000${projectId}`;
1453 const threadsByProject = new Map<string, TThread[]>();
1454 for (const thread of threads) {
1455 if (thread.archivedAt !== null) {
1456 continue;
1457 }
1458 const key = scopedKey(thread.environmentId, thread.projectId);
1459 const existing = threadsByProject.get(key) ?? [];
1460 existing.push(thread);
1461 threadsByProject.set(key, existing);
1462 }
1463
1464 return sortProjectsByActivity(
1465 projects,
1466 sortOrder,
1467 (project) => threadsByProject.get(scopedKey(project.environmentId, project.id)) ?? [],
1468 (left, right) =>
1469 left.title.localeCompare(right.title) ||
1470 left.environmentId.localeCompare(right.environmentId) ||
1471 left.id.localeCompare(right.id),
1472 );
1473}