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<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 interface ThreadStatusPill { label: | "Working" | "Connecting" | "Completed" | "Pending Approval" | "Awaiting Input" | "Plan Ready"; colorClass: string; dotClass: string; pulse: boolean;} const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = { "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<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;} 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 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<string | null | undefined>): 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 | undefined>): 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; }Lines 1–500 of 833. Load more
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<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 interface ThreadStatusPill { label: | "Working" | "Connecting" | "Completed" | "Pending Approval" | "Awaiting Input" | "Plan Ready"; colorClass: string; dotClass: string; pulse: boolean; } const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = { "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<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; } 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 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<string | null | undefined> ): 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 | undefined> ): 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; }