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 { activeThreadAnchorTimestampMs, getThreadSortTimestamp, resolveSettledThreadTimestamp, sortThreads, toSortableTimestamp, type SettledThreadTimestampInput, type ThreadSortInput,} from "../lib/threadSort";import type { SidebarThreadSummary, Thread } from "../types";import type { ThreadRouteTarget } from "../threadRoutes";import { cn } from "../lib/utils";import { isLatestRunSettled } 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;// A small buffer keeps the next few rows warm without leasing every row that// content-visibility leaves mounted below the scroll viewport.export const SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX = 160; export function useSidebarRowSubscriptionLease(isActive: boolean): { readonly leaseLiveStatus: boolean; readonly rowRef: React.Dispatch<React.SetStateAction<HTMLElement | null>>;} { const [row, setRow] = React.useState<HTMLElement | null>(null); const [isNearViewport, setIsNearViewport] = React.useState(isActive); React.useEffect(() => { if (isActive) { setIsNearViewport(true); return; } if (row === null) return; if (typeof IntersectionObserver === "undefined") { setIsNearViewport(true); return; } const scrollRoot = row.closest<HTMLElement>('[data-slot="scroll-area-viewport"]'); const observer = new IntersectionObserver( ([entry]) => setIsNearViewport(entry?.isIntersecting === true), { root: scrollRoot, rootMargin: `${SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX}px 0px`, }, ); observer.observe(row); return () => observer.disconnect(); }, [isActive, row]); return { leaseLiveStatus: isActive || isNearViewport, rowRef: setRow, };} // A row keeps the last live value it rendered so a released lease never// blanks its badge. The value is bound to `key`, so a different worktree or// linked pull request cannot reuse the previous one.export function useRetainedValue<T>(key: string | null, value: T | null): T | null { const retained = React.useRef<{ readonly key: string; readonly value: T } | null>(null); if (key !== null && value !== null) { retained.current = { key, value }; } if (value !== null) return value; return key !== null && retained.current?.key === key ? retained.current.value : null;} // 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 isSidebarSubagentThread(thread: Pick<SidebarThreadSummary, "lineage">): boolean { return thread.lineage.relationshipToParent === "subagent";} export function filterSidebarV2VisibleThreads< T extends Pick<SidebarThreadSummary, "archivedAt" | "lineage"> & { environmentId: string; projectId: string; },>(threads: readonly T[], scopedProjectKeys: ReadonlySet<string> | null): T[] { return threads.filter( (thread) => thread.archivedAt === null && !isSidebarSubagentThread(thread) && (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), );} export function getSidebarForkParentThreadId( thread: Pick<SidebarThreadSummary, "forkedFrom" | "lineage">,) { if (thread.lineage.relationshipToParent !== "fork") { return null; } return thread.forkedFrom?.type === "run" ? thread.forkedFrom.threadId : thread.lineage.parentThreadId;} 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})`, };} /** * Bulk unpin follows the same "count only what the action will touch" rule * as title regeneration: on a mixed selection the label counts the pinned * rows alone, and the item disappears when nothing selected is pinned. */export function buildBulkUnpinContextMenuItem(input: { pinnedCount: number;}): ContextMenuItem<"unpin"> | null { if (input.pinnedCount === 0) return null; return { id: "unpin", label: `Unpin (${input.pinnedCount})` };} export interface ThreadStatusPill { label: | "Working" | "Connecting" | "Completed" | "Pending Approval" | "Awaiting Input" | "Waiting" | "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, Waiting: 2.5, "Plan Ready": 2, Completed: 1,}; type ThreadStatusInput = Pick< SidebarThreadSummary, | "hasActionableProposedPlan" | "hasPendingApprovals" | "hasPendingUserInput" | "interactionMode" | "latestRun" | "runtime"> & { lastVisitedAt?: string | null | undefined; pendingBackgroundTasks?: SidebarThreadSummary["pendingBackgroundTasks"] | 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, };} /** * Effective visited watermark for a thread. Servers with visited tracking * project `lastVisitedAt` on the shell and are authoritative — that value is * shared across every device connected to the environment. Pre-tracking * servers omit the field, and the browser's locally persisted watermark keeps * working as before. */export function resolveThreadLastVisitedAt( serverLastVisitedAt: string | null | undefined, localLastVisitedAt: string | undefined,): string | undefined { // When the server tracks visits it is authoritative — including explicit // rewinds from mark-unread, which a newer browser-local watermark must not // mask. The local value only carries servers without visited tracking. if (serverLastVisitedAt === undefined) return localLastVisitedAt; return serverLastVisitedAt ?? undefined;} export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { if (!thread.latestRun?.completedAt) return false; const completedAt = Date.parse(thread.latestRun.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;Lines 1–500 of 1163. 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 { activeThreadAnchorTimestampMs, getThreadSortTimestamp, resolveSettledThreadTimestamp, sortThreads, toSortableTimestamp, type SettledThreadTimestampInput, type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; import type { ThreadRouteTarget } from "../threadRoutes"; import { cn } from "../lib/utils"; import { isLatestRunSettled } 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; // A small buffer keeps the next few rows warm without leasing every row that // content-visibility leaves mounted below the scroll viewport. export const SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX = 160; export function useSidebarRowSubscriptionLease(isActive: boolean): { readonly leaseLiveStatus: boolean; readonly rowRef: React.Dispatch<React.SetStateAction<HTMLElement | null>>; } { const [row, setRow] = React.useState<HTMLElement | null>(null); const [isNearViewport, setIsNearViewport] = React.useState(isActive); React.useEffect(() => { if (isActive) { setIsNearViewport(true); return; } if (row === null) return; if (typeof IntersectionObserver === "undefined") { setIsNearViewport(true); return; } const scrollRoot = row.closest<HTMLElement>('[data-slot="scroll-area-viewport"]'); const observer = new IntersectionObserver( ([entry]) => setIsNearViewport(entry?.isIntersecting === true), { root: scrollRoot, rootMargin: `${SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX}px 0px`, }, ); observer.observe(row); return () => observer.disconnect(); }, [isActive, row]); return { leaseLiveStatus: isActive || isNearViewport, rowRef: setRow, }; } // A row keeps the last live value it rendered so a released lease never // blanks its badge. The value is bound to `key`, so a different worktree or // linked pull request cannot reuse the previous one. export function useRetainedValue<T>(key: string | null, value: T | null): T | null { const retained = React.useRef<{ readonly key: string; readonly value: T } | null>(null); if (key !== null && value !== null) { retained.current = { key, value }; } if (value !== null) return value; return key !== null && retained.current?.key === key ? retained.current.value : null; } // 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 isSidebarSubagentThread(thread: Pick<SidebarThreadSummary, "lineage">): boolean { return thread.lineage.relationshipToParent === "subagent"; } export function filterSidebarV2VisibleThreads< T extends Pick<SidebarThreadSummary, "archivedAt" | "lineage"> & { environmentId: string; projectId: string; }, >(threads: readonly T[], scopedProjectKeys: ReadonlySet<string> | null): T[] { return threads.filter( (thread) => thread.archivedAt === null && !isSidebarSubagentThread(thread) && (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); } export function getSidebarForkParentThreadId( thread: Pick<SidebarThreadSummary, "forkedFrom" | "lineage">, ) { if (thread.lineage.relationshipToParent !== "fork") { return null; } return thread.forkedFrom?.type === "run" ? thread.forkedFrom.threadId : thread.lineage.parentThreadId; } 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})`, }; } /** * Bulk unpin follows the same "count only what the action will touch" rule * as title regeneration: on a mixed selection the label counts the pinned * rows alone, and the item disappears when nothing selected is pinned. */ export function buildBulkUnpinContextMenuItem(input: { pinnedCount: number; }): ContextMenuItem<"unpin"> | null { if (input.pinnedCount === 0) return null; return { id: "unpin", label: `Unpin (${input.pinnedCount})` }; } export interface ThreadStatusPill { label: | "Working" | "Connecting" | "Completed" | "Pending Approval" | "Awaiting Input" | "Waiting" | "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, Waiting: 2.5, "Plan Ready": 2, Completed: 1, }; type ThreadStatusInput = Pick< SidebarThreadSummary, | "hasActionableProposedPlan" | "hasPendingApprovals" | "hasPendingUserInput" | "interactionMode" | "latestRun" | "runtime" > & { lastVisitedAt?: string | null | undefined; pendingBackgroundTasks?: SidebarThreadSummary["pendingBackgroundTasks"] | 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, }; } /** * Effective visited watermark for a thread. Servers with visited tracking * project `lastVisitedAt` on the shell and are authoritative — that value is * shared across every device connected to the environment. Pre-tracking * servers omit the field, and the browser's locally persisted watermark keeps * working as before. */ export function resolveThreadLastVisitedAt( serverLastVisitedAt: string | null | undefined, localLastVisitedAt: string | undefined, ): string | undefined { // When the server tracks visits it is authoritative — including explicit // rewinds from mark-unread, which a newer browser-local watermark must not // mask. The local value only carries servers without visited tracking. if (serverLastVisitedAt === undefined) return localLastVisitedAt; return serverLastVisitedAt ?? undefined; } export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { if (!thread.latestRun?.completedAt) return false; const completedAt = Date.parse(thread.latestRun.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;