Skip to content

Commit 6aadae6

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

File tree

7 files changed

+207
-180
lines changed

7 files changed

+207
-180
lines changed

apps/server/src/keybindings.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
7676
{ key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
7777
{ key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
7878
{ key: "mod+o", command: "editor.openFavorite" },
79+
{ key: "mod+1", command: "thread.jump.1" },
80+
{ key: "mod+2", command: "thread.jump.2" },
81+
{ key: "mod+3", command: "thread.jump.3" },
82+
{ key: "mod+4", command: "thread.jump.4" },
83+
{ key: "mod+5", command: "thread.jump.5" },
84+
{ key: "mod+6", command: "thread.jump.6" },
85+
{ key: "mod+7", command: "thread.jump.7" },
86+
{ key: "mod+8", command: "thread.jump.8" },
87+
{ key: "mod+9", command: "thread.jump.9" },
7988
];
8089

8190
function normalizeKeyToken(token: string): string {

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

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

33
import {
4-
formatThreadJumpHintLabel,
54
getFallbackThreadIdAfterDelete,
6-
getThreadJumpKey,
5+
getVisibleThreadJumpTargets,
76
getVisibleThreadsForProject,
87
getProjectSortTimestamp,
98
hasUnseenCompletion,
10-
isThreadJumpModifierPressed,
11-
resolveThreadJumpIndex,
129
resolveProjectStatusIndicator,
1310
resolveSidebarNewThreadEnvMode,
1411
resolveThreadRowClassName,
@@ -100,94 +97,71 @@ describe("resolveSidebarNewThreadEnvMode", () => {
10097
});
10198
});
10299

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();
100+
function makeJumpProject(
101+
expanded: boolean,
102+
threadIds: ThreadId[],
103+
shouldShowThreadPanel = expanded,
104+
) {
105+
return {
106+
project: { expanded },
107+
renderedThreads: threadIds.map((id) => ({ id })),
108+
shouldShowThreadPanel,
109+
};
110+
}
111+
112+
describe("getVisibleThreadJumpTargets", () => {
113+
function tid(n: number): ThreadId {
114+
return ThreadId.makeUnsafe(`thread-${n}`);
115+
}
116+
117+
it("returns thread IDs from expanded projects only", () => {
118+
const targets = getVisibleThreadJumpTargets([
119+
makeJumpProject(true, [tid(1), tid(2)]),
120+
makeJumpProject(false, [tid(3)]),
121+
makeJumpProject(true, [tid(4)]),
122+
]);
123+
124+
expect(targets).toEqual([tid(1), tid(2), tid(4)]);
108125
});
109126

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);
127+
it("skips projects where shouldShowThreadPanel is false", () => {
128+
const targets = getVisibleThreadJumpTargets([
129+
makeJumpProject(true, [tid(1)], false),
130+
makeJumpProject(true, [tid(2)], true),
131+
]);
132+
133+
expect(targets).toEqual([tid(2)]);
147134
});
148135

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();
136+
it("caps at 9 targets", () => {
137+
const allThreads = Array.from({ length: 12 }, (_, i) => tid(i));
138+
const targets = getVisibleThreadJumpTargets([makeJumpProject(true, allThreads)]);
139+
140+
expect(targets).toHaveLength(9);
141+
expect(targets).toEqual(allThreads.slice(0, 9));
142+
});
143+
144+
it("returns empty array when all projects are collapsed", () => {
145+
const targets = getVisibleThreadJumpTargets([
146+
makeJumpProject(false, [tid(1), tid(2)]),
147+
makeJumpProject(false, [tid(3)]),
148+
]);
149+
150+
expect(targets).toEqual([]);
186151
});
187152

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");
153+
it("returns empty array for no projects", () => {
154+
expect(getVisibleThreadJumpTargets([])).toEqual([]);
155+
});
156+
157+
it("preserves order across multiple expanded projects", () => {
158+
const targets = getVisibleThreadJumpTargets([
159+
makeJumpProject(true, [tid(1), tid(2)]),
160+
makeJumpProject(true, [tid(3), tid(4)]),
161+
makeJumpProject(true, [tid(5)]),
162+
]);
163+
164+
expect(targets).toEqual([tid(1), tid(2), tid(3), tid(4), tid(5)]);
191165
});
192166
});
193167

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

Lines changed: 29 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
2+
import type { ThreadId } from "@t3tools/contracts";
23
import type { Thread } from "../types";
3-
import { cn, isMacPlatform } from "../lib/utils";
4+
import { cn } from "../lib/utils";
45
import {
56
findLatestProposedPlan,
67
hasActionableProposedPlan,
78
isLatestTurnSettled,
89
} from "../session-logic";
910

1011
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;
12+
const MAX_THREAD_JUMP_TARGETS = 9;
1213
export type SidebarNewThreadEnvMode = "local" | "worktree";
1314
type SidebarProject = {
1415
id: string;
@@ -17,16 +18,6 @@ type SidebarProject = {
1718
updatedAt?: string | undefined;
1819
};
1920
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-
}
3021

3122
export interface ThreadStatusPill {
3223
label:
@@ -78,36 +69,34 @@ export function resolveSidebarNewThreadEnvMode(input: {
7869
return input.requestedEnvMode ?? input.defaultEnvMode;
7970
}
8071

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;
72+
/**
73+
* Returns an ordered array of thread IDs eligible for Cmd/Ctrl+N jump shortcuts.
74+
*
75+
* Only threads that are actually visible in the sidebar are counted:
76+
* - Collapsed projects are skipped entirely.
77+
* - Threads hidden behind "show more" are excluded (already filtered by `renderedThreads`).
78+
* - At most 9 targets are returned (matching Cmd+1 through Cmd+9).
79+
*/
80+
export function getVisibleThreadJumpTargets(
81+
renderedProjects: ReadonlyArray<{
82+
project: { expanded: boolean };
83+
renderedThreads: ReadonlyArray<{ id: ThreadId }>;
84+
shouldShowThreadPanel: boolean;
85+
}>,
86+
): ThreadId[] {
87+
const targets: ThreadId[] = [];
88+
89+
for (const entry of renderedProjects) {
90+
if (!entry.project.expanded) continue;
91+
if (!entry.shouldShowThreadPanel) continue;
92+
93+
for (const thread of entry.renderedThreads) {
94+
targets.push(thread.id);
95+
if (targets.length >= MAX_THREAD_JUMP_TARGETS) return targets;
96+
}
10097
}
10198

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}`;
99+
return targets;
111100
}
112101

113102
export function resolveThreadRowClassName(input: {

0 commit comments

Comments
 (0)