From aa91042c24e725da573362d1bd90cdb2d5822b9e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 26 Mar 2026 21:12:13 -0700 Subject: [PATCH 1/2] Add keyboard shortcuts for jumping to sidebar threads - Map the first nine visible threads to number keys - Show platform-specific shortcut hints while modifier is held - Add tests for key mapping and modifier detection --- apps/web/src/components/Sidebar.logic.test.ts | 95 ++++++++ apps/web/src/components/Sidebar.logic.ts | 45 +++- apps/web/src/components/Sidebar.tsx | 212 ++++++++++++++---- 3 files changed, 309 insertions(+), 43 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index dcfe3f6b69..c1451abdd3 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from "vitest"; import { + formatThreadJumpHintLabel, getFallbackThreadIdAfterDelete, + getThreadJumpKey, getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, + isThreadJumpModifierPressed, + resolveThreadJumpIndex, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -96,6 +100,97 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); +describe("thread jump helpers", () => { + it("assigns jump keys for the first nine visible threads", () => { + expect(getThreadJumpKey(0)).toBe("1"); + expect(getThreadJumpKey(8)).toBe("9"); + expect(getThreadJumpKey(9)).toBeNull(); + }); + + it("detects the active jump modifier by platform", () => { + expect( + isThreadJumpModifierPressed( + { + key: "Meta", + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + }, + "MacIntel", + ), + ).toBe(true); + expect( + isThreadJumpModifierPressed( + { + key: "Control", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + }, + "Win32", + ), + ).toBe(true); + expect( + isThreadJumpModifierPressed( + { + key: "Control", + metaKey: false, + ctrlKey: true, + shiftKey: true, + altKey: false, + }, + "Win32", + ), + ).toBe(false); + }); + + it("resolves mod+digit events to zero-based visible thread indices", () => { + expect( + resolveThreadJumpIndex( + { + key: "1", + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + }, + "MacIntel", + ), + ).toBe(0); + expect( + resolveThreadJumpIndex( + { + key: "9", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + }, + "Linux", + ), + ).toBe(8); + expect( + resolveThreadJumpIndex( + { + key: "0", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + }, + "Linux", + ), + ).toBeNull(); + }); + + it("formats thread jump hint labels for macOS and non-macOS", () => { + expect(formatThreadJumpHintLabel("3", "MacIntel")).toBe("⌘3"); + expect(formatThreadJumpHintLabel("3", "Linux")).toBe("Ctrl+3"); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { interactionMode: "plan" as const, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 6ca29d27e9..8f9fe38010 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,6 +1,6 @@ import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { Thread } from "../types"; -import { cn } from "../lib/utils"; +import { cn, isMacPlatform } from "../lib/utils"; import { findLatestProposedPlan, hasActionableProposedPlan, @@ -8,6 +8,7 @@ import { } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; +const THREAD_JUMP_KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] as const; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; @@ -16,6 +17,16 @@ type SidebarProject = { updatedAt?: string | undefined; }; type SidebarThreadSortInput = Pick; +type ThreadJumpKey = (typeof THREAD_JUMP_KEYS)[number]; +export type { ThreadJumpKey }; + +export interface ThreadJumpEvent { + key: string; + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; +} export interface ThreadStatusPill { label: @@ -67,6 +78,38 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } +export function getThreadJumpKey(index: number): ThreadJumpKey | null { + return THREAD_JUMP_KEYS[index] ?? null; +} + +export function isThreadJumpModifierPressed( + event: ThreadJumpEvent, + platform = navigator.platform, +): boolean { + return ( + (isMacPlatform(platform) ? event.metaKey : event.ctrlKey) && !event.altKey && !event.shiftKey + ); +} + +export function resolveThreadJumpIndex( + event: ThreadJumpEvent, + platform = navigator.platform, +): number | null { + if (!isThreadJumpModifierPressed(event, platform)) { + return null; + } + + const index = THREAD_JUMP_KEYS.indexOf(event.key as ThreadJumpKey); + return index === -1 ? null : index; +} + +export function formatThreadJumpHintLabel( + key: ThreadJumpKey, + platform = navigator.platform, +): string { + return isMacPlatform(platform) ? `⌘${key}` : `Ctrl+${key}`; +} + export function resolveThreadRowClassName(input: { isActive: boolean; isSelected: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6c531b1e7a..c0c354763d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -90,8 +90,12 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { + formatThreadJumpHintLabel, getFallbackThreadIdAfterDelete, + getThreadJumpKey, getVisibleThreadsForProject, + isThreadJumpModifierPressed, + resolveThreadJumpIndex, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -99,6 +103,7 @@ import { shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, sortThreadsForSidebar, + type ThreadJumpKey, } from "./Sidebar.logic"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; @@ -405,6 +410,7 @@ export default function Sidebar() { const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); + const [showThreadJumpHints, setShowThreadJumpHints] = useState(false); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const dragInProgressRef = useRef(false); @@ -417,6 +423,7 @@ export default function Sidebar() { const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); + const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const projectCwdById = useMemo( @@ -974,6 +981,20 @@ export default function Sidebar() { ], ); + const navigateToThread = useCallback( + (threadId: ThreadId) => { + if (selectedThreadIds.size > 0) { + clearSelection(); + } + setSelectionAnchor(threadId); + void navigate({ + to: "/$threadId", + params: { threadId }, + }); + }, + [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor], + ); + const handleProjectContextMenu = useCallback( async (projectId: ProjectId, position: { x: number; y: number }) => { const api = readNativeApi(); @@ -1103,43 +1124,139 @@ export default function Sidebar() { [appSettings.sidebarProjectSortOrder, projects, threads], ); const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; + const renderedProjects = useMemo( + () => + sortedProjects.map((project) => { + const projectThreads = sortThreadsForSidebar( + threads.filter((thread) => thread.projectId === project.id), + appSettings.sidebarThreadSortOrder, + ); + const projectStatus = resolveProjectStatusIndicator( + projectThreads.map((thread) => + resolveThreadStatusPill({ + thread, + hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, + hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, + }), + ), + ); + const activeThreadId = routeThreadId ?? undefined; + const isThreadListExpanded = expandedThreadListsByProject.has(project.id); + const pinnedCollapsedThread = + !project.expanded && activeThreadId + ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) + : null; + const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; + const { hasHiddenThreads, visibleThreads } = getVisibleThreadsForProject({ + threads: projectThreads, + activeThreadId, + isThreadListExpanded, + previewLimit: THREAD_PREVIEW_LIMIT, + }); + const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleThreads; + + return { + hasHiddenThreads, + orderedProjectThreadIds, + project, + projectStatus, + projectThreads, + renderedThreads, + shouldShowThreadPanel, + isThreadListExpanded, + }; + }), + [ + appSettings.sidebarThreadSortOrder, + expandedThreadListsByProject, + routeThreadId, + sortedProjects, + threads, + ], + ); + const threadJumpKeyById = useMemo(() => { + const mapping = new Map(); + let visibleThreadIndex = 0; + + for (const renderedProject of renderedProjects) { + for (const thread of renderedProject.renderedThreads) { + const jumpKey = getThreadJumpKey(visibleThreadIndex); + if (!jumpKey) { + return mapping; + } + mapping.set(thread.id, jumpKey); + visibleThreadIndex += 1; + } + } + + return mapping; + }, [renderedProjects]); + const threadJumpThreadIds = useMemo(() => [...threadJumpKeyById.keys()], [threadJumpKeyById]); + + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + if (isThreadJumpModifierPressed(event, platform)) { + setShowThreadJumpHints(true); + } + + if (event.defaultPrevented || event.repeat) { + return; + } + + const jumpIndex = resolveThreadJumpIndex(event, platform); + if (jumpIndex === null) { + return; + } + + const targetThreadId = threadJumpThreadIds[jumpIndex]; + if (!targetThreadId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(targetThreadId); + }; + + const onWindowKeyUp = (event: KeyboardEvent) => { + setShowThreadJumpHints(isThreadJumpModifierPressed(event, platform)); + }; + + const onWindowBlur = () => { + setShowThreadJumpHints(false); + }; + + window.addEventListener("keydown", onWindowKeyDown); + window.addEventListener("keyup", onWindowKeyUp); + window.addEventListener("blur", onWindowBlur); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + window.removeEventListener("keyup", onWindowKeyUp); + window.removeEventListener("blur", onWindowBlur); + }; + }, [navigateToThread, platform, threadJumpThreadIds]); function renderProjectItem( - project: (typeof sortedProjects)[number], + renderedProject: (typeof renderedProjects)[number], dragHandleProps: SortableProjectHandleProps | null, ) { - const projectThreads = sortThreadsForSidebar( - threads.filter((thread) => thread.projectId === project.id), - appSettings.sidebarThreadSortOrder, - ); - const projectStatus = resolveProjectStatusIndicator( - projectThreads.map((thread) => - resolveThreadStatusPill({ - thread, - hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, - }), - ), - ); - const activeThreadId = routeThreadId ?? undefined; - const isThreadListExpanded = expandedThreadListsByProject.has(project.id); - const pinnedCollapsedThread = - !project.expanded && activeThreadId - ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) - : null; - const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; - const { hasHiddenThreads, visibleThreads } = getVisibleThreadsForProject({ - threads: projectThreads, - activeThreadId, + const { + hasHiddenThreads, + orderedProjectThreadIds, + project, + projectStatus, + projectThreads, + renderedThreads, + shouldShowThreadPanel, isThreadListExpanded, - previewLimit: THREAD_PREVIEW_LIMIT, - }); - const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleThreads; + } = renderedProject; const renderThreadRow = (thread: (typeof projectThreads)[number]) => { const isActive = routeThreadId === thread.id; const isSelected = selectedThreadIds.has(thread.id); const isHighlighted = isActive || isSelected; + const jumpKey = threadJumpKeyById.get(thread.id) ?? null; const threadStatus = resolveThreadStatusPill({ thread, hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, @@ -1166,14 +1283,7 @@ export default function Sidebar() { onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); - if (selectedThreadIds.size > 0) { - clearSelection(); - } - setSelectionAnchor(thread.id); - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); + navigateToThread(thread.id); }} onContextMenu={(event) => { event.preventDefault(); @@ -1282,6 +1392,21 @@ export default function Sidebar() { > {formatRelativeTime(thread.updatedAt ?? thread.createdAt)} + {jumpKey ? ( + + {formatThreadJumpHintLabel(jumpKey, platform)} + + ) : null} @@ -1802,9 +1927,12 @@ export default function Sidebar() { items={sortedProjects.map((project) => project.id)} strategy={verticalListSortingStrategy} > - {sortedProjects.map((project) => ( - - {(dragHandleProps) => renderProjectItem(project, dragHandleProps)} + {renderedProjects.map((renderedProject) => ( + + {(dragHandleProps) => renderProjectItem(renderedProject, dragHandleProps)} ))} @@ -1812,9 +1940,9 @@ export default function Sidebar() { ) : ( - {sortedProjects.map((project) => ( - - {renderProjectItem(project, null)} + {renderedProjects.map((renderedProject) => ( + + {renderProjectItem(renderedProject, null)} ))} From 6aadae6e1c026270709d53aa1d274932d99a903d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 27 Mar 2026 16:33:19 -0700 Subject: [PATCH 2/2] Add numeric shortcuts for visible thread jumps - Bind Cmd/Ctrl+1-9 to thread jump commands - Resolve jump targets from visible sidebar threads - Show shortcut hints using current keybinding labels --- apps/server/src/keybindings.ts | 9 ++ apps/web/src/components/Sidebar.logic.test.ts | 146 +++++++----------- apps/web/src/components/Sidebar.logic.ts | 69 ++++----- apps/web/src/components/Sidebar.tsx | 118 +++++++------- apps/web/src/keybindings.test.ts | 22 +++ apps/web/src/keybindings.ts | 14 ++ packages/contracts/src/keybindings.ts | 9 ++ 7 files changed, 207 insertions(+), 180 deletions(-) diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 58363d2138..81a80c2438 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -76,6 +76,15 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, + { key: "mod+1", command: "thread.jump.1" }, + { key: "mod+2", command: "thread.jump.2" }, + { key: "mod+3", command: "thread.jump.3" }, + { key: "mod+4", command: "thread.jump.4" }, + { key: "mod+5", command: "thread.jump.5" }, + { key: "mod+6", command: "thread.jump.6" }, + { key: "mod+7", command: "thread.jump.7" }, + { key: "mod+8", command: "thread.jump.8" }, + { key: "mod+9", command: "thread.jump.9" }, ]; function normalizeKeyToken(token: string): string { diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index c1451abdd3..80e61535c3 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,14 +1,11 @@ import { describe, expect, it } from "vitest"; import { - formatThreadJumpHintLabel, getFallbackThreadIdAfterDelete, - getThreadJumpKey, + getVisibleThreadJumpTargets, getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, - isThreadJumpModifierPressed, - resolveThreadJumpIndex, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -100,94 +97,71 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); -describe("thread jump helpers", () => { - it("assigns jump keys for the first nine visible threads", () => { - expect(getThreadJumpKey(0)).toBe("1"); - expect(getThreadJumpKey(8)).toBe("9"); - expect(getThreadJumpKey(9)).toBeNull(); +function makeJumpProject( + expanded: boolean, + threadIds: ThreadId[], + shouldShowThreadPanel = expanded, +) { + return { + project: { expanded }, + renderedThreads: threadIds.map((id) => ({ id })), + shouldShowThreadPanel, + }; +} + +describe("getVisibleThreadJumpTargets", () => { + function tid(n: number): ThreadId { + return ThreadId.makeUnsafe(`thread-${n}`); + } + + it("returns thread IDs from expanded projects only", () => { + const targets = getVisibleThreadJumpTargets([ + makeJumpProject(true, [tid(1), tid(2)]), + makeJumpProject(false, [tid(3)]), + makeJumpProject(true, [tid(4)]), + ]); + + expect(targets).toEqual([tid(1), tid(2), tid(4)]); }); - it("detects the active jump modifier by platform", () => { - expect( - isThreadJumpModifierPressed( - { - key: "Meta", - metaKey: true, - ctrlKey: false, - shiftKey: false, - altKey: false, - }, - "MacIntel", - ), - ).toBe(true); - expect( - isThreadJumpModifierPressed( - { - key: "Control", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: false, - }, - "Win32", - ), - ).toBe(true); - expect( - isThreadJumpModifierPressed( - { - key: "Control", - metaKey: false, - ctrlKey: true, - shiftKey: true, - altKey: false, - }, - "Win32", - ), - ).toBe(false); + it("skips projects where shouldShowThreadPanel is false", () => { + const targets = getVisibleThreadJumpTargets([ + makeJumpProject(true, [tid(1)], false), + makeJumpProject(true, [tid(2)], true), + ]); + + expect(targets).toEqual([tid(2)]); }); - it("resolves mod+digit events to zero-based visible thread indices", () => { - expect( - resolveThreadJumpIndex( - { - key: "1", - metaKey: true, - ctrlKey: false, - shiftKey: false, - altKey: false, - }, - "MacIntel", - ), - ).toBe(0); - expect( - resolveThreadJumpIndex( - { - key: "9", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: false, - }, - "Linux", - ), - ).toBe(8); - expect( - resolveThreadJumpIndex( - { - key: "0", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: false, - }, - "Linux", - ), - ).toBeNull(); + it("caps at 9 targets", () => { + const allThreads = Array.from({ length: 12 }, (_, i) => tid(i)); + const targets = getVisibleThreadJumpTargets([makeJumpProject(true, allThreads)]); + + expect(targets).toHaveLength(9); + expect(targets).toEqual(allThreads.slice(0, 9)); + }); + + it("returns empty array when all projects are collapsed", () => { + const targets = getVisibleThreadJumpTargets([ + makeJumpProject(false, [tid(1), tid(2)]), + makeJumpProject(false, [tid(3)]), + ]); + + expect(targets).toEqual([]); }); - it("formats thread jump hint labels for macOS and non-macOS", () => { - expect(formatThreadJumpHintLabel("3", "MacIntel")).toBe("⌘3"); - expect(formatThreadJumpHintLabel("3", "Linux")).toBe("Ctrl+3"); + it("returns empty array for no projects", () => { + expect(getVisibleThreadJumpTargets([])).toEqual([]); + }); + + it("preserves order across multiple expanded projects", () => { + const targets = getVisibleThreadJumpTargets([ + makeJumpProject(true, [tid(1), tid(2)]), + makeJumpProject(true, [tid(3), tid(4)]), + makeJumpProject(true, [tid(5)]), + ]); + + expect(targets).toEqual([tid(1), tid(2), tid(3), tid(4), tid(5)]); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 8f9fe38010..794a389904 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,6 +1,7 @@ import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import type { ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; -import { cn, isMacPlatform } from "../lib/utils"; +import { cn } from "../lib/utils"; import { findLatestProposedPlan, hasActionableProposedPlan, @@ -8,7 +9,7 @@ import { } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; -const THREAD_JUMP_KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] as const; +const MAX_THREAD_JUMP_TARGETS = 9; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; @@ -17,16 +18,6 @@ type SidebarProject = { updatedAt?: string | undefined; }; type SidebarThreadSortInput = Pick; -type ThreadJumpKey = (typeof THREAD_JUMP_KEYS)[number]; -export type { ThreadJumpKey }; - -export interface ThreadJumpEvent { - key: string; - metaKey: boolean; - ctrlKey: boolean; - shiftKey: boolean; - altKey: boolean; -} export interface ThreadStatusPill { label: @@ -78,36 +69,34 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } -export function getThreadJumpKey(index: number): ThreadJumpKey | null { - return THREAD_JUMP_KEYS[index] ?? null; -} - -export function isThreadJumpModifierPressed( - event: ThreadJumpEvent, - platform = navigator.platform, -): boolean { - return ( - (isMacPlatform(platform) ? event.metaKey : event.ctrlKey) && !event.altKey && !event.shiftKey - ); -} - -export function resolveThreadJumpIndex( - event: ThreadJumpEvent, - platform = navigator.platform, -): number | null { - if (!isThreadJumpModifierPressed(event, platform)) { - return null; +/** + * Returns an ordered array of thread IDs eligible for Cmd/Ctrl+N jump shortcuts. + * + * Only threads that are actually visible in the sidebar are counted: + * - Collapsed projects are skipped entirely. + * - Threads hidden behind "show more" are excluded (already filtered by `renderedThreads`). + * - At most 9 targets are returned (matching Cmd+1 through Cmd+9). + */ +export function getVisibleThreadJumpTargets( + renderedProjects: ReadonlyArray<{ + project: { expanded: boolean }; + renderedThreads: ReadonlyArray<{ id: ThreadId }>; + shouldShowThreadPanel: boolean; + }>, +): ThreadId[] { + const targets: ThreadId[] = []; + + for (const entry of renderedProjects) { + if (!entry.project.expanded) continue; + if (!entry.shouldShowThreadPanel) continue; + + for (const thread of entry.renderedThreads) { + targets.push(thread.id); + if (targets.length >= MAX_THREAD_JUMP_TARGETS) return targets; + } } - const index = THREAD_JUMP_KEYS.indexOf(event.key as ThreadJumpKey); - return index === -1 ? null : index; -} - -export function formatThreadJumpHintLabel( - key: ThreadJumpKey, - platform = navigator.platform, -): string { - return isMacPlatform(platform) ? `⌘${key}` : `Ctrl+${key}`; + return targets; } export function resolveThreadRowClassName(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c0c354763d..1f5af440dd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -31,6 +31,7 @@ import { CSS } from "@dnd-kit/utilities"; import { DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, + type KeybindingCommand, ProjectId, ThreadId, type GitStatusResult, @@ -46,7 +47,11 @@ import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; -import { shortcutLabelForCommand } from "../keybindings"; +import { + parseThreadJumpIndex, + resolveShortcutCommand, + shortcutLabelForCommand, +} from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; @@ -90,12 +95,9 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { - formatThreadJumpHintLabel, getFallbackThreadIdAfterDelete, - getThreadJumpKey, + getVisibleThreadJumpTargets, getVisibleThreadsForProject, - isThreadJumpModifierPressed, - resolveThreadJumpIndex, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -103,7 +105,6 @@ import { shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, sortThreadsForSidebar, - type ThreadJumpKey, } from "./Sidebar.logic"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; @@ -411,6 +412,7 @@ export default function Sidebar() { ReadonlySet >(() => new Set()); const [showThreadJumpHints, setShowThreadJumpHints] = useState(false); + const showThreadJumpHintsRef = useRef(false); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const dragInProgressRef = useRef(false); @@ -423,7 +425,6 @@ export default function Sidebar() { const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); - const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const projectCwdById = useMemo( @@ -1175,44 +1176,57 @@ export default function Sidebar() { threads, ], ); - const threadJumpKeyById = useMemo(() => { - const mapping = new Map(); - let visibleThreadIndex = 0; - - for (const renderedProject of renderedProjects) { - for (const thread of renderedProject.renderedThreads) { - const jumpKey = getThreadJumpKey(visibleThreadIndex); - if (!jumpKey) { - return mapping; - } - mapping.set(thread.id, jumpKey); - visibleThreadIndex += 1; - } + const jumpTargets = useMemo( + () => getVisibleThreadJumpTargets(renderedProjects), + [renderedProjects], + ); + const jumpTargetSet = useMemo(() => { + const mapping = new Map(); + for (let i = 0; i < jumpTargets.length; i++) { + mapping.set(jumpTargets[i]!, i); } - return mapping; - }, [renderedProjects]); - const threadJumpThreadIds = useMemo(() => [...threadJumpKeyById.keys()], [threadJumpKeyById]); + }, [jumpTargets]); + + // Derive the hint modifier from the actual keybindings so hints adapt if + // the user remaps thread jump shortcuts to a different modifier. + const threadJumpUsesModKey = useMemo( + () => + keybindings.some( + (binding) => binding.command.startsWith("thread.jump.") && binding.shortcut.modKey, + ), + [keybindings], + ); useEffect(() => { - const onWindowKeyDown = (event: KeyboardEvent) => { - if (isThreadJumpModifierPressed(event, platform)) { - setShowThreadJumpHints(true); + const updateHintVisibility = (visible: boolean) => { + if (showThreadJumpHintsRef.current !== visible) { + showThreadJumpHintsRef.current = visible; + setShowThreadJumpHints(visible); } + }; - if (event.defaultPrevented || event.repeat) { - return; - } + const isModHeld = (event: KeyboardEvent) => { + if (!threadJumpUsesModKey) return false; + const isMac = isMacPlatform(navigator.platform); + return (isMac ? event.metaKey : event.ctrlKey) && !event.altKey && !event.shiftKey; + }; - const jumpIndex = resolveThreadJumpIndex(event, platform); - if (jumpIndex === null) { - return; + const onWindowKeyDown = (event: KeyboardEvent) => { + if (isModHeld(event)) { + updateHintVisibility(true); } - const targetThreadId = threadJumpThreadIds[jumpIndex]; - if (!targetThreadId) { - return; - } + if (event.defaultPrevented || event.repeat) return; + + const command = resolveShortcutCommand(event, keybindings); + if (!command) return; + + const jumpIndex = parseThreadJumpIndex(command); + if (jumpIndex === null) return; + + const targetThreadId = jumpTargets[jumpIndex]; + if (!targetThreadId) return; event.preventDefault(); event.stopPropagation(); @@ -1220,11 +1234,13 @@ export default function Sidebar() { }; const onWindowKeyUp = (event: KeyboardEvent) => { - setShowThreadJumpHints(isThreadJumpModifierPressed(event, platform)); + if (!isModHeld(event)) { + updateHintVisibility(false); + } }; const onWindowBlur = () => { - setShowThreadJumpHints(false); + updateHintVisibility(false); }; window.addEventListener("keydown", onWindowKeyDown); @@ -1236,7 +1252,7 @@ export default function Sidebar() { window.removeEventListener("keyup", onWindowKeyUp); window.removeEventListener("blur", onWindowBlur); }; - }, [navigateToThread, platform, threadJumpThreadIds]); + }, [jumpTargets, keybindings, navigateToThread, threadJumpUsesModKey]); function renderProjectItem( renderedProject: (typeof renderedProjects)[number], @@ -1256,7 +1272,9 @@ export default function Sidebar() { const isActive = routeThreadId === thread.id; const isSelected = selectedThreadIds.has(thread.id); const isHighlighted = isActive || isSelected; - const jumpKey = threadJumpKeyById.get(thread.id) ?? null; + const jumpIndex = jumpTargetSet.get(thread.id) ?? null; + const jumpCommand = + jumpIndex !== null ? (`thread.jump.${jumpIndex + 1}` as KeybindingCommand) : null; const threadStatus = resolveThreadStatusPill({ thread, hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, @@ -1370,7 +1388,7 @@ export default function Sidebar() { {thread.title} )} -
+
{terminalStatus && ( {formatRelativeTime(thread.updatedAt ?? thread.createdAt)} - {jumpKey ? ( - - {formatThreadJumpHintLabel(jumpKey, platform)} + {showThreadJumpHints && jumpCommand ? ( + + + {shortcutLabelForCommand(keybindings, jumpCommand)} + ) : null}
diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 0ecccf43f8..439040d3f3 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -17,6 +17,7 @@ import { isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, + parseThreadJumpIndex, resolveShortcutCommand, shortcutLabelForCommand, terminalNavigationShortcutData, @@ -485,3 +486,24 @@ describe("plus key parsing", () => { ); }); }); + +describe("parseThreadJumpIndex", () => { + it("returns zero-based index for valid thread jump commands", () => { + assert.strictEqual(parseThreadJumpIndex("thread.jump.1"), 0); + assert.strictEqual(parseThreadJumpIndex("thread.jump.5"), 4); + assert.strictEqual(parseThreadJumpIndex("thread.jump.9"), 8); + }); + + it("returns null for non-thread-jump commands", () => { + assert.isNull(parseThreadJumpIndex("chat.new")); + assert.isNull(parseThreadJumpIndex("terminal.toggle")); + assert.isNull(parseThreadJumpIndex("")); + }); + + it("returns null for invalid digits", () => { + assert.isNull(parseThreadJumpIndex("thread.jump.0")); + assert.isNull(parseThreadJumpIndex("thread.jump.10")); + assert.isNull(parseThreadJumpIndex("thread.jump.")); + assert.isNull(parseThreadJumpIndex("thread.jump.abc")); + }); +}); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 09d9308aad..a3b60a0841 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -230,6 +230,20 @@ export function isOpenFavoriteEditorShortcut( return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options); } +const THREAD_JUMP_COMMAND_PREFIX = "thread.jump."; + +/** + * Parses a thread jump command (e.g. "thread.jump.3") into a zero-based index. + * Returns `null` for non-thread-jump commands. + */ +export function parseThreadJumpIndex(command: string): number | null { + if (!command.startsWith(THREAD_JUMP_COMMAND_PREFIX)) return null; + const digit = command.slice(THREAD_JUMP_COMMAND_PREFIX.length); + const num = Number(digit); + if (num < 1 || num > 9 || !Number.isInteger(num)) return null; + return num - 1; +} + export function isTerminalClearShortcut( event: ShortcutEventLike, platform = navigator.platform, diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 48821b1824..3dbf7e76e7 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -16,6 +16,15 @@ const STATIC_KEYBINDING_COMMANDS = [ "chat.new", "chat.newLocal", "editor.openFavorite", + "thread.jump.1", + "thread.jump.2", + "thread.jump.3", + "thread.jump.4", + "thread.jump.5", + "thread.jump.6", + "thread.jump.7", + "thread.jump.8", + "thread.jump.9", ] as const; export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([