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 dcfe3f6b69..80e61535c3 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getFallbackThreadIdAfterDelete, + getVisibleThreadJumpTargets, getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, @@ -96,6 +97,74 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); +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("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("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("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)]); + }); +}); + 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..794a389904 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,5 @@ import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import type { ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { cn } from "../lib/utils"; import { @@ -8,6 +9,7 @@ import { } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; +const MAX_THREAD_JUMP_TARGETS = 9; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; @@ -67,6 +69,36 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } +/** + * 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; + } + } + + return targets; +} + 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..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"; @@ -91,6 +96,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { isNonEmpty as isNonEmptyString } from "effect/String"; import { getFallbackThreadIdAfterDelete, + getVisibleThreadJumpTargets, getVisibleThreadsForProject, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, @@ -405,6 +411,8 @@ export default function Sidebar() { const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); + const [showThreadJumpHints, setShowThreadJumpHints] = useState(false); + const showThreadJumpHintsRef = useRef(false); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const dragInProgressRef = useRef(false); @@ -974,6 +982,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 +1125,156 @@ 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 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; + }, [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 updateHintVisibility = (visible: boolean) => { + if (showThreadJumpHintsRef.current !== visible) { + showThreadJumpHintsRef.current = visible; + setShowThreadJumpHints(visible); + } + }; + + const isModHeld = (event: KeyboardEvent) => { + if (!threadJumpUsesModKey) return false; + const isMac = isMacPlatform(navigator.platform); + return (isMac ? event.metaKey : event.ctrlKey) && !event.altKey && !event.shiftKey; + }; + + const onWindowKeyDown = (event: KeyboardEvent) => { + if (isModHeld(event)) { + updateHintVisibility(true); + } + + 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(); + navigateToThread(targetThreadId); + }; + + const onWindowKeyUp = (event: KeyboardEvent) => { + if (!isModHeld(event)) { + updateHintVisibility(false); + } + }; + + const onWindowBlur = () => { + updateHintVisibility(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); + }; + }, [jumpTargets, keybindings, navigateToThread, threadJumpUsesModKey]); 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 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, @@ -1166,14 +1301,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(); @@ -1260,7 +1388,7 @@ export default function Sidebar() { {thread.title} )} -
+
{terminalStatus && ( {formatRelativeTime(thread.updatedAt ?? thread.createdAt)} + {showThreadJumpHints && jumpCommand ? ( + + + {shortcutLabelForCommand(keybindings, jumpCommand)} + + + ) : null}
@@ -1802,9 +1937,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 +1950,9 @@ export default function Sidebar() { ) : ( - {sortedProjects.map((project) => ( - - {renderProjectItem(project, null)} + {renderedProjects.map((renderedProject) => ( + + {renderProjectItem(renderedProject, 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([