import * as React from "react";import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";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 = 200;// Visible sidebar rows are prewarmed into the thread-detail cache so opening a// nearby thread usually reuses an already-hot subscription. Each prewarmed// thread holds a live, fully hydrated detail subscription (all messages and// activities, growing as agents work) for as long as the row stays visible,// so this limit is a direct renderer-heap and server-load multiplier — keep// it small; cold opens still render instantly from the cached snapshot.export const SIDEBAR_THREAD_PREWARM_LIMIT = 3; // The list already reaches its destination through sortable transforms while// the pointer is down. dnd-kit's default also animates the committed DOM order// after release, replaying the same movement across every affected row.export const animatePinnedLayoutChanges: AnimateLayoutChanges = (args) => args.isSorting ? defaultAnimateLayoutChanges(args) : false; 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<TResult>;}): Promise<{ archivedThreadKeys: readonly string[]; mutationFailure: Extract<TResult, { readonly _tag: "Failure" }> | null; followupFailures: readonly Extract<TResult, { readonly _tag: "Failure" }>[];}> { const archivedThreadKeys: string[] = []; const followupFailures: Extract<TResult, { readonly _tag: "Failure" }>[] = []; 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<TResult, { readonly _tag: "Failure" }>; 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 function buildBulkTitleRegenerationContextMenuItem(input: { supportedCount: number; actionableCount: number;}): ContextMenuItem<"regenerate-title"> | null { if (input.supportedCount === 0) return null; if (input.actionableCount === 0) { return { id: "regenerate-title", label: `Regenerating… (${input.supportedCount})`, disabled: true, }; } return { id: "regenerate-title", label: `Regenerate titles (${input.actionableCount})`, };} export interface ThreadStatusPill { label: | "Working" | "Monitoring" | "Connecting" | "Completed" | "Pending Approval" | "Awaiting Input" | "Plan Ready"; colorClass: string; dotClass: string; pulse: boolean;} // Rollup order mirrors the per-thread resolver exactly: attention states,// then active work, then the actionable plan prompt, then passive// monitoring. A Monitoring sibling must never hide a Plan Ready thread.const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = { "Pending Approval": 6, "Awaiting Input": 5, Working: 4, Connecting: 4, "Plan Ready": 3, Monitoring: 2, Completed: 1,}; type ThreadStatusInput = Pick< SidebarThreadSummary, | "hasActionableProposedPlan" | "hasPendingApprovals" | "hasPendingUserInput" | "interactionMode" | "latestTurn" | "session" | "backgroundLiveness"> & { 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<ThreadJumpHintVisibilityController | null>(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;} function nodeClosest(node: object | null, selector: string): unknown { if (node === null || !("closest" in node) || typeof node.closest !== "function") return null; return node.closest(selector);} /** Clicks on a nested link keep the link's meaning. The row must not treat them as multi-select. */export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { if (target == null || typeof target !== "object") return false; if (nodeClosest(target, "a[href]") !== null) return true; const parent = "parentElement" in target && target.parentElement !== null && typeof target.parentElement === "object" ? target.parentElement : null; return nodeClosest(parent, "a[href]") !== null;} // Shift+click on the new thread button creates directly in the current// project, skipping the command palette's project picker. With a single// project there is nothing to pick, so a plain click already creates// immediately and the modifier changes nothing.export function shouldCreateNewThreadInCurrentProject( shiftKey: boolean, projectGroupCount: number,): boolean { return shiftKey || projectGroupCount <= 1;} export function orderItemsByPreferredIds<TItem, TId>(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<TId, number[]>(); 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<number>(); 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<TThreadId>( renderedProjects: readonly { shouldShowThreadPanel?: boolean; renderedThreadIds: readonly TThreadId[]; }[],): TThreadId[] { return renderedProjects.flatMap((renderedProject) => renderedProject.shouldShowThreadPanel === false ? [] : renderedProject.renderedThreadIds, );} export function getSidebarThreadIdsToPrewarm<TThreadId>( visibleThreadIds: readonly TThreadId[], limit = SIDEBAR_THREAD_PREWARM_LIMIT,): TThreadId[] { return visibleThreadIds.slice(0, Math.max(0, limit));} export function resolveAdjacentThreadId<T>(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 thread 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 SidebarThreadStatus = | "approval" | "input" | "working" | "monitoring" | "failed" | "ready"; type SidebarThreadStatusInput = Pick< SidebarThreadSummary, "hasPendingApprovals" | "hasPendingUserInput" | "session" | "backgroundLiveness">; export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus { if (thread.hasPendingApprovals) { return "approval"; } if (thread.hasPendingUserInput) { return "input"; } if (thread.session?.status === "running" || thread.session?.status === "starting") { return "working"; } // A failed session outranks lingering background liveness: the user must // see the failure, not a stale Working (review finding). if (thread.session?.status === "error") { return "failed"; } // Background work outlives the turn: fleets read as working; monitoring // only when watch loops are the sole live work. if (thread.backgroundLiveness === "working") { return "working";Lines 1–500 of 961. Load more
import * as React from "react"; import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; 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 = 200; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. Each prewarmed // thread holds a live, fully hydrated detail subscription (all messages and // activities, growing as agents work) for as long as the row stays visible, // so this limit is a direct renderer-heap and server-load multiplier — keep // it small; cold opens still render instantly from the cached snapshot. export const SIDEBAR_THREAD_PREWARM_LIMIT = 3; // The list already reaches its destination through sortable transforms while // the pointer is down. dnd-kit's default also animates the committed DOM order // after release, replaying the same movement across every affected row. export const animatePinnedLayoutChanges: AnimateLayoutChanges = (args) => args.isSorting ? defaultAnimateLayoutChanges(args) : false; 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<TResult>; }): Promise<{ archivedThreadKeys: readonly string[]; mutationFailure: Extract<TResult, { readonly _tag: "Failure" }> | null; followupFailures: readonly Extract<TResult, { readonly _tag: "Failure" }>[]; }> { const archivedThreadKeys: string[] = []; const followupFailures: Extract<TResult, { readonly _tag: "Failure" }>[] = []; 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<TResult, { readonly _tag: "Failure" }>; 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 function buildBulkTitleRegenerationContextMenuItem(input: { supportedCount: number; actionableCount: number; }): ContextMenuItem<"regenerate-title"> | null { if (input.supportedCount === 0) return null; if (input.actionableCount === 0) { return { id: "regenerate-title", label: `Regenerating… (${input.supportedCount})`, disabled: true, }; } return { id: "regenerate-title", label: `Regenerate titles (${input.actionableCount})`, }; } export interface ThreadStatusPill { label: | "Working" | "Monitoring" | "Connecting" | "Completed" | "Pending Approval" | "Awaiting Input" | "Plan Ready"; colorClass: string; dotClass: string; pulse: boolean; } // Rollup order mirrors the per-thread resolver exactly: attention states, // then active work, then the actionable plan prompt, then passive // monitoring. A Monitoring sibling must never hide a Plan Ready thread. const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = { "Pending Approval": 6, "Awaiting Input": 5, Working: 4, Connecting: 4, "Plan Ready": 3, Monitoring: 2, Completed: 1, }; type ThreadStatusInput = Pick< SidebarThreadSummary, | "hasActionableProposedPlan" | "hasPendingApprovals" | "hasPendingUserInput" | "interactionMode" | "latestTurn" | "session" | "backgroundLiveness" > & { 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<ThreadJumpHintVisibilityController | null>(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; } function nodeClosest(node: object | null, selector: string): unknown { if (node === null || !("closest" in node) || typeof node.closest !== "function") return null; return node.closest(selector); } /** Clicks on a nested link keep the link's meaning. The row must not treat them as multi-select. */ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { if (target == null || typeof target !== "object") return false; if (nodeClosest(target, "a[href]") !== null) return true; const parent = "parentElement" in target && target.parentElement !== null && typeof target.parentElement === "object" ? target.parentElement : null; return nodeClosest(parent, "a[href]") !== null; } // Shift+click on the new thread button creates directly in the current // project, skipping the command palette's project picker. With a single // project there is nothing to pick, so a plain click already creates // immediately and the modifier changes nothing. export function shouldCreateNewThreadInCurrentProject( shiftKey: boolean, projectGroupCount: number, ): boolean { return shiftKey || projectGroupCount <= 1; } export function orderItemsByPreferredIds<TItem, TId>(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<TId, number[]>(); 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<number>(); 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<TThreadId>( renderedProjects: readonly { shouldShowThreadPanel?: boolean; renderedThreadIds: readonly TThreadId[]; }[], ): TThreadId[] { return renderedProjects.flatMap((renderedProject) => renderedProject.shouldShowThreadPanel === false ? [] : renderedProject.renderedThreadIds, ); } export function getSidebarThreadIdsToPrewarm<TThreadId>( visibleThreadIds: readonly TThreadId[], limit = SIDEBAR_THREAD_PREWARM_LIMIT, ): TThreadId[] { return visibleThreadIds.slice(0, Math.max(0, limit)); } export function resolveAdjacentThreadId<T>(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 thread 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 SidebarThreadStatus = | "approval" | "input" | "working" | "monitoring" | "failed" | "ready"; type SidebarThreadStatusInput = Pick< SidebarThreadSummary, "hasPendingApprovals" | "hasPendingUserInput" | "session" | "backgroundLiveness" >; export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus { if (thread.hasPendingApprovals) { return "approval"; } if (thread.hasPendingUserInput) { return "input"; } if (thread.session?.status === "running" || thread.session?.status === "starting") { return "working"; } // A failed session outranks lingering background liveness: the user must // see the failure, not a stale Working (review finding). if (thread.session?.status === "error") { return "failed"; } // Background work outlives the turn: fleets read as working; monitoring // only when watch loops are the sole live work. if (thread.backgroundLiveness === "working") { return "working";