import * as React from "react"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { getThreadSortTimestamp, sortThreads, toSortableTimestamp, type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-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; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; name: string; createdAt?: string | undefined; updatedAt?: string | undefined; }; export type ThreadTraversalDirection = "previous" | "next"; 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 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 hasUnseenCompletionSinceVisit(input: { completedAt: string | null | undefined; lastVisitedAt?: string | null | undefined; }): boolean { if (!input.completedAt) return false; const completedAt = Date.parse(input.completedAt); if (Number.isNaN(completedAt)) return false; if (!input.lastVisitedAt) return true; const lastVisitedAt = Date.parse(input.lastVisitedAt); if (Number.isNaN(lastVisitedAt)) return true; return completedAt > lastVisitedAt; } export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { return hasUnseenCompletionSinceVisit({ completedAt: thread.latestTurn?.completedAt, lastVisitedAt: thread.lastVisitedAt, }); } export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { if (target === null) return true; return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); } export function resolveSidebarNewThreadEnvMode(input: { requestedEnvMode?: SidebarNewThreadEnvMode; defaultEnvMode: SidebarNewThreadEnvMode; }): SidebarNewThreadEnvMode { return input.requestedEnvMode ?? input.defaultEnvMode; } export function resolveSidebarNewThreadSeedContext(input: { projectId: string; defaultEnvMode: SidebarNewThreadEnvMode; activeThread?: { projectId: string; branch: string | null; worktreePath: string | null; } | null; activeDraftThread?: { projectId: string; branch: string | null; worktreePath: string | null; envMode: SidebarNewThreadEnvMode; } | null; }): { branch?: string | null; worktreePath?: string | null; envMode: SidebarNewThreadEnvMode; } { if (input.defaultEnvMode === "worktree") { return { envMode: "worktree", }; } if (input.activeDraftThread?.projectId === input.projectId) { return { branch: input.activeDraftThread.branch, worktreePath: input.activeDraftThread.worktreePath, envMode: input.activeDraftThread.envMode, }; } if (input.activeThread?.projectId === input.projectId) { return { branch: input.activeThread.branch, worktreePath: input.activeThread.worktreePath, envMode: input.activeThread.worktreePath ? "worktree" : "local", }; } return { envMode: input.defaultEnvMode, }; } export function orderItemsByPreferredIds(input: { items: readonly TItem[]; preferredIds: readonly TId[]; getId: (item: TItem) => TId; }): TItem[] { const { getId, items, preferredIds } = input; if (preferredIds.length === 0) { return [...items]; } const itemsById = new Map(items.map((item) => [getId(item), item] as const)); const preferredIdSet = new Set(preferredIds); const emittedPreferredIds = new Set(); const ordered = preferredIds.flatMap((id) => { if (emittedPreferredIds.has(id)) { return []; } const item = itemsById.get(id); if (!item) { return []; } emittedPreferredIds.add(id); return [item]; }); const remaining = items.filter((item) => !preferredIdSet.has(getId(item))); 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 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-6 w-full translate-x-0 cursor-pointer justify-start px-2 text-left select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring sm:h-7"; if (input.isSelected && input.isActive) { return cn( baseClassName, "bg-primary/22 text-foreground font-medium hover:bg-primary/26 hover:text-foreground dark:bg-primary/30 dark:hover:bg-primary/36", ); } if (input.isSelected) { return cn( baseClassName, "bg-primary/15 text-foreground hover:bg-primary/19 hover:text-foreground dark:bg-primary/22 dark:hover:bg-primary/28", ); } if (input.isActive) { return cn( baseClassName, "bg-accent/85 text-foreground font-medium hover:bg-accent hover:text-foreground dark:bg-accent/55 dark:hover:bg-accent/70", ); } return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground"); } 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 === "connecting") { 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; } export function sortProjectsForSidebar< TProject extends SidebarProject, TThread extends Pick & ThreadSortInput, >( projects: readonly TProject[], threads: readonly TThread[], sortOrder: SidebarProjectSortOrder, ): TProject[] { if (sortOrder === "manual") { return [...projects]; } const threadsByProjectId = new Map(); for (const thread of threads) { const existing = threadsByProjectId.get(thread.projectId) ?? []; existing.push(thread); threadsByProjectId.set(thread.projectId, existing); } return [...projects].toSorted((left, right) => { const rightTimestamp = getProjectSortTimestamp( right, threadsByProjectId.get(right.id) ?? [], sortOrder, ); const leftTimestamp = getProjectSortTimestamp( left, threadsByProjectId.get(left.id) ?? [], sortOrder, ); const byTimestamp = rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; if (byTimestamp !== 0) return byTimestamp; return left.name.localeCompare(right.name) || left.id.localeCompare(right.id); }); }