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 @@
1import { describe, expect, it } from "vitest";
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
33import {
4 createThreadJumpHintVisibilityController,
45 getVisibleSidebarThreadIds,
56 resolveAdjacentThreadId,
67 getFallbackThreadIdAfterDelete,
......@@ -15,6 +16,7 @@ import {
1516 shouldClearThreadSelectionOnMouseDown,
1617 sortProjectsForSidebar,
1718 sortThreadsForSidebar,
19 THREAD_JUMP_HINT_SHOW_DELAY_MS,
1820} from "./Sidebar.logic";
1921import { ProjectId, ThreadId } from "@t3tools/contracts";
2022import {
......@@ -52,6 +54,68 @@ describe("hasUnseenCompletion", () => {
5254 });
5355});
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
55119describe("shouldClearThreadSelectionOnMouseDown", () => {
56120 it("preserves selection for thread items", () => {
57121 const child = {
......@@ -171,6 +235,27 @@ describe("getVisibleSidebarThreadIds", () => {
171235 ThreadId.makeUnsafe("thread-6"),
172236 ]);
173237 });
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 });
174259});
175260
176261describe("isContextMenuPointerDown", () => {
apps/web/src/components/Sidebar.logic.ts+91-1
......@@ -1,3 +1,4 @@
1import * as React from "react";
12import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
23import type { Thread } from "../types";
34import { cn } from "../lib/utils";
......@@ -8,6 +9,7 @@ import {
89} from "../session-logic";
910
1011export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
12export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;
1113export type SidebarNewThreadEnvMode = "local" | "worktree";
1214type SidebarProject = {
1315 id: string;
......@@ -46,6 +48,91 @@ type ThreadStatusInput = Pick<
4648 "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"
4749>;
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
49136export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
50137 if (!thread.latestTurn?.completedAt) return false;
51138 const completedAt = Date.parse(thread.latestTurn.completedAt);
......@@ -71,13 +158,16 @@ export function resolveSidebarNewThreadEnvMode(input: {
71158
72159export function getVisibleSidebarThreadIds<TThreadId>(
73160 renderedProjects: readonly {
161 shouldShowThreadPanel?: boolean;
74162 renderedThreads: readonly {
75163 id: TThreadId;
76164 }[];
77165 }[],
78166): TThreadId[] {
79167 return renderedProjects.flatMap((renderedProject) =>
80 renderedProject.renderedThreads.map((thread) => thread.id),
168 renderedProject.shouldShowThreadPanel === false
169 ? []
170 : renderedProject.renderedThreads.map((thread) => thread.id),
81171 );
82172}
83173
apps/web/src/components/Sidebar.tsx+18-19
......@@ -69,6 +69,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery";
6969import { readNativeApi } from "../nativeApi";
7070import { useComposerDraftStore } from "../composerDraftStore";
7171import { useHandleNewThread } from "../hooks/useHandleNewThread";
72
7273import { useThreadActions } from "../hooks/useThreadActions";
7374import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
7475import { toastManager } from "./ui/toast";
......@@ -116,6 +117,7 @@ import {
116117 shouldClearThreadSelectionOnMouseDown,
117118 sortProjectsForSidebar,
118119 sortThreadsForSidebar,
120 useThreadJumpHintVisibility,
119121} from "./Sidebar.logic";
120122import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
121123import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
......@@ -399,7 +401,7 @@ export default function Sidebar() {
399401 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
400402 ReadonlySet<ProjectId>
401403 >(() => new Set());
402 const [showThreadJumpHints, setShowThreadJumpHints] = useState(false);
404 const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();
403405 const renamingCommittedRef = useRef(false);
404406 const renamingInputRef = useRef<HTMLInputElement | null>(null);
405407 const dragInProgressRef = useRef(false);
......@@ -1122,23 +1124,22 @@ export default function Sidebar() {
11221124 visibleThreads,
11231125 ],
11241126 );
1127 const visibleSidebarThreadIds = useMemo(
1128 () => getVisibleSidebarThreadIds(renderedProjects),
1129 [renderedProjects],
1130 );
11251131 const threadJumpCommandById = useMemo(() => {
11261132 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;
11371137 }
1138 mapping.set(threadId, jumpCommand);
11381139 }
11391140
11401141 return mapping;
1141 }, [renderedProjects]);
1142 }, [visibleSidebarThreadIds]);
11421143 const threadJumpThreadIds = useMemo(
11431144 () => [...threadJumpCommandById.keys()],
11441145 [threadJumpCommandById],
......@@ -1153,10 +1154,7 @@ export default function Sidebar() {
11531154 }
11541155 return mapping;
11551156 }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]);
1156 const orderedSidebarThreadIds = useMemo(
1157 () => getVisibleSidebarThreadIds(renderedProjects),
1158 [renderedProjects],
1159 );
1157 const orderedSidebarThreadIds = visibleSidebarThreadIds;
11601158
11611159 useEffect(() => {
11621160 const getShortcutContext = () => ({
......@@ -1165,7 +1163,7 @@ export default function Sidebar() {
11651163 });
11661164
11671165 const onWindowKeyDown = (event: KeyboardEvent) => {
1168 setShowThreadJumpHints(
1166 updateThreadJumpHintsVisibility(
11691167 shouldShowThreadJumpHints(event, keybindings, {
11701168 platform,
11711169 context: getShortcutContext(),
......@@ -1213,7 +1211,7 @@ export default function Sidebar() {
12131211 };
12141212
12151213 const onWindowKeyUp = (event: KeyboardEvent) => {
1216 setShowThreadJumpHints(
1214 updateThreadJumpHintsVisibility(
12171215 shouldShowThreadJumpHints(event, keybindings, {
12181216 platform,
12191217 context: getShortcutContext(),
......@@ -1222,7 +1220,7 @@ export default function Sidebar() {
12221220 };
12231221
12241222 const onWindowBlur = () => {
1225 setShowThreadJumpHints(false);
1223 updateThreadJumpHintsVisibility(false);
12261224 };
12271225
12281226 window.addEventListener("keydown", onWindowKeyDown);
......@@ -1242,6 +1240,7 @@ export default function Sidebar() {
12421240 routeTerminalOpen,
12431241 routeThreadId,
12441242 threadJumpThreadIds,
1243 updateThreadJumpHintsVisibility,
12451244 ]);
12461245
12471246 function renderProjectItem(