diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index b54ec1cb93..ac343b7288 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + createThreadJumpHintVisibilityController, getVisibleSidebarThreadIds, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, @@ -15,6 +16,7 @@ import { shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, sortThreadsForSidebar, + THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; import { ProjectId, ThreadId } from "@t3tools/contracts"; import { @@ -52,6 +54,68 @@ describe("hasUnseenCompletion", () => { }); }); +describe("createThreadJumpHintVisibilityController", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("delays showing jump hints until the configured delay elapses", () => { + const visibilityChanges: boolean[] = []; + const controller = createThreadJumpHintVisibilityController({ + delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS, + onVisibilityChange: (visible) => { + visibilityChanges.push(visible); + }, + }); + + controller.sync(true); + vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS - 1); + + expect(visibilityChanges).toEqual([]); + + vi.advanceTimersByTime(1); + + expect(visibilityChanges).toEqual([true]); + }); + + it("hides immediately when the modifiers are released", () => { + const visibilityChanges: boolean[] = []; + const controller = createThreadJumpHintVisibilityController({ + delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS, + onVisibilityChange: (visible) => { + visibilityChanges.push(visible); + }, + }); + + controller.sync(true); + vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS); + controller.sync(false); + + expect(visibilityChanges).toEqual([true, false]); + }); + + it("cancels a pending reveal when the modifier is released early", () => { + const visibilityChanges: boolean[] = []; + const controller = createThreadJumpHintVisibilityController({ + delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS, + onVisibilityChange: (visible) => { + visibilityChanges.push(visible); + }, + }); + + controller.sync(true); + vi.advanceTimersByTime(Math.floor(THREAD_JUMP_HINT_SHOW_DELAY_MS / 2)); + controller.sync(false); + vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS); + + expect(visibilityChanges).toEqual([]); + }); +}); + describe("shouldClearThreadSelectionOnMouseDown", () => { it("preserves selection for thread items", () => { const child = { @@ -171,6 +235,27 @@ describe("getVisibleSidebarThreadIds", () => { ThreadId.makeUnsafe("thread-6"), ]); }); + + it("skips threads from collapsed projects whose thread panels are not shown", () => { + expect( + getVisibleSidebarThreadIds([ + { + shouldShowThreadPanel: false, + renderedThreads: [ + { id: ThreadId.makeUnsafe("thread-hidden-2") }, + { id: ThreadId.makeUnsafe("thread-hidden-1") }, + ], + }, + { + shouldShowThreadPanel: true, + renderedThreads: [ + { id: ThreadId.makeUnsafe("thread-12") }, + { id: ThreadId.makeUnsafe("thread-11") }, + ], + }, + ]), + ).toEqual([ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")]); + }); }); describe("isContextMenuPointerDown", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 1e0871e0d2..6350644d7f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,3 +1,4 @@ +import * as React from "react"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { Thread } from "../types"; import { cn } from "../lib/utils"; @@ -8,6 +9,7 @@ import { } 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; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; @@ -46,6 +48,91 @@ type ThreadStatusInput = Pick< "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" >; +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 hasUnseenCompletion(thread: ThreadStatusInput): boolean { if (!thread.latestTurn?.completedAt) return false; const completedAt = Date.parse(thread.latestTurn.completedAt); @@ -71,13 +158,16 @@ export function resolveSidebarNewThreadEnvMode(input: { export function getVisibleSidebarThreadIds( renderedProjects: readonly { + shouldShowThreadPanel?: boolean; renderedThreads: readonly { id: TThreadId; }[]; }[], ): TThreadId[] { return renderedProjects.flatMap((renderedProject) => - renderedProject.renderedThreads.map((thread) => thread.id), + renderedProject.shouldShowThreadPanel === false + ? [] + : renderedProject.renderedThreads.map((thread) => thread.id), ); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 100d0e3f47..31787c7627 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -69,6 +69,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; + import { useThreadActions } from "../hooks/useThreadActions"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; @@ -116,6 +117,7 @@ import { shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, sortThreadsForSidebar, + useThreadJumpHintVisibility, } from "./Sidebar.logic"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -361,7 +363,7 @@ export default function Sidebar() { const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); - const [showThreadJumpHints, setShowThreadJumpHints] = useState(false); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const dragInProgressRef = useRef(false); @@ -1070,23 +1072,22 @@ export default function Sidebar() { visibleThreads, ], ); + const visibleSidebarThreadIds = useMemo( + () => getVisibleSidebarThreadIds(renderedProjects), + [renderedProjects], + ); const threadJumpCommandById = useMemo(() => { const mapping = new Map>>(); - let visibleThreadIndex = 0; - - for (const renderedProject of renderedProjects) { - for (const thread of renderedProject.renderedThreads) { - const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); - if (!jumpCommand) { - return mapping; - } - mapping.set(thread.id, jumpCommand); - visibleThreadIndex += 1; + for (const [visibleThreadIndex, threadId] of visibleSidebarThreadIds.entries()) { + const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); + if (!jumpCommand) { + return mapping; } + mapping.set(threadId, jumpCommand); } return mapping; - }, [renderedProjects]); + }, [visibleSidebarThreadIds]); const threadJumpThreadIds = useMemo( () => [...threadJumpCommandById.keys()], [threadJumpCommandById], @@ -1101,10 +1102,7 @@ export default function Sidebar() { } return mapping; }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]); - const orderedSidebarThreadIds = useMemo( - () => getVisibleSidebarThreadIds(renderedProjects), - [renderedProjects], - ); + const orderedSidebarThreadIds = visibleSidebarThreadIds; useEffect(() => { const getShortcutContext = () => ({ @@ -1113,7 +1111,7 @@ export default function Sidebar() { }); const onWindowKeyDown = (event: KeyboardEvent) => { - setShowThreadJumpHints( + updateThreadJumpHintsVisibility( shouldShowThreadJumpHints(event, keybindings, { platform, context: getShortcutContext(), @@ -1161,7 +1159,7 @@ export default function Sidebar() { }; const onWindowKeyUp = (event: KeyboardEvent) => { - setShowThreadJumpHints( + updateThreadJumpHintsVisibility( shouldShowThreadJumpHints(event, keybindings, { platform, context: getShortcutContext(), @@ -1170,7 +1168,7 @@ export default function Sidebar() { }; const onWindowBlur = () => { - setShowThreadJumpHints(false); + updateThreadJumpHintsVisibility(false); }; window.addEventListener("keydown", onWindowKeyDown); @@ -1190,6 +1188,7 @@ export default function Sidebar() { routeTerminalOpen, routeThreadId, threadJumpThreadIds, + updateThreadJumpHintsVisibility, ]); function renderProjectItem(