| author | |
| committer | |
| log | 32dca3b76b1c6ec0f7975b293a0af23a142f0a7e |
| tree | 1340a4e484e003c4f86a2ba629143889e646232b |
| parent | 63584449002d93aab65599ec34d2352663d5caf7 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>3 files changed, 195 insertions(+), 21 deletions(-)
apps/web/src/components/Sidebar.logic.test.ts+86-1| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | createThreadJumpHintVisibilityController, | |
| 4 | 5 | getVisibleSidebarThreadIds, |
| 5 | 6 | resolveAdjacentThreadId, |
| 6 | 7 | getFallbackThreadIdAfterDelete, |
| ... | ... | @@ -15,6 +16,7 @@ import { |
| 15 | 16 | shouldClearThreadSelectionOnMouseDown, |
| 16 | 17 | sortProjectsForSidebar, |
| 17 | 18 | sortThreadsForSidebar, |
| 19 | THREAD_JUMP_HINT_SHOW_DELAY_MS, | |
| 18 | 20 | } from "./Sidebar.logic"; |
| 19 | 21 | import { ProjectId, ThreadId } from "@t3tools/contracts"; |
| 20 | 22 | import { |
| ... | ... | @@ -52,6 +54,68 @@ describe("hasUnseenCompletion", () => { |
| 52 | 54 | }); |
| 53 | 55 | }); |
| 54 | 56 | |
| 57 | describe("createThreadJumpHintVisibilityController", () => { | |
| 58 | beforeEach(() => { | |
| 59 | vi.useFakeTimers(); | |
| 60 | }); | |
| 61 | ||
| 62 | afterEach(() => { | |
| 63 | vi.useRealTimers(); | |
| 64 | }); | |
| 65 | ||
| 66 | it("delays showing jump hints until the configured delay elapses", () => { | |
| 67 | const visibilityChanges: boolean[] = []; | |
| 68 | const controller = createThreadJumpHintVisibilityController({ | |
| 69 | delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS, | |
| 70 | onVisibilityChange: (visible) => { | |
| 71 | visibilityChanges.push(visible); | |
| 72 | }, | |
| 73 | }); | |
| 74 | ||
| 75 | controller.sync(true); | |
| 76 | vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS - 1); | |
| 77 | ||
| 78 | expect(visibilityChanges).toEqual([]); | |
| 79 | ||
| 80 | vi.advanceTimersByTime(1); | |
| 81 | ||
| 82 | expect(visibilityChanges).toEqual([true]); | |
| 83 | }); | |
| 84 | ||
| 85 | it("hides immediately when the modifiers are released", () => { | |
| 86 | const visibilityChanges: boolean[] = []; | |
| 87 | const controller = createThreadJumpHintVisibilityController({ | |
| 88 | delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS, | |
| 89 | onVisibilityChange: (visible) => { | |
| 90 | visibilityChanges.push(visible); | |
| 91 | }, | |
| 92 | }); | |
| 93 | ||
| 94 | controller.sync(true); | |
| 95 | vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS); | |
| 96 | controller.sync(false); | |
| 97 | ||
| 98 | expect(visibilityChanges).toEqual([true, false]); | |
| 99 | }); | |
| 100 | ||
| 101 | it("cancels a pending reveal when the modifier is released early", () => { | |
| 102 | const visibilityChanges: boolean[] = []; | |
| 103 | const controller = createThreadJumpHintVisibilityController({ | |
| 104 | delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS, | |
| 105 | onVisibilityChange: (visible) => { | |
| 106 | visibilityChanges.push(visible); | |
| 107 | }, | |
| 108 | }); | |
| 109 | ||
| 110 | controller.sync(true); | |
| 111 | vi.advanceTimersByTime(Math.floor(THREAD_JUMP_HINT_SHOW_DELAY_MS / 2)); | |
| 112 | controller.sync(false); | |
| 113 | vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS); | |
| 114 | ||
| 115 | expect(visibilityChanges).toEqual([]); | |
| 116 | }); | |
| 117 | }); | |
| 118 | ||
| 55 | 119 | describe("shouldClearThreadSelectionOnMouseDown", () => { |
| 56 | 120 | it("preserves selection for thread items", () => { |
| 57 | 121 | const child = { |
| ... | ... | @@ -171,6 +235,27 @@ describe("getVisibleSidebarThreadIds", () => { |
| 171 | 235 | ThreadId.makeUnsafe("thread-6"), |
| 172 | 236 | ]); |
| 173 | 237 | }); |
| 238 | ||
| 239 | it("skips threads from collapsed projects whose thread panels are not shown", () => { | |
| 240 | expect( | |
| 241 | getVisibleSidebarThreadIds([ | |
| 242 | { | |
| 243 | shouldShowThreadPanel: false, | |
| 244 | renderedThreads: [ | |
| 245 | { id: ThreadId.makeUnsafe("thread-hidden-2") }, | |
| 246 | { id: ThreadId.makeUnsafe("thread-hidden-1") }, | |
| 247 | ], | |
| 248 | }, | |
| 249 | { | |
| 250 | shouldShowThreadPanel: true, | |
| 251 | renderedThreads: [ | |
| 252 | { id: ThreadId.makeUnsafe("thread-12") }, | |
| 253 | { id: ThreadId.makeUnsafe("thread-11") }, | |
| 254 | ], | |
| 255 | }, | |
| 256 | ]), | |
| 257 | ).toEqual([ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")]); | |
| 258 | }); | |
| 174 | 259 | }); |
| 175 | 260 | |
| 176 | 261 | describe("isContextMenuPointerDown", () => { |
apps/web/src/components/Sidebar.logic.ts+91-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as React from "react"; | |
| 1 | 2 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 2 | 3 | import type { Thread } from "../types"; |
| 3 | 4 | import { cn } from "../lib/utils"; |
| ... | ... | @@ -8,6 +9,7 @@ import { |
| 8 | 9 | } from "../session-logic"; |
| 9 | 10 | |
| 10 | 11 | export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; |
| 12 | export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; | |
| 11 | 13 | export type SidebarNewThreadEnvMode = "local" | "worktree"; |
| 12 | 14 | type SidebarProject = { |
| 13 | 15 | id: string; |
| ... | ... | @@ -46,6 +48,91 @@ type ThreadStatusInput = Pick< |
| 46 | 48 | "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" |
| 47 | 49 | >; |
| 48 | 50 | |
| 51 | export interface ThreadJumpHintVisibilityController { | |
| 52 | sync: (shouldShow: boolean) => void; | |
| 53 | dispose: () => void; | |
| 54 | } | |
| 55 | ||
| 56 | export function createThreadJumpHintVisibilityController(input: { | |
| 57 | delayMs: number; | |
| 58 | onVisibilityChange: (visible: boolean) => void; | |
| 59 | setTimeoutFn?: typeof globalThis.setTimeout; | |
| 60 | clearTimeoutFn?: typeof globalThis.clearTimeout; | |
| 61 | }): ThreadJumpHintVisibilityController { | |
| 62 | const setTimeoutFn = input.setTimeoutFn ?? globalThis.setTimeout; | |
| 63 | const clearTimeoutFn = input.clearTimeoutFn ?? globalThis.clearTimeout; | |
| 64 | let isVisible = false; | |
| 65 | let timeoutId: NodeJS.Timeout | null = null; | |
| 66 | ||
| 67 | const clearPendingShow = () => { | |
| 68 | if (timeoutId === null) { | |
| 69 | return; | |
| 70 | } | |
| 71 | clearTimeoutFn(timeoutId); | |
| 72 | timeoutId = null; | |
| 73 | }; | |
| 74 | ||
| 75 | return { | |
| 76 | sync: (shouldShow) => { | |
| 77 | if (!shouldShow) { | |
| 78 | clearPendingShow(); | |
| 79 | if (isVisible) { | |
| 80 | isVisible = false; | |
| 81 | input.onVisibilityChange(false); | |
| 82 | } | |
| 83 | return; | |
| 84 | } | |
| 85 | ||
| 86 | if (isVisible || timeoutId !== null) { | |
| 87 | return; | |
| 88 | } | |
| 89 | ||
| 90 | timeoutId = setTimeoutFn(() => { | |
| 91 | timeoutId = null; | |
| 92 | isVisible = true; | |
| 93 | input.onVisibilityChange(true); | |
| 94 | }, input.delayMs); | |
| 95 | }, | |
| 96 | dispose: () => { | |
| 97 | clearPendingShow(); | |
| 98 | }, | |
| 99 | }; | |
| 100 | } | |
| 101 | ||
| 102 | export function useThreadJumpHintVisibility(): { | |
| 103 | showThreadJumpHints: boolean; | |
| 104 | updateThreadJumpHintsVisibility: (shouldShow: boolean) => void; | |
| 105 | } { | |
| 106 | const [showThreadJumpHints, setShowThreadJumpHints] = React.useState(false); | |
| 107 | const controllerRef = React.useRef<ThreadJumpHintVisibilityController | null>(null); | |
| 108 | ||
| 109 | React.useEffect(() => { | |
| 110 | const controller = createThreadJumpHintVisibilityController({ | |
| 111 | delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS, | |
| 112 | onVisibilityChange: (visible) => { | |
| 113 | setShowThreadJumpHints(visible); | |
| 114 | }, | |
| 115 | setTimeoutFn: window.setTimeout.bind(window), | |
| 116 | clearTimeoutFn: window.clearTimeout.bind(window), | |
| 117 | }); | |
| 118 | controllerRef.current = controller; | |
| 119 | ||
| 120 | return () => { | |
| 121 | controller.dispose(); | |
| 122 | controllerRef.current = null; | |
| 123 | }; | |
| 124 | }, []); | |
| 125 | ||
| 126 | const updateThreadJumpHintsVisibility = React.useCallback((shouldShow: boolean) => { | |
| 127 | controllerRef.current?.sync(shouldShow); | |
| 128 | }, []); | |
| 129 | ||
| 130 | return { | |
| 131 | showThreadJumpHints, | |
| 132 | updateThreadJumpHintsVisibility, | |
| 133 | }; | |
| 134 | } | |
| 135 | ||
| 49 | 136 | export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { |
| 50 | 137 | if (!thread.latestTurn?.completedAt) return false; |
| 51 | 138 | const completedAt = Date.parse(thread.latestTurn.completedAt); |
| ... | ... | @@ -71,13 +158,16 @@ export function resolveSidebarNewThreadEnvMode(input: { |
| 71 | 158 | |
| 72 | 159 | export function getVisibleSidebarThreadIds<TThreadId>( |
| 73 | 160 | renderedProjects: readonly { |
| 161 | shouldShowThreadPanel?: boolean; | |
| 74 | 162 | renderedThreads: readonly { |
| 75 | 163 | id: TThreadId; |
| 76 | 164 | }[]; |
| 77 | 165 | }[], |
| 78 | 166 | ): TThreadId[] { |
| 79 | 167 | return renderedProjects.flatMap((renderedProject) => |
| 80 | renderedProject.renderedThreads.map((thread) => thread.id), | |
| 168 | renderedProject.shouldShowThreadPanel === false | |
| 169 | ? [] | |
| 170 | : renderedProject.renderedThreads.map((thread) => thread.id), | |
| 81 | 171 | ); |
| 82 | 172 | } |
| 83 | 173 |
apps/web/src/components/Sidebar.tsx+18-19| ... | ... | @@ -69,6 +69,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 69 | 69 | import { readNativeApi } from "../nativeApi"; |
| 70 | 70 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 71 | 71 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 72 | ||
| 72 | 73 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 73 | 74 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 74 | 75 | import { toastManager } from "./ui/toast"; |
| ... | ... | @@ -116,6 +117,7 @@ import { |
| 116 | 117 | shouldClearThreadSelectionOnMouseDown, |
| 117 | 118 | sortProjectsForSidebar, |
| 118 | 119 | sortThreadsForSidebar, |
| 120 | useThreadJumpHintVisibility, | |
| 119 | 121 | } from "./Sidebar.logic"; |
| 120 | 122 | import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; |
| 121 | 123 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| ... | ... | @@ -399,7 +401,7 @@ export default function Sidebar() { |
| 399 | 401 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< |
| 400 | 402 | ReadonlySet<ProjectId> |
| 401 | 403 | >(() => new Set()); |
| 402 | const [showThreadJumpHints, setShowThreadJumpHints] = useState(false); | |
| 404 | const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); | |
| 403 | 405 | const renamingCommittedRef = useRef(false); |
| 404 | 406 | const renamingInputRef = useRef<HTMLInputElement | null>(null); |
| 405 | 407 | const dragInProgressRef = useRef(false); |
| ... | ... | @@ -1122,23 +1124,22 @@ export default function Sidebar() { |
| 1122 | 1124 | visibleThreads, |
| 1123 | 1125 | ], |
| 1124 | 1126 | ); |
| 1127 | const visibleSidebarThreadIds = useMemo( | |
| 1128 | () => getVisibleSidebarThreadIds(renderedProjects), | |
| 1129 | [renderedProjects], | |
| 1130 | ); | |
| 1125 | 1131 | const threadJumpCommandById = useMemo(() => { |
| 1126 | 1132 | const mapping = new Map<ThreadId, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>(); |
| 1127 | let visibleThreadIndex = 0; | |
| 1128 | ||
| 1129 | for (const renderedProject of renderedProjects) { | |
| 1130 | for (const thread of renderedProject.renderedThreads) { | |
| 1131 | const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); | |
| 1132 | if (!jumpCommand) { | |
| 1133 | return mapping; | |
| 1134 | } | |
| 1135 | mapping.set(thread.id, jumpCommand); | |
| 1136 | visibleThreadIndex += 1; | |
| 1133 | for (const [visibleThreadIndex, threadId] of visibleSidebarThreadIds.entries()) { | |
| 1134 | const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); | |
| 1135 | if (!jumpCommand) { | |
| 1136 | return mapping; | |
| 1137 | 1137 | } |
| 1138 | mapping.set(threadId, jumpCommand); | |
| 1138 | 1139 | } |
| 1139 | 1140 | |
| 1140 | 1141 | return mapping; |
| 1141 | }, [renderedProjects]); | |
| 1142 | }, [visibleSidebarThreadIds]); | |
| 1142 | 1143 | const threadJumpThreadIds = useMemo( |
| 1143 | 1144 | () => [...threadJumpCommandById.keys()], |
| 1144 | 1145 | [threadJumpCommandById], |
| ... | ... | @@ -1153,10 +1154,7 @@ export default function Sidebar() { |
| 1153 | 1154 | } |
| 1154 | 1155 | return mapping; |
| 1155 | 1156 | }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]); |
| 1156 | const orderedSidebarThreadIds = useMemo( | |
| 1157 | () => getVisibleSidebarThreadIds(renderedProjects), | |
| 1158 | [renderedProjects], | |
| 1159 | ); | |
| 1157 | const orderedSidebarThreadIds = visibleSidebarThreadIds; | |
| 1160 | 1158 | |
| 1161 | 1159 | useEffect(() => { |
| 1162 | 1160 | const getShortcutContext = () => ({ |
| ... | ... | @@ -1165,7 +1163,7 @@ export default function Sidebar() { |
| 1165 | 1163 | }); |
| 1166 | 1164 | |
| 1167 | 1165 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 1168 | setShowThreadJumpHints( | |
| 1166 | updateThreadJumpHintsVisibility( | |
| 1169 | 1167 | shouldShowThreadJumpHints(event, keybindings, { |
| 1170 | 1168 | platform, |
| 1171 | 1169 | context: getShortcutContext(), |
| ... | ... | @@ -1213,7 +1211,7 @@ export default function Sidebar() { |
| 1213 | 1211 | }; |
| 1214 | 1212 | |
| 1215 | 1213 | const onWindowKeyUp = (event: KeyboardEvent) => { |
| 1216 | setShowThreadJumpHints( | |
| 1214 | updateThreadJumpHintsVisibility( | |
| 1217 | 1215 | shouldShowThreadJumpHints(event, keybindings, { |
| 1218 | 1216 | platform, |
| 1219 | 1217 | context: getShortcutContext(), |
| ... | ... | @@ -1222,7 +1220,7 @@ export default function Sidebar() { |
| 1222 | 1220 | }; |
| 1223 | 1221 | |
| 1224 | 1222 | const onWindowBlur = () => { |
| 1225 | setShowThreadJumpHints(false); | |
| 1223 | updateThreadJumpHintsVisibility(false); | |
| 1226 | 1224 | }; |
| 1227 | 1225 | |
| 1228 | 1226 | window.addEventListener("keydown", onWindowKeyDown); |
| ... | ... | @@ -1242,6 +1240,7 @@ export default function Sidebar() { |
| 1242 | 1240 | routeTerminalOpen, |
| 1243 | 1241 | routeThreadId, |
| 1244 | 1242 | threadJumpThreadIds, |
| 1243 | updateThreadJumpHintsVisibility, | |
| 1245 | 1244 | ]); |
| 1246 | 1245 | |
| 1247 | 1246 | function renderProjectItem( |