authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-29 18:19:37-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-29 18:19:37-07:00
log32dca3b76b1c6ec0f7975b293a0af23a142f0a7e
tree1340a4e484e003c4f86a2ba629143889e646232b
parent63584449002d93aab65599ec34d2352663d5caf7
signature Signed by PGP key B5690EEEBB952194

[codex] debounce thread jump hint pills (#1526)

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 @@
1import { describe, expect, it } from "vitest";1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
3import {3import {
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";
19import { ProjectId, ThreadId } from "@t3tools/contracts";21import { ProjectId, ThreadId } from "@t3tools/contracts";
20import {22import {
...@@ -52,6 +54,68 @@ describe("hasUnseenCompletion", () => {...@@ -52,6 +54,68 @@ describe("hasUnseenCompletion", () => {
52 });54 });
53});55});
5456
57describe("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
55describe("shouldClearThreadSelectionOnMouseDown", () => {119describe("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});
175260
176describe("isContextMenuPointerDown", () => {261describe("isContextMenuPointerDown", () => {
apps/web/src/components/Sidebar.logic.ts+91-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as React from "react";
1import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";2import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
2import type { Thread } from "../types";3import type { Thread } from "../types";
3import { cn } from "../lib/utils";4import { cn } from "../lib/utils";
...@@ -8,6 +9,7 @@ import {...@@ -8,6 +9,7 @@ import {
8} from "../session-logic";9} from "../session-logic";
910
10export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";11export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
12export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;
11export type SidebarNewThreadEnvMode = "local" | "worktree";13export type SidebarNewThreadEnvMode = "local" | "worktree";
12type SidebarProject = {14type 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>;
4850
51export interface ThreadJumpHintVisibilityController {
52 sync: (shouldShow: boolean) => void;
53 dispose: () => void;
54}
55
56export 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
102export 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
49export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {136export 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: {
71158
72export function getVisibleSidebarThreadIds<TThreadId>(159export 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}
83173
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";
69import { readNativeApi } from "../nativeApi";69import { readNativeApi } from "../nativeApi";
70import { useComposerDraftStore } from "../composerDraftStore";70import { useComposerDraftStore } from "../composerDraftStore";
71import { useHandleNewThread } from "../hooks/useHandleNewThread";71import { useHandleNewThread } from "../hooks/useHandleNewThread";
72
72import { useThreadActions } from "../hooks/useThreadActions";73import { useThreadActions } from "../hooks/useThreadActions";
73import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";74import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
74import { toastManager } from "./ui/toast";75import { 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";
120import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";122import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
121import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";123import { 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()) {
11281134 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 }
11391140
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 );
11601158
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 });
11661164
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 };
12141212
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 };
12231221
1224 const onWindowBlur = () => {1222 const onWindowBlur = () => {
1225 setShowThreadJumpHints(false);1223 updateThreadJumpHintsVisibility(false);
1226 };1224 };
12271225
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 ]);
12461245
1247 function renderProjectItem(1246 function renderProjectItem(