| 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,6 +1,7 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | createThreadJumpHintVisibilityController, | ||
| 4 | getVisibleSidebarThreadIds, | 5 | getVisibleSidebarThreadIds, |
| 5 | resolveAdjacentThreadId, | 6 | resolveAdjacentThreadId, |
| 6 | getFallbackThreadIdAfterDelete, | 7 | getFallbackThreadIdAfterDelete, |
| ... | @@ -15,6 +16,7 @@ import { | ... | @@ -15,6 +16,7 @@ import { |
| 15 | shouldClearThreadSelectionOnMouseDown, | 16 | shouldClearThreadSelectionOnMouseDown, |
| 16 | sortProjectsForSidebar, | 17 | sortProjectsForSidebar, |
| 17 | sortThreadsForSidebar, | 18 | sortThreadsForSidebar, |
| 19 | THREAD_JUMP_HINT_SHOW_DELAY_MS, | ||
| 18 | } from "./Sidebar.logic"; | 20 | } from "./Sidebar.logic"; |
| 19 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | 21 | import { ProjectId, ThreadId } from "@t3tools/contracts"; |
| 20 | import { | 22 | import { |
| ... | @@ -52,6 +54,68 @@ describe("hasUnseenCompletion", () => { | ... | @@ -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 | describe("shouldClearThreadSelectionOnMouseDown", () => { | 119 | describe("shouldClearThreadSelectionOnMouseDown", () => { |
| 56 | it("preserves selection for thread items", () => { | 120 | it("preserves selection for thread items", () => { |
| 57 | const child = { | 121 | const child = { |
| ... | @@ -171,6 +235,27 @@ describe("getVisibleSidebarThreadIds", () => { | ... | @@ -171,6 +235,27 @@ describe("getVisibleSidebarThreadIds", () => { |
| 171 | ThreadId.makeUnsafe("thread-6"), | 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 | describe("isContextMenuPointerDown", () => { | 261 | describe("isContextMenuPointerDown", () => { |
apps/web/src/components/Sidebar.logic.ts+91-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as React from "react"; | ||
| 1 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; | 2 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 2 | import type { Thread } from "../types"; | 3 | import type { Thread } from "../types"; |
| 3 | import { cn } from "../lib/utils"; | 4 | import { cn } from "../lib/utils"; |
| ... | @@ -8,6 +9,7 @@ import { | ... | @@ -8,6 +9,7 @@ import { |
| 8 | } from "../session-logic"; | 9 | } from "../session-logic"; |
| 9 | 10 | ||
| 10 | export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; | 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 | export type SidebarNewThreadEnvMode = "local" | "worktree"; | 13 | export type SidebarNewThreadEnvMode = "local" | "worktree"; |
| 12 | type SidebarProject = { | 14 | type SidebarProject = { |
| 13 | id: string; | 15 | id: string; |
| ... | @@ -46,6 +48,91 @@ type ThreadStatusInput = Pick< | ... | @@ -46,6 +48,91 @@ type ThreadStatusInput = Pick< |
| 46 | "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" | 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 | export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { | 136 | export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { |
| 50 | if (!thread.latestTurn?.completedAt) return false; | 137 | if (!thread.latestTurn?.completedAt) return false; |
| 51 | const completedAt = Date.parse(thread.latestTurn.completedAt); | 138 | const completedAt = Date.parse(thread.latestTurn.completedAt); |
| ... | @@ -71,13 +158,16 @@ export function resolveSidebarNewThreadEnvMode(input: { | ... | @@ -71,13 +158,16 @@ export function resolveSidebarNewThreadEnvMode(input: { |
| 71 | 158 | ||
| 72 | export function getVisibleSidebarThreadIds<TThreadId>( | 159 | export function getVisibleSidebarThreadIds<TThreadId>( |
| 73 | renderedProjects: readonly { | 160 | renderedProjects: readonly { |
| 161 | shouldShowThreadPanel?: boolean; | ||
| 74 | renderedThreads: readonly { | 162 | renderedThreads: readonly { |
| 75 | id: TThreadId; | 163 | id: TThreadId; |
| 76 | }[]; | 164 | }[]; |
| 77 | }[], | 165 | }[], |
| 78 | ): TThreadId[] { | 166 | ): TThreadId[] { |
| 79 | return renderedProjects.flatMap((renderedProject) => | 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,6 +69,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 69 | import { readNativeApi } from "../nativeApi"; | 69 | import { readNativeApi } from "../nativeApi"; |
| 70 | import { useComposerDraftStore } from "../composerDraftStore"; | 70 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 71 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 71 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 72 | |||
| 72 | import { useThreadActions } from "../hooks/useThreadActions"; | 73 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 73 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 74 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 74 | import { toastManager } from "./ui/toast"; | 75 | import { toastManager } from "./ui/toast"; |
| ... | @@ -116,6 +117,7 @@ import { | ... | @@ -116,6 +117,7 @@ import { |
| 116 | shouldClearThreadSelectionOnMouseDown, | 117 | shouldClearThreadSelectionOnMouseDown, |
| 117 | sortProjectsForSidebar, | 118 | sortProjectsForSidebar, |
| 118 | sortThreadsForSidebar, | 119 | sortThreadsForSidebar, |
| 120 | useThreadJumpHintVisibility, | ||
| 119 | } from "./Sidebar.logic"; | 121 | } from "./Sidebar.logic"; |
| 120 | import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; | 122 | import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; |
| 121 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 123 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| ... | @@ -399,7 +401,7 @@ export default function Sidebar() { | ... | @@ -399,7 +401,7 @@ export default function Sidebar() { |
| 399 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< | 401 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< |
| 400 | ReadonlySet<ProjectId> | 402 | ReadonlySet<ProjectId> |
| 401 | >(() => new Set()); | 403 | >(() => new Set()); |
| 402 | const [showThreadJumpHints, setShowThreadJumpHints] = useState(false); | 404 | const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); |
| 403 | const renamingCommittedRef = useRef(false); | 405 | const renamingCommittedRef = useRef(false); |
| 404 | const renamingInputRef = useRef<HTMLInputElement | null>(null); | 406 | const renamingInputRef = useRef<HTMLInputElement | null>(null); |
| 405 | const dragInProgressRef = useRef(false); | 407 | const dragInProgressRef = useRef(false); |
| ... | @@ -1122,23 +1124,22 @@ export default function Sidebar() { | ... | @@ -1122,23 +1124,22 @@ export default function Sidebar() { |
| 1122 | visibleThreads, | 1124 | visibleThreads, |
| 1123 | ], | 1125 | ], |
| 1124 | ); | 1126 | ); |
| 1127 | const visibleSidebarThreadIds = useMemo( | ||
| 1128 | () => getVisibleSidebarThreadIds(renderedProjects), | ||
| 1129 | [renderedProjects], | ||
| 1130 | ); | ||
| 1125 | const threadJumpCommandById = useMemo(() => { | 1131 | const threadJumpCommandById = useMemo(() => { |
| 1126 | const mapping = new Map<ThreadId, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>(); | 1132 | const mapping = new Map<ThreadId, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>(); |
| 1127 | let visibleThreadIndex = 0; | 1133 | for (const [visibleThreadIndex, threadId] of visibleSidebarThreadIds.entries()) { |
| 1128 | 1134 | const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); | |
| 1129 | for (const renderedProject of renderedProjects) { | 1135 | if (!jumpCommand) { |
| 1130 | for (const thread of renderedProject.renderedThreads) { | 1136 | return mapping; |
| 1131 | const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); | ||
| 1132 | if (!jumpCommand) { | ||
| 1133 | return mapping; | ||
| 1134 | } | ||
| 1135 | mapping.set(thread.id, jumpCommand); | ||
| 1136 | visibleThreadIndex += 1; | ||
| 1137 | } | 1137 | } |
| 1138 | mapping.set(threadId, jumpCommand); | ||
| 1138 | } | 1139 | } |
| 1139 | 1140 | ||
| 1140 | return mapping; | 1141 | return mapping; |
| 1141 | }, [renderedProjects]); | 1142 | }, [visibleSidebarThreadIds]); |
| 1142 | const threadJumpThreadIds = useMemo( | 1143 | const threadJumpThreadIds = useMemo( |
| 1143 | () => [...threadJumpCommandById.keys()], | 1144 | () => [...threadJumpCommandById.keys()], |
| 1144 | [threadJumpCommandById], | 1145 | [threadJumpCommandById], |
| ... | @@ -1153,10 +1154,7 @@ export default function Sidebar() { | ... | @@ -1153,10 +1154,7 @@ export default function Sidebar() { |
| 1153 | } | 1154 | } |
| 1154 | return mapping; | 1155 | return mapping; |
| 1155 | }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]); | 1156 | }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]); |
| 1156 | const orderedSidebarThreadIds = useMemo( | 1157 | const orderedSidebarThreadIds = visibleSidebarThreadIds; |
| 1157 | () => getVisibleSidebarThreadIds(renderedProjects), | ||
| 1158 | [renderedProjects], | ||
| 1159 | ); | ||
| 1160 | 1158 | ||
| 1161 | useEffect(() => { | 1159 | useEffect(() => { |
| 1162 | const getShortcutContext = () => ({ | 1160 | const getShortcutContext = () => ({ |
| ... | @@ -1165,7 +1163,7 @@ export default function Sidebar() { | ... | @@ -1165,7 +1163,7 @@ export default function Sidebar() { |
| 1165 | }); | 1163 | }); |
| 1166 | 1164 | ||
| 1167 | const onWindowKeyDown = (event: KeyboardEvent) => { | 1165 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 1168 | setShowThreadJumpHints( | 1166 | updateThreadJumpHintsVisibility( |
| 1169 | shouldShowThreadJumpHints(event, keybindings, { | 1167 | shouldShowThreadJumpHints(event, keybindings, { |
| 1170 | platform, | 1168 | platform, |
| 1171 | context: getShortcutContext(), | 1169 | context: getShortcutContext(), |
| ... | @@ -1213,7 +1211,7 @@ export default function Sidebar() { | ... | @@ -1213,7 +1211,7 @@ export default function Sidebar() { |
| 1213 | }; | 1211 | }; |
| 1214 | 1212 | ||
| 1215 | const onWindowKeyUp = (event: KeyboardEvent) => { | 1213 | const onWindowKeyUp = (event: KeyboardEvent) => { |
| 1216 | setShowThreadJumpHints( | 1214 | updateThreadJumpHintsVisibility( |
| 1217 | shouldShowThreadJumpHints(event, keybindings, { | 1215 | shouldShowThreadJumpHints(event, keybindings, { |
| 1218 | platform, | 1216 | platform, |
| 1219 | context: getShortcutContext(), | 1217 | context: getShortcutContext(), |
| ... | @@ -1222,7 +1220,7 @@ export default function Sidebar() { | ... | @@ -1222,7 +1220,7 @@ export default function Sidebar() { |
| 1222 | }; | 1220 | }; |
| 1223 | 1221 | ||
| 1224 | const onWindowBlur = () => { | 1222 | const onWindowBlur = () => { |
| 1225 | setShowThreadJumpHints(false); | 1223 | updateThreadJumpHintsVisibility(false); |
| 1226 | }; | 1224 | }; |
| 1227 | 1225 | ||
| 1228 | window.addEventListener("keydown", onWindowKeyDown); | 1226 | window.addEventListener("keydown", onWindowKeyDown); |
| ... | @@ -1242,6 +1240,7 @@ export default function Sidebar() { | ... | @@ -1242,6 +1240,7 @@ export default function Sidebar() { |
| 1242 | routeTerminalOpen, | 1240 | routeTerminalOpen, |
| 1243 | routeThreadId, | 1241 | routeThreadId, |
| 1244 | threadJumpThreadIds, | 1242 | threadJumpThreadIds, |
| 1243 | updateThreadJumpHintsVisibility, | ||
| 1245 | ]); | 1244 | ]); |
| 1246 | 1245 | ||
| 1247 | function renderProjectItem( | 1246 | function renderProjectItem( |