Skip to content

Commit 32dca3b

Browse files
[codex] debounce thread jump hint pills (pingdotgg#1526)
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 6358444 commit 32dca3b

File tree

3 files changed

+195
-21
lines changed

3 files changed

+195
-21
lines changed

apps/web/src/components/Sidebar.logic.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { describe, expect, it } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22

33
import {
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";
1921
import { ProjectId, ThreadId } from "@t3tools/contracts";
2022
import {
@@ -52,6 +54,68 @@ describe("hasUnseenCompletion", () => {
5254
});
5355
});
5456

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+
55119
describe("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

176261
describe("isContextMenuPointerDown", () => {

apps/web/src/components/Sidebar.logic.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as React from "react";
12
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
23
import type { Thread } from "../types";
34
import { cn } from "../lib/utils";
@@ -8,6 +9,7 @@ import {
89
} from "../session-logic";
910

1011
export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
12+
export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;
1113
export type SidebarNewThreadEnvMode = "local" | "worktree";
1214
type SidebarProject = {
1315
id: string;
@@ -46,6 +48,91 @@ type ThreadStatusInput = Pick<
4648
"interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"
4749
>;
4850

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+
49136
export 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

72159
export 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

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery";
6969
import { readNativeApi } from "../nativeApi";
7070
import { useComposerDraftStore } from "../composerDraftStore";
7171
import { useHandleNewThread } from "../hooks/useHandleNewThread";
72+
7273
import { useThreadActions } from "../hooks/useThreadActions";
7374
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
7475
import { toastManager } from "./ui/toast";
@@ -116,6 +117,7 @@ import {
116117
shouldClearThreadSelectionOnMouseDown,
117118
sortProjectsForSidebar,
118119
sortThreadsForSidebar,
120+
useThreadJumpHintVisibility,
119121
} from "./Sidebar.logic";
120122
import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
121123
import { 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(

0 commit comments

Comments
 (0)