Skip to content

Commit aa91042

Browse files
committed
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
1 parent add5f34 commit aa91042

File tree

3 files changed

+309
-43
lines changed

3 files changed

+309
-43
lines changed

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { describe, expect, it } from "vitest";
22

33
import {
4+
formatThreadJumpHintLabel,
45
getFallbackThreadIdAfterDelete,
6+
getThreadJumpKey,
57
getVisibleThreadsForProject,
68
getProjectSortTimestamp,
79
hasUnseenCompletion,
10+
isThreadJumpModifierPressed,
11+
resolveThreadJumpIndex,
812
resolveProjectStatusIndicator,
913
resolveSidebarNewThreadEnvMode,
1014
resolveThreadRowClassName,
@@ -96,6 +100,97 @@ describe("resolveSidebarNewThreadEnvMode", () => {
96100
});
97101
});
98102

103+
describe("thread jump helpers", () => {
104+
it("assigns jump keys for the first nine visible threads", () => {
105+
expect(getThreadJumpKey(0)).toBe("1");
106+
expect(getThreadJumpKey(8)).toBe("9");
107+
expect(getThreadJumpKey(9)).toBeNull();
108+
});
109+
110+
it("detects the active jump modifier by platform", () => {
111+
expect(
112+
isThreadJumpModifierPressed(
113+
{
114+
key: "Meta",
115+
metaKey: true,
116+
ctrlKey: false,
117+
shiftKey: false,
118+
altKey: false,
119+
},
120+
"MacIntel",
121+
),
122+
).toBe(true);
123+
expect(
124+
isThreadJumpModifierPressed(
125+
{
126+
key: "Control",
127+
metaKey: false,
128+
ctrlKey: true,
129+
shiftKey: false,
130+
altKey: false,
131+
},
132+
"Win32",
133+
),
134+
).toBe(true);
135+
expect(
136+
isThreadJumpModifierPressed(
137+
{
138+
key: "Control",
139+
metaKey: false,
140+
ctrlKey: true,
141+
shiftKey: true,
142+
altKey: false,
143+
},
144+
"Win32",
145+
),
146+
).toBe(false);
147+
});
148+
149+
it("resolves mod+digit events to zero-based visible thread indices", () => {
150+
expect(
151+
resolveThreadJumpIndex(
152+
{
153+
key: "1",
154+
metaKey: true,
155+
ctrlKey: false,
156+
shiftKey: false,
157+
altKey: false,
158+
},
159+
"MacIntel",
160+
),
161+
).toBe(0);
162+
expect(
163+
resolveThreadJumpIndex(
164+
{
165+
key: "9",
166+
metaKey: false,
167+
ctrlKey: true,
168+
shiftKey: false,
169+
altKey: false,
170+
},
171+
"Linux",
172+
),
173+
).toBe(8);
174+
expect(
175+
resolveThreadJumpIndex(
176+
{
177+
key: "0",
178+
metaKey: false,
179+
ctrlKey: true,
180+
shiftKey: false,
181+
altKey: false,
182+
},
183+
"Linux",
184+
),
185+
).toBeNull();
186+
});
187+
188+
it("formats thread jump hint labels for macOS and non-macOS", () => {
189+
expect(formatThreadJumpHintLabel("3", "MacIntel")).toBe("⌘3");
190+
expect(formatThreadJumpHintLabel("3", "Linux")).toBe("Ctrl+3");
191+
});
192+
});
193+
99194
describe("resolveThreadStatusPill", () => {
100195
const baseThread = {
101196
interactionMode: "plan" as const,

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

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
22
import type { Thread } from "../types";
3-
import { cn } from "../lib/utils";
3+
import { cn, isMacPlatform } from "../lib/utils";
44
import {
55
findLatestProposedPlan,
66
hasActionableProposedPlan,
77
isLatestTurnSettled,
88
} from "../session-logic";
99

1010
export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
11+
const THREAD_JUMP_KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] as const;
1112
export type SidebarNewThreadEnvMode = "local" | "worktree";
1213
type SidebarProject = {
1314
id: string;
@@ -16,6 +17,16 @@ type SidebarProject = {
1617
updatedAt?: string | undefined;
1718
};
1819
type SidebarThreadSortInput = Pick<Thread, "createdAt" | "updatedAt" | "messages">;
20+
type ThreadJumpKey = (typeof THREAD_JUMP_KEYS)[number];
21+
export type { ThreadJumpKey };
22+
23+
export interface ThreadJumpEvent {
24+
key: string;
25+
metaKey: boolean;
26+
ctrlKey: boolean;
27+
shiftKey: boolean;
28+
altKey: boolean;
29+
}
1930

2031
export interface ThreadStatusPill {
2132
label:
@@ -67,6 +78,38 @@ export function resolveSidebarNewThreadEnvMode(input: {
6778
return input.requestedEnvMode ?? input.defaultEnvMode;
6879
}
6980

81+
export function getThreadJumpKey(index: number): ThreadJumpKey | null {
82+
return THREAD_JUMP_KEYS[index] ?? null;
83+
}
84+
85+
export function isThreadJumpModifierPressed(
86+
event: ThreadJumpEvent,
87+
platform = navigator.platform,
88+
): boolean {
89+
return (
90+
(isMacPlatform(platform) ? event.metaKey : event.ctrlKey) && !event.altKey && !event.shiftKey
91+
);
92+
}
93+
94+
export function resolveThreadJumpIndex(
95+
event: ThreadJumpEvent,
96+
platform = navigator.platform,
97+
): number | null {
98+
if (!isThreadJumpModifierPressed(event, platform)) {
99+
return null;
100+
}
101+
102+
const index = THREAD_JUMP_KEYS.indexOf(event.key as ThreadJumpKey);
103+
return index === -1 ? null : index;
104+
}
105+
106+
export function formatThreadJumpHintLabel(
107+
key: ThreadJumpKey,
108+
platform = navigator.platform,
109+
): string {
110+
return isMacPlatform(platform) ? `⌘${key}` : `Ctrl+${key}`;
111+
}
112+
70113
export function resolveThreadRowClassName(input: {
71114
isActive: boolean;
72115
isSelected: boolean;

0 commit comments

Comments
 (0)