import * as React from "react"; import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { getThreadSortTimestamp, sortThreads, toSortableTimestamp, type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; import type { ThreadRouteTarget } from "../threadRoutes"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; type SidebarProject = { id: string; title: string; createdAt?: string | undefined; updatedAt?: string | undefined; }; type ScopedSidebarProject = SidebarProject & { environmentId: string; }; type ScopedSidebarThread = ThreadSortInput & { environmentId: string; projectId: string; archivedAt: string | null; }; type LogicalSidebarProject = SidebarProject & { projectKey: string; memberProjectRefs: readonly { environmentId: string; projectId: string; }[]; }; export type ThreadTraversalDirection = "previous" | "next"; export async function archiveSelectedThreadEntries< TEntry extends { readonly threadKey: string }, TResult extends { readonly _tag: "Success" | "Failure" }, >(input: { entries: readonly TEntry[]; archive: (entry: TEntry, onArchived: () => void) => Promise; }): Promise<{ archivedThreadKeys: readonly string[]; mutationFailure: Extract | null; followupFailures: readonly Extract[]; }> { const archivedThreadKeys: string[] = []; const followupFailures: Extract[] = []; for (const entry of input.entries) { let didArchive = false; const result = await input.archive(entry, () => { didArchive = true; }); if (didArchive || result._tag === "Success") { archivedThreadKeys.push(entry.threadKey); } if (result._tag === "Success") continue; const failure = result as Extract; if (didArchive) { followupFailures.push(failure); continue; } return { archivedThreadKeys, mutationFailure: failure, followupFailures }; } return { archivedThreadKeys, mutationFailure: null, followupFailures }; } export function buildMultiSelectThreadContextMenuItems(input: { count: number; hasRunningThread: boolean; }): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] { return [ { id: "mark-unread", label: `Mark unread (${input.count})` }, { id: "archive", label: `Archive (${input.count})`, disabled: input.hasRunningThread, }, { id: "delete", label: `Delete (${input.count})`, destructive: true }, ]; } export interface ThreadStatusPill { label: | "Working" | "Connecting" | "Completed" | "Pending Approval" | "Awaiting Input" | "Plan Ready"; colorClass: string; dotClass: string; pulse: boolean; } const THREAD_STATUS_PRIORITY: Record = { "Pending Approval": 5, "Awaiting Input": 4, Working: 3, Connecting: 3, "Plan Ready": 2, Completed: 1, }; type ThreadStatusInput = Pick< SidebarThreadSummary, | "hasActionableProposedPlan" | "hasPendingApprovals" | "hasPendingUserInput" | "interactionMode" | "latestTurn" | "session" > & { lastVisitedAt?: string | undefined; }; export interface ThreadJumpHintVisibilityController { sync: (shouldShow: boolean) => void; dispose: () => void; } export function resolveSidebarStageBadgeLabel(input: { primaryServerVersion: string | null | undefined; fallbackStageLabel: string; }): string { return resolveServerBackedAppStageLabel(input); } export function createThreadJumpHintVisibilityController(input: { delayMs: number; onVisibilityChange: (visible: boolean) => void; setTimeoutFn?: typeof globalThis.setTimeout; clearTimeoutFn?: typeof globalThis.clearTimeout; }): ThreadJumpHintVisibilityController { const setTimeoutFn = input.setTimeoutFn ?? globalThis.setTimeout; const clearTimeoutFn = input.clearTimeoutFn ?? globalThis.clearTimeout; let isVisible = false; let timeoutId: NodeJS.Timeout | null = null; const clearPendingShow = () => { if (timeoutId === null) { return; } clearTimeoutFn(timeoutId); timeoutId = null; }; return { sync: (shouldShow) => { if (!shouldShow) { clearPendingShow(); if (isVisible) { isVisible = false; input.onVisibilityChange(false); } return; } if (isVisible || timeoutId !== null) { return; } timeoutId = setTimeoutFn(() => { timeoutId = null; isVisible = true; input.onVisibilityChange(true); }, input.delayMs); }, dispose: () => { clearPendingShow(); }, }; } export function useThreadJumpHintVisibility(): { showThreadJumpHints: boolean; updateThreadJumpHintsVisibility: (shouldShow: boolean) => void; } { const [showThreadJumpHints, setShowThreadJumpHints] = React.useState(false); const controllerRef = React.useRef(null); React.useEffect(() => { const controller = createThreadJumpHintVisibilityController({ delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS, onVisibilityChange: (visible) => { setShowThreadJumpHints(visible); }, setTimeoutFn: window.setTimeout.bind(window), clearTimeoutFn: window.clearTimeout.bind(window), }); controllerRef.current = controller; return () => { controller.dispose(); controllerRef.current = null; }; }, []); const updateThreadJumpHintsVisibility = React.useCallback((shouldShow: boolean) => { controllerRef.current?.sync(shouldShow); }, []); return { showThreadJumpHints, updateThreadJumpHintsVisibility, }; } export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { if (!thread.latestTurn?.completedAt) return false; const completedAt = Date.parse(thread.latestTurn.completedAt); if (Number.isNaN(completedAt)) return false; if (!thread.lastVisitedAt) return false; const lastVisitedAt = Date.parse(thread.lastVisitedAt); if (Number.isNaN(lastVisitedAt)) return true; return completedAt > lastVisitedAt; } export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { if (target === null) return true; return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); } // A double-click dispatches two `click` events before `dblclick`: the first has // `detail === 1`, the second `detail === 2`. The second click must not run the // row's single-click navigation, otherwise double-click-to-rename would also // navigate. `MouseEvent.detail` is 0 for synthetic/keyboard activations, which // still count as a normal single activation. export function isTrailingDoubleClick(detail: number): boolean { return detail > 1; } export function orderItemsByPreferredIds(input: { items: readonly TItem[]; preferredIds: readonly TId[]; getId: (item: TItem) => TId; getPreferenceIds?: (item: TItem) => readonly TId[]; }): TItem[] { const { getId, getPreferenceIds, items, preferredIds } = input; if (preferredIds.length === 0) { return [...items]; } const indexesByPreferenceId = new Map(); for (const [index, item] of items.entries()) { const preferenceIds = getPreferenceIds?.(item) ?? [getId(item)]; for (const preferenceId of new Set(preferenceIds)) { const indexes = indexesByPreferenceId.get(preferenceId); if (indexes) { indexes.push(index); } else { indexesByPreferenceId.set(preferenceId, [index]); } } } const emittedIndexes = new Set(); const ordered = preferredIds.flatMap((id) => { const index = indexesByPreferenceId .get(id) ?.find((candidate) => !emittedIndexes.has(candidate)); if (index === undefined) { return []; } emittedIndexes.add(index); return [items[index]!]; }); const remaining = items.filter((_, index) => !emittedIndexes.has(index)); return [...ordered, ...remaining]; } export function getVisibleSidebarThreadIds( renderedProjects: readonly { shouldShowThreadPanel?: boolean; renderedThreadIds: readonly TThreadId[]; }[], ): TThreadId[] { return renderedProjects.flatMap((renderedProject) => renderedProject.shouldShowThreadPanel === false ? [] : renderedProject.renderedThreadIds, ); } export function getSidebarThreadIdsToPrewarm( visibleThreadIds: readonly TThreadId[], limit = SIDEBAR_THREAD_PREWARM_LIMIT, ): TThreadId[] { return visibleThreadIds.slice(0, Math.max(0, limit)); } export function resolveAdjacentThreadId(input: { threadIds: readonly T[]; currentThreadId: T | null; direction: ThreadTraversalDirection; }): T | null { const { currentThreadId, direction, threadIds } = input; if (threadIds.length === 0) { return null; } if (currentThreadId === null) { return direction === "previous" ? (threadIds.at(-1) ?? null) : (threadIds[0] ?? null); } const currentIndex = threadIds.indexOf(currentThreadId); if (currentIndex === -1) { return null; } if (direction === "previous") { return currentIndex > 0 ? (threadIds[currentIndex - 1] ?? null) : null; } return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null; } export function shouldNavigateAfterProjectRemoval(input: { routeTarget: ThreadRouteTarget | null; projectThreads: readonly { environmentId: string; id: string; }[]; projectDraftId: string | null; }): boolean { const { projectDraftId, projectThreads, routeTarget } = input; if (routeTarget?.kind === "draft") { return projectDraftId === routeTarget.draftId; } if (routeTarget?.kind !== "server") { return false; } return projectThreads.some( (thread) => thread.environmentId === routeTarget.threadRef.environmentId && thread.id === routeTarget.threadRef.threadId, ); } export function isContextMenuPointerDown(input: { button: number; ctrlKey: boolean; isMac: boolean; }): boolean { if (input.button === 2) return true; return input.isMac && input.button === 0 && input.ctrlKey; } export function resolveThreadRowClassName(input: { isActive: boolean; isSelected: boolean; }): string { const baseClassName = "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"; if (input.isSelected && input.isActive) { return cn( baseClassName, "bg-sidebar-row-active text-sidebar-foreground font-medium hover:bg-sidebar-row-active hover:text-sidebar-foreground", ); } if (input.isSelected) { return cn( baseClassName, "bg-sidebar-row-selected text-sidebar-foreground hover:bg-sidebar-row-active hover:text-sidebar-foreground", ); } if (input.isActive) { return cn( baseClassName, "bg-sidebar-row-active text-sidebar-foreground font-medium hover:bg-sidebar-row-active hover:text-sidebar-foreground", ); } return cn( baseClassName, "text-sidebar-muted-foreground/80 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", ); } // ── Sidebar v2 status model ───────────────────────────────────────── // Five visual states, three colors: color is reserved for "act now" // (approval), "in motion" (working), and "broken" (failed). Ready is the // unlabeled resting state — the agent stopped and is waiting on the user, // whether it finished, asked a question, or proposed a plan. // Unread completion is tracked separately: it describes whether a ready // thread needs attention, not what the thread is currently doing. export type SidebarV2Status = "approval" | "input" | "working" | "failed" | "ready"; type SidebarV2StatusInput = Pick< SidebarThreadSummary, "hasPendingApprovals" | "hasPendingUserInput" | "session" >; export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status { if (thread.hasPendingApprovals) { return "approval"; } if (thread.hasPendingUserInput) { return "input"; } if (thread.session?.status === "running" || thread.session?.status === "starting") { return "working"; } if (thread.session?.status === "error") { return "failed"; } return "ready"; } /** NaN-safe Date.parse for sort comparators: a malformed timestamp must not poison the whole ordering, so it sinks to the epoch instead. */ export function parseTimestampMs(isoDate: string): number { const parsed = Date.parse(isoDate); return Number.isNaN(parsed) ? 0 : parsed; } /** First VALID timestamp wins: `a ?? b` falls through on null, but a present- yet-malformed string must also fall through to the next candidate rather than sink the row to the epoch. */ export function firstValidTimestampMs( ...candidates: ReadonlyArray ): number { for (const candidate of candidates) { if (candidate == null) continue; const parsed = Date.parse(candidate); if (!Number.isNaN(parsed)) return parsed; } return 0; } /** String twin of firstValidTimestampMs for callers that need the ISO string (display labels, tick anchors) rather than epoch ms. */ export function firstValidTimestamp( ...candidates: ReadonlyArray ): string | null { for (const candidate of candidates) { if (candidate == null) continue; if (!Number.isNaN(Date.parse(candidate))) return candidate; } return null; } // v2 sort: static creation order, newest thread on top. Activity NEVER // reorders the list — a row holds its position from open until settled, so // the screen only moves at lifecycle transitions. Status (including pending // approval) is carried by each card's edge strip, not by position. export function sortThreadsForSidebarV2< T extends { readonly id: string; readonly createdAt: string }, >(threads: readonly T[]): T[] { return [...threads].toSorted( (left, right) => parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || left.id.localeCompare(right.id), ); } type SettledTimestampInput = Pick< SidebarThreadSummary, "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" >; /** The timestamp a settled row sorts and labels by: settledAt when stamped (explicit settles), otherwise last activity — the same candidates threadLastActivityAt feeds the auto-settle window (user message plus all latestTurn stamps), so a thread whose last activity was a turn completion doesn't sort by an older message time. updatedAt is the final net. */ export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null { const settledAt = firstValidTimestamp(thread.settledAt); if (settledAt !== null) return settledAt; let latest: string | null = null; let latestMs = Number.NEGATIVE_INFINITY; for (const candidate of [ thread.latestUserMessageAt, thread.latestTurn?.requestedAt, thread.latestTurn?.startedAt, thread.latestTurn?.completedAt, ]) { if (candidate == null) continue; const parsed = Date.parse(candidate); if (!Number.isNaN(parsed) && parsed > latestMs) { latest = candidate; latestMs = parsed; } } return latest ?? firstValidTimestamp(thread.updatedAt); } // Settled rows are history, so they order by when the work ENDED, not when // the thread was created or last touched. export function sortSettledThreadsForSidebarV2< T extends SettledTimestampInput & { readonly id: string }, >(threads: readonly T[]): T[] { const timestampMs = (thread: T) => { const timestamp = resolveSettledTimestamp(thread); return timestamp === null ? 0 : Date.parse(timestamp); }; return [...threads].toSorted( (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id), ); } /** The timestamp a working thread's elapsed label counts from: the running turn's start (request time until adoption), falling back to the session's last transition when the turn projection lags behind. Malformed timestamps fall through to the next candidate, not just missing ones. */ export function resolveWorkingStartedAt( thread: Pick, ): string | null { const turn = thread.latestTurn; if (turn && turn.completedAt === null) { return firstValidTimestamp(turn.startedAt, turn.requestedAt, thread.session?.updatedAt); } return firstValidTimestamp(thread.session?.updatedAt); } export function formatWorkingDurationLabel(elapsedMs: number): string { const seconds = Number.isFinite(elapsedMs) ? Math.max(0, Math.floor(elapsedMs / 1000)) : 0; if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m`; return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; } export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; }): ThreadStatusPill | null { const { thread } = input; if (thread.hasPendingApprovals) { return { label: "Pending Approval", colorClass: "text-amber-600 dark:text-amber-300/90", dotClass: "bg-amber-500 dark:bg-amber-300/90", pulse: false, }; } if (thread.hasPendingUserInput) { return { label: "Awaiting Input", colorClass: "text-indigo-600 dark:text-indigo-300/90", dotClass: "bg-indigo-500 dark:bg-indigo-300/90", pulse: false, }; } if (thread.session?.status === "running") { return { label: "Working", colorClass: "text-sky-600 dark:text-sky-300/80", dotClass: "bg-sky-500 dark:bg-sky-300/80", pulse: true, }; } if (thread.session?.status === "starting") { return { label: "Connecting", colorClass: "text-sky-600 dark:text-sky-300/80", dotClass: "bg-sky-500 dark:bg-sky-300/80", pulse: true, }; } const hasPlanReadyPrompt = !thread.hasPendingUserInput && thread.interactionMode === "plan" && isLatestTurnSettled(thread.latestTurn, thread.session) && thread.hasActionableProposedPlan; if (hasPlanReadyPrompt) { return { label: "Plan Ready", colorClass: "text-violet-600 dark:text-violet-300/90", dotClass: "bg-violet-500 dark:bg-violet-300/90", pulse: false, }; } if (hasUnseenCompletion(thread)) { return { label: "Completed", colorClass: "text-emerald-600 dark:text-emerald-300/90", dotClass: "bg-emerald-500 dark:bg-emerald-300/90", pulse: false, }; } return null; } export function resolveProjectStatusIndicator( statuses: ReadonlyArray, ): ThreadStatusPill | null { let highestPriorityStatus: ThreadStatusPill | null = null; for (const status of statuses) { if (status === null) continue; if ( highestPriorityStatus === null || THREAD_STATUS_PRIORITY[status.label] > THREAD_STATUS_PRIORITY[highestPriorityStatus.label] ) { highestPriorityStatus = status; } } return highestPriorityStatus; } export function getVisibleThreadsForProject>(input: { threads: readonly T[]; activeThreadId: T["id"] | undefined; isThreadListExpanded: boolean; previewLimit: number; }): { hasHiddenThreads: boolean; visibleThreads: T[]; hiddenThreads: T[]; } { const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input; const hasHiddenThreads = threads.length > previewLimit; if (!hasHiddenThreads || isThreadListExpanded) { return { hasHiddenThreads, hiddenThreads: [], visibleThreads: [...threads], }; } const previewThreads = threads.slice(0, previewLimit); if (!activeThreadId || previewThreads.some((thread) => thread.id === activeThreadId)) { return { hasHiddenThreads: true, hiddenThreads: threads.slice(previewLimit), visibleThreads: previewThreads, }; } const activeThread = threads.find((thread) => thread.id === activeThreadId); if (!activeThread) { return { hasHiddenThreads: true, hiddenThreads: threads.slice(previewLimit), visibleThreads: previewThreads, }; } const visibleThreadIds = new Set([...previewThreads, activeThread].map((thread) => thread.id)); return { hasHiddenThreads: true, hiddenThreads: threads.filter((thread) => !visibleThreadIds.has(thread.id)), visibleThreads: threads.filter((thread) => visibleThreadIds.has(thread.id)), }; } export function getFallbackThreadIdAfterDelete< T extends Pick & ThreadSortInput, >(input: { threads: readonly T[]; deletedThreadId: T["id"]; sortOrder: SidebarThreadSortOrder; deletedThreadIds?: ReadonlySet; }): T["id"] | null { const { deletedThreadId, deletedThreadIds, sortOrder, threads } = input; const deletedThread = threads.find((thread) => thread.id === deletedThreadId); if (!deletedThread) { return null; } return ( sortThreads( threads.filter( (thread) => thread.projectId === deletedThread.projectId && thread.id !== deletedThreadId && !deletedThreadIds?.has(thread.id), ), sortOrder, )[0]?.id ?? null ); } export function getProjectSortTimestamp( project: SidebarProject, projectThreads: readonly ThreadSortInput[], sortOrder: Exclude, ): number { if (projectThreads.length > 0) { return projectThreads.reduce( (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)), Number.NEGATIVE_INFINITY, ); } if (sortOrder === "created_at") { return toSortableTimestamp(project.createdAt) ?? Number.NEGATIVE_INFINITY; } return toSortableTimestamp(project.updatedAt ?? project.createdAt) ?? Number.NEGATIVE_INFINITY; } function sortProjectsByActivity( projects: readonly TProject[], sortOrder: SidebarProjectSortOrder, getProjectThreads: (project: TProject) => readonly ThreadSortInput[], compareTies: (left: TProject, right: TProject) => number, ): TProject[] { if (sortOrder === "manual") { return [...projects]; } return [...projects].toSorted((left, right) => { const rightTimestamp = getProjectSortTimestamp(right, getProjectThreads(right), sortOrder); const leftTimestamp = getProjectSortTimestamp(left, getProjectThreads(left), sortOrder); const byTimestamp = rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; return byTimestamp || compareTies(left, right); }); } export function sortProjectsForSidebar< TProject extends SidebarProject, TThread extends Pick & ThreadSortInput, >( projects: readonly TProject[], threads: readonly TThread[], sortOrder: SidebarProjectSortOrder, ): TProject[] { const threadsByProjectId = new Map(); for (const thread of threads) { const existing = threadsByProjectId.get(thread.projectId) ?? []; existing.push(thread); threadsByProjectId.set(thread.projectId, existing); } return sortProjectsByActivity( projects, sortOrder, (project) => threadsByProjectId.get(project.id) ?? [], (left, right) => left.title.localeCompare(right.title) || left.id.localeCompare(right.id), ); } export function sortLogicalProjectsForSidebar< TProject extends LogicalSidebarProject, TThread extends ScopedSidebarThread, >( projects: readonly TProject[], threads: readonly TThread[], sortOrder: SidebarProjectSortOrder, ): TProject[] { const groupKeyByProjectRef = new Map( projects.flatMap((project) => project.memberProjectRefs.map( (projectRef) => [`${projectRef.environmentId}\0${projectRef.projectId}`, project.projectKey] as const, ), ), ); const threadsByProjectKey = new Map(); for (const thread of threads) { if (thread.archivedAt !== null) continue; const projectKey = groupKeyByProjectRef.get(`${thread.environmentId}\0${thread.projectId}`); if (!projectKey) continue; const existing = threadsByProjectKey.get(projectKey); if (existing) { existing.push(thread); } else { threadsByProjectKey.set(projectKey, [thread]); } } return sortProjectsByActivity( projects, sortOrder, (project) => threadsByProjectKey.get(project.projectKey) ?? [], (left, right) => left.title.localeCompare(right.title) || left.projectKey.localeCompare(right.projectKey), ); } /** * Sorts the cross-environment project collection used by landing surfaces. * Project ids are only unique within an environment, and archived threads * must not make a project appear recently active. */ export function sortScopedProjectsForSidebar< TProject extends ScopedSidebarProject, TThread extends ScopedSidebarThread, >( projects: readonly TProject[], threads: readonly TThread[], sortOrder: SidebarProjectSortOrder, ): TProject[] { const scopedKey = (environmentId: string, projectId: string) => `${environmentId}\u0000${projectId}`; const threadsByProject = new Map(); for (const thread of threads) { if (thread.archivedAt !== null) { continue; } const key = scopedKey(thread.environmentId, thread.projectId); const existing = threadsByProject.get(key) ?? []; existing.push(thread); threadsByProject.set(key, existing); } return sortProjectsByActivity( projects, sortOrder, (project) => threadsByProject.get(scopedKey(project.environmentId, project.id)) ?? [], (left, right) => left.title.localeCompare(right.title) || left.environmentId.localeCompare(right.environmentId) || left.id.localeCompare(right.id), ); }