authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-03-28 18:23:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-28 18:23:30-07:00
log58ba4f12633ef05f7a8ab707cba4cd7ccaad0d58
treeac847e05ac0eaac9a77075c7de40d6e51b84ee27
parent5513845fd56acf7a019d24e72fc4e11bf5654f50
signature Signed by PGP key B5690EEEBB952194

Add keyboard shortcuts for jumping to sidebar threads (#1456)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

10 files changed, 713 insertions(+), 81 deletions(-)

apps/server/src/keybindings.test.ts+13
......@@ -165,6 +165,19 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
165165 }).pipe(Effect.provide(makeKeybindingsLayer())),
166166 );
167167
168 it.effect("ships configurable thread navigation defaults", () =>
169 Effect.sync(() => {
170 const defaultsByCommand = new Map(
171 DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const),
172 );
173
174 assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+[");
175 assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");
176 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
177 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
178 }),
179 );
180
168181 it.effect("uses defaults in runtime when config is malformed without overriding file", () =>
169182 Effect.gen(function* () {
170183 const fs = yield* FileSystem.FileSystem;
apps/server/src/keybindings.ts+7
......@@ -15,6 +15,7 @@ import {
1515 MAX_WHEN_EXPRESSION_DEPTH,
1616 ResolvedKeybindingRule,
1717 ResolvedKeybindingsConfig,
18 THREAD_JUMP_KEYBINDING_COMMANDS,
1819 type ServerConfigIssue,
1920} from "@t3tools/contracts";
2021import { Mutable } from "effect/Types";
......@@ -76,6 +77,12 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
7677 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
7778 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
7879 { key: "mod+o", command: "editor.openFavorite" },
80 { key: "mod+shift+[", command: "thread.previous" },
81 { key: "mod+shift+]", command: "thread.next" },
82 ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
83 key: `mod+${index + 1}`,
84 command,
85 })),
7986];
8087
8188function normalizeKeyToken(token: string): string {
apps/web/src/components/ChatView.tsx+26-8
......@@ -1162,25 +1162,43 @@ export default function ChatView({ threadId }: ChatViewProps) {
11621162 }, [activeProjectCwd, activeThreadWorktreePath]);
11631163 // Default true while loading to avoid toolbar flicker.
11641164 const isGitRepo = branchesQuery.data?.isRepo ?? true;
1165 const terminalShortcutLabelOptions = useMemo(
1166 () => ({
1167 context: {
1168 terminalFocus: true,
1169 terminalOpen: Boolean(terminalState.terminalOpen),
1170 },
1171 }),
1172 [terminalState.terminalOpen],
1173 );
1174 const nonTerminalShortcutLabelOptions = useMemo(
1175 () => ({
1176 context: {
1177 terminalFocus: false,
1178 terminalOpen: Boolean(terminalState.terminalOpen),
1179 },
1180 }),
1181 [terminalState.terminalOpen],
1182 );
11651183 const terminalToggleShortcutLabel = useMemo(
11661184 () => shortcutLabelForCommand(keybindings, "terminal.toggle"),
11671185 [keybindings],
11681186 );
11691187 const splitTerminalShortcutLabel = useMemo(
1170 () => shortcutLabelForCommand(keybindings, "terminal.split"),
1171 [keybindings],
1188 () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions),
1189 [keybindings, terminalShortcutLabelOptions],
11721190 );
11731191 const newTerminalShortcutLabel = useMemo(
1174 () => shortcutLabelForCommand(keybindings, "terminal.new"),
1175 [keybindings],
1192 () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions),
1193 [keybindings, terminalShortcutLabelOptions],
11761194 );
11771195 const closeTerminalShortcutLabel = useMemo(
1178 () => shortcutLabelForCommand(keybindings, "terminal.close"),
1179 [keybindings],
1196 () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions),
1197 [keybindings, terminalShortcutLabelOptions],
11801198 );
11811199 const diffPanelShortcutLabel = useMemo(
1182 () => shortcutLabelForCommand(keybindings, "diff.toggle"),
1183 [keybindings],
1200 () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions),
1201 [keybindings, nonTerminalShortcutLabelOptions],
11841202 );
11851203 const onToggleDiff = useCallback(() => {
11861204 void navigate({
apps/web/src/components/Sidebar.logic.test.ts+76
......@@ -1,6 +1,8 @@
11import { describe, expect, it } from "vitest";
22
33import {
4 getVisibleSidebarThreadIds,
5 resolveAdjacentThreadId,
46 getFallbackThreadIdAfterDelete,
57 getVisibleThreadsForProject,
68 getProjectSortTimestamp,
......@@ -97,6 +99,80 @@ describe("resolveSidebarNewThreadEnvMode", () => {
9799 });
98100});
99101
102describe("resolveAdjacentThreadId", () => {
103 it("resolves adjacent thread ids in ordered sidebar traversal", () => {
104 const threads = [
105 ThreadId.makeUnsafe("thread-1"),
106 ThreadId.makeUnsafe("thread-2"),
107 ThreadId.makeUnsafe("thread-3"),
108 ];
109
110 expect(
111 resolveAdjacentThreadId({
112 threadIds: threads,
113 currentThreadId: threads[1] ?? null,
114 direction: "previous",
115 }),
116 ).toBe(threads[0]);
117 expect(
118 resolveAdjacentThreadId({
119 threadIds: threads,
120 currentThreadId: threads[1] ?? null,
121 direction: "next",
122 }),
123 ).toBe(threads[2]);
124 expect(
125 resolveAdjacentThreadId({
126 threadIds: threads,
127 currentThreadId: null,
128 direction: "next",
129 }),
130 ).toBe(threads[0]);
131 expect(
132 resolveAdjacentThreadId({
133 threadIds: threads,
134 currentThreadId: null,
135 direction: "previous",
136 }),
137 ).toBe(threads[2]);
138 expect(
139 resolveAdjacentThreadId({
140 threadIds: threads,
141 currentThreadId: threads[0] ?? null,
142 direction: "previous",
143 }),
144 ).toBeNull();
145 });
146});
147
148describe("getVisibleSidebarThreadIds", () => {
149 it("returns only the rendered visible thread order across projects", () => {
150 expect(
151 getVisibleSidebarThreadIds([
152 {
153 renderedThreads: [
154 { id: ThreadId.makeUnsafe("thread-12") },
155 { id: ThreadId.makeUnsafe("thread-11") },
156 { id: ThreadId.makeUnsafe("thread-10") },
157 ],
158 },
159 {
160 renderedThreads: [
161 { id: ThreadId.makeUnsafe("thread-8") },
162 { id: ThreadId.makeUnsafe("thread-6") },
163 ],
164 },
165 ]),
166 ).toEqual([
167 ThreadId.makeUnsafe("thread-12"),
168 ThreadId.makeUnsafe("thread-11"),
169 ThreadId.makeUnsafe("thread-10"),
170 ThreadId.makeUnsafe("thread-8"),
171 ThreadId.makeUnsafe("thread-6"),
172 ]);
173 });
174});
175
100176describe("isContextMenuPointerDown", () => {
101177 it("treats secondary-button presses as context menu gestures on all platforms", () => {
102178 expect(
apps/web/src/components/Sidebar.logic.ts+41
......@@ -17,6 +17,8 @@ type SidebarProject = {
1717};
1818type SidebarThreadSortInput = Pick<Thread, "createdAt" | "updatedAt" | "messages">;
1919
20export type ThreadTraversalDirection = "previous" | "next";
21
2022export interface ThreadStatusPill {
2123 label:
2224 | "Working"
......@@ -67,6 +69,45 @@ export function resolveSidebarNewThreadEnvMode(input: {
6769 return input.requestedEnvMode ?? input.defaultEnvMode;
6870}
6971
72export function getVisibleSidebarThreadIds<TThreadId>(
73 renderedProjects: readonly {
74 renderedThreads: readonly {
75 id: TThreadId;
76 }[];
77 }[],
78): TThreadId[] {
79 return renderedProjects.flatMap((renderedProject) =>
80 renderedProject.renderedThreads.map((thread) => thread.id),
81 );
82}
83
84export function resolveAdjacentThreadId<T>(input: {
85 threadIds: readonly T[];
86 currentThreadId: T | null;
87 direction: ThreadTraversalDirection;
88}): T | null {
89 const { currentThreadId, direction, threadIds } = input;
90
91 if (threadIds.length === 0) {
92 return null;
93 }
94
95 if (currentThreadId === null) {
96 return direction === "previous" ? (threadIds.at(-1) ?? null) : (threadIds[0] ?? null);
97 }
98
99 const currentIndex = threadIds.indexOf(currentThreadId);
100 if (currentIndex === -1) {
101 return null;
102 }
103
104 if (direction === "previous") {
105 return currentIndex > 0 ? (threadIds[currentIndex - 1] ?? null) : null;
106 }
107
108 return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null;
109}
110
70111export function isContextMenuPointerDown(input: {
71112 button: number;
72113 ctrlKey: boolean;
apps/web/src/components/Sidebar.tsx+260-58
......@@ -53,9 +53,17 @@ import {
5353} from "@t3tools/contracts/settings";
5454import { isElectron } from "../env";
5555import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
56import { isTerminalFocused } from "../lib/terminalFocus";
5657import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
5758import { useStore } from "../store";
58import { shortcutLabelForCommand } from "../keybindings";
59import {
60 resolveShortcutCommand,
61 shortcutLabelForCommand,
62 shouldShowThreadJumpHints,
63 threadJumpCommandForIndex,
64 threadJumpIndexFromCommand,
65 threadTraversalDirectionFromCommand,
66} from "../keybindings";
5967import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
6068import { gitStatusQueryOptions } from "../lib/gitReactQuery";
6169import { serverConfigQueryOptions } from "../lib/serverReactQuery";
......@@ -100,7 +108,9 @@ import {
100108import { useThreadSelectionStore } from "../threadSelectionStore";
101109import { isNonEmpty as isNonEmptyString } from "effect/String";
102110import {
111 getVisibleSidebarThreadIds,
103112 getVisibleThreadsForProject,
113 resolveAdjacentThreadId,
104114 isContextMenuPointerDown,
105115 resolveProjectStatusIndicator,
106116 resolveSidebarNewThreadEnvMode,
......@@ -353,6 +363,7 @@ export default function Sidebar() {
353363 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
354364 ReadonlySet<ProjectId>
355365 >(() => new Set());
366 const [showThreadJumpHints, setShowThreadJumpHints] = useState(false);
356367 const renamingCommittedRef = useRef(false);
357368 const renamingInputRef = useRef<HTMLInputElement | null>(null);
358369 const dragInProgressRef = useRef(false);
......@@ -366,12 +377,26 @@ export default function Sidebar() {
366377 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
367378 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
368379 const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform);
380 const platform = navigator.platform;
369381 const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop;
370382 const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
371383 const projectCwdById = useMemo(
372384 () => new Map(projects.map((project) => [project.id, project.cwd] as const)),
373385 [projects],
374386 );
387 const routeTerminalOpen = routeThreadId
388 ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen
389 : false;
390 const sidebarShortcutLabelOptions = useMemo(
391 () => ({
392 platform,
393 context: {
394 terminalFocus: false,
395 terminalOpen: routeTerminalOpen,
396 },
397 }),
398 [platform, routeTerminalOpen],
399 );
375400 const threadGitTargets = useMemo(
376401 () =>
377402 threads.map((thread) => ({
......@@ -823,6 +848,20 @@ export default function Sidebar() {
823848 ],
824849 );
825850
851 const navigateToThread = useCallback(
852 (threadId: ThreadId) => {
853 if (selectedThreadIds.size > 0) {
854 clearSelection();
855 }
856 setSelectionAnchor(threadId);
857 void navigate({
858 to: "/$threadId",
859 params: { threadId },
860 });
861 },
862 [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor],
863 );
864
826865 const handleProjectContextMenu = useCallback(
827866 async (projectId: ProjectId, position: { x: number; y: number }) => {
828867 const api = readNativeApi();
......@@ -979,45 +1018,201 @@ export default function Sidebar() {
9791018 [appSettings.sidebarProjectSortOrder, projects, visibleThreads],
9801019 );
9811020 const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual";
1021 const renderedProjects = useMemo(
1022 () =>
1023 sortedProjects.map((project) => {
1024 const projectThreads = sortThreadsForSidebar(
1025 visibleThreads.filter((thread) => thread.projectId === project.id),
1026 appSettings.sidebarThreadSortOrder,
1027 );
1028 const projectStatus = resolveProjectStatusIndicator(
1029 projectThreads.map((thread) =>
1030 resolveThreadStatusPill({
1031 thread,
1032 hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
1033 hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,
1034 }),
1035 ),
1036 );
1037 const activeThreadId = routeThreadId ?? undefined;
1038 const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
1039 const pinnedCollapsedThread =
1040 !project.expanded && activeThreadId
1041 ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null)
1042 : null;
1043 const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null;
1044 const { hasHiddenThreads, visibleThreads: visibleProjectThreads } =
1045 getVisibleThreadsForProject({
1046 threads: projectThreads,
1047 activeThreadId,
1048 isThreadListExpanded,
1049 previewLimit: THREAD_PREVIEW_LIMIT,
1050 });
1051 const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);
1052 const renderedThreads = pinnedCollapsedThread
1053 ? [pinnedCollapsedThread]
1054 : visibleProjectThreads;
1055
1056 return {
1057 hasHiddenThreads,
1058 orderedProjectThreadIds,
1059 project,
1060 projectStatus,
1061 projectThreads,
1062 renderedThreads,
1063 shouldShowThreadPanel,
1064 isThreadListExpanded,
1065 };
1066 }),
1067 [
1068 appSettings.sidebarThreadSortOrder,
1069 expandedThreadListsByProject,
1070 routeThreadId,
1071 sortedProjects,
1072 visibleThreads,
1073 ],
1074 );
1075 const threadJumpCommandById = useMemo(() => {
1076 const mapping = new Map<ThreadId, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>();
1077 let visibleThreadIndex = 0;
1078
1079 for (const renderedProject of renderedProjects) {
1080 for (const thread of renderedProject.renderedThreads) {
1081 const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex);
1082 if (!jumpCommand) {
1083 return mapping;
1084 }
1085 mapping.set(thread.id, jumpCommand);
1086 visibleThreadIndex += 1;
1087 }
1088 }
1089
1090 return mapping;
1091 }, [renderedProjects]);
1092 const threadJumpThreadIds = useMemo(
1093 () => [...threadJumpCommandById.keys()],
1094 [threadJumpCommandById],
1095 );
1096 const threadJumpLabelById = useMemo(() => {
1097 const mapping = new Map<ThreadId, string>();
1098 for (const [threadId, command] of threadJumpCommandById) {
1099 const label = shortcutLabelForCommand(keybindings, command, sidebarShortcutLabelOptions);
1100 if (label) {
1101 mapping.set(threadId, label);
1102 }
1103 }
1104 return mapping;
1105 }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]);
1106 const orderedSidebarThreadIds = useMemo(
1107 () => getVisibleSidebarThreadIds(renderedProjects),
1108 [renderedProjects],
1109 );
1110
1111 useEffect(() => {
1112 const getShortcutContext = () => ({
1113 terminalFocus: isTerminalFocused(),
1114 terminalOpen: routeTerminalOpen,
1115 });
1116
1117 const onWindowKeyDown = (event: KeyboardEvent) => {
1118 setShowThreadJumpHints(
1119 shouldShowThreadJumpHints(event, keybindings, {
1120 platform,
1121 context: getShortcutContext(),
1122 }),
1123 );
1124
1125 if (event.defaultPrevented || event.repeat) {
1126 return;
1127 }
1128
1129 const command = resolveShortcutCommand(event, keybindings, {
1130 platform,
1131 context: getShortcutContext(),
1132 });
1133 const traversalDirection = threadTraversalDirectionFromCommand(command);
1134 if (traversalDirection !== null) {
1135 const targetThreadId = resolveAdjacentThreadId({
1136 threadIds: orderedSidebarThreadIds,
1137 currentThreadId: routeThreadId,
1138 direction: traversalDirection,
1139 });
1140 if (!targetThreadId) {
1141 return;
1142 }
1143
1144 event.preventDefault();
1145 event.stopPropagation();
1146 navigateToThread(targetThreadId);
1147 return;
1148 }
1149
1150 const jumpIndex = threadJumpIndexFromCommand(command ?? "");
1151 if (jumpIndex === null) {
1152 return;
1153 }
1154
1155 const targetThreadId = threadJumpThreadIds[jumpIndex];
1156 if (!targetThreadId) {
1157 return;
1158 }
1159
1160 event.preventDefault();
1161 event.stopPropagation();
1162 navigateToThread(targetThreadId);
1163 };
1164
1165 const onWindowKeyUp = (event: KeyboardEvent) => {
1166 setShowThreadJumpHints(
1167 shouldShowThreadJumpHints(event, keybindings, {
1168 platform,
1169 context: getShortcutContext(),
1170 }),
1171 );
1172 };
1173
1174 const onWindowBlur = () => {
1175 setShowThreadJumpHints(false);
1176 };
1177
1178 window.addEventListener("keydown", onWindowKeyDown);
1179 window.addEventListener("keyup", onWindowKeyUp);
1180 window.addEventListener("blur", onWindowBlur);
1181
1182 return () => {
1183 window.removeEventListener("keydown", onWindowKeyDown);
1184 window.removeEventListener("keyup", onWindowKeyUp);
1185 window.removeEventListener("blur", onWindowBlur);
1186 };
1187 }, [
1188 keybindings,
1189 navigateToThread,
1190 orderedSidebarThreadIds,
1191 platform,
1192 routeTerminalOpen,
1193 routeThreadId,
1194 threadJumpThreadIds,
1195 ]);
9821196
9831197 function renderProjectItem(
984 project: (typeof sortedProjects)[number],
1198 renderedProject: (typeof renderedProjects)[number],
9851199 dragHandleProps: SortableProjectHandleProps | null,
9861200 ) {
987 const projectThreads = sortThreadsForSidebar(
988 visibleThreads.filter((thread) => thread.projectId === project.id),
989 appSettings.sidebarThreadSortOrder,
990 );
991 const projectStatus = resolveProjectStatusIndicator(
992 projectThreads.map((thread) =>
993 resolveThreadStatusPill({
994 thread,
995 hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
996 hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,
997 }),
998 ),
999 );
1000 const activeThreadId = routeThreadId ?? undefined;
1001 const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
1002 const pinnedCollapsedThread =
1003 !project.expanded && activeThreadId
1004 ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null)
1005 : null;
1006 const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null;
1007 const { hasHiddenThreads, visibleThreads: visibleProjectThreads } = getVisibleThreadsForProject(
1008 {
1009 threads: projectThreads,
1010 activeThreadId,
1011 isThreadListExpanded,
1012 previewLimit: THREAD_PREVIEW_LIMIT,
1013 },
1014 );
1015 const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);
1016 const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleProjectThreads;
1201 const {
1202 hasHiddenThreads,
1203 orderedProjectThreadIds,
1204 project,
1205 projectStatus,
1206 projectThreads,
1207 renderedThreads,
1208 shouldShowThreadPanel,
1209 isThreadListExpanded,
1210 } = renderedProject;
10171211 const renderThreadRow = (thread: (typeof projectThreads)[number]) => {
10181212 const isActive = routeThreadId === thread.id;
10191213 const isSelected = selectedThreadIds.has(thread.id);
10201214 const isHighlighted = isActive || isSelected;
1215 const jumpLabel = threadJumpLabelById.get(thread.id) ?? null;
10211216 const isThreadRunning =
10221217 thread.session?.status === "running" && thread.session.activeTurnId != null;
10231218 const threadStatus = resolveThreadStatusPill({
......@@ -1057,14 +1252,7 @@ export default function Sidebar() {
10571252 onKeyDown={(event) => {
10581253 if (event.key !== "Enter" && event.key !== " ") return;
10591254 event.preventDefault();
1060 if (selectedThreadIds.size > 0) {
1061 clearSelection();
1062 }
1063 setSelectionAnchor(thread.id);
1064 void navigate({
1065 to: "/$threadId",
1066 params: { threadId: thread.id },
1067 });
1255 navigateToThread(thread.id);
10681256 }}
10691257 onContextMenu={(event) => {
10701258 event.preventDefault();
......@@ -1234,15 +1422,26 @@ export default function Sidebar() {
12341422 </Tooltip>
12351423 )
12361424 ) : (
1237 <span
1238 className={`text-[10px] ${
1239 isHighlighted
1240 ? "text-foreground/72 dark:text-foreground/82"
1241 : "text-muted-foreground/40"
1242 }`}
1243 >
1244 {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}
1245 </span>
1425 <>
1426 {showThreadJumpHints && jumpLabel ? (
1427 <span
1428 className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"
1429 title={jumpLabel}
1430 >
1431 {jumpLabel}
1432 </span>
1433 ) : (
1434 <span
1435 className={`text-[10px] ${
1436 isHighlighted
1437 ? "text-foreground/72 dark:text-foreground/82"
1438 : "text-muted-foreground/40"
1439 }`}
1440 >
1441 {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}
1442 </span>
1443 )}
1444 </>
12461445 )}
12471446 </div>
12481447 </div>
......@@ -1490,8 +1689,8 @@ export default function Sidebar() {
14901689 ? "text-rose-500 animate-pulse"
14911690 : "text-amber-500 animate-pulse";
14921691 const newThreadShortcutLabel =
1493 shortcutLabelForCommand(keybindings, "chat.newLocal") ??
1494 shortcutLabelForCommand(keybindings, "chat.new");
1692 shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ??
1693 shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions);
14951694
14961695 const handleDesktopUpdateButtonClick = useCallback(() => {
14971696 const bridge = window.desktopBridge;
......@@ -1773,12 +1972,15 @@ export default function Sidebar() {
17731972 >
17741973 <SidebarMenu>
17751974 <SortableContext
1776 items={sortedProjects.map((project) => project.id)}
1975 items={renderedProjects.map((renderedProject) => renderedProject.project.id)}
17771976 strategy={verticalListSortingStrategy}
17781977 >
1779 {sortedProjects.map((project) => (
1780 <SortableProjectItem key={project.id} projectId={project.id}>
1781 {(dragHandleProps) => renderProjectItem(project, dragHandleProps)}
1978 {renderedProjects.map((renderedProject) => (
1979 <SortableProjectItem
1980 key={renderedProject.project.id}
1981 projectId={renderedProject.project.id}
1982 >
1983 {(dragHandleProps) => renderProjectItem(renderedProject, dragHandleProps)}
17821984 </SortableProjectItem>
17831985 ))}
17841986 </SortableContext>
......@@ -1786,9 +1988,9 @@ export default function Sidebar() {
17861988 </DndContext>
17871989 ) : (
17881990 <SidebarMenu ref={attachProjectListAutoAnimateRef}>
1789 {sortedProjects.map((project) => (
1790 <SidebarMenuItem key={project.id} className="rounded-md">
1791 {renderProjectItem(project, null)}
1991 {renderedProjects.map((renderedProject) => (
1992 <SidebarMenuItem key={renderedProject.project.id} className="rounded-md">
1993 {renderProjectItem(renderedProject, null)}
17921994 </SidebarMenuItem>
17931995 ))}
17941996 </SidebarMenu>
apps/web/src/keybindings.test.ts+124-3
......@@ -18,8 +18,12 @@ import {
1818 isTerminalSplitShortcut,
1919 isTerminalToggleShortcut,
2020 resolveShortcutCommand,
21 shouldShowThreadJumpHints,
2122 shortcutLabelForCommand,
2223 terminalNavigationShortcutData,
24 threadJumpCommandForIndex,
25 threadJumpIndexFromCommand,
26 threadTraversalDirectionFromCommand,
2327 type ShortcutEventLike,
2428} from "./keybindings";
2529
......@@ -100,6 +104,11 @@ const DEFAULT_BINDINGS = compile([
100104 { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" },
101105 { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" },
102106 { shortcut: modShortcut("o"), command: "editor.openFavorite" },
107 { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" },
108 { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" },
109 { shortcut: modShortcut("1"), command: "thread.jump.1" },
110 { shortcut: modShortcut("2"), command: "thread.jump.2" },
111 { shortcut: modShortcut("3"), command: "thread.jump.3" },
103112]);
104113
105114describe("isTerminalToggleShortcut", () => {
......@@ -215,7 +224,7 @@ describe("split/new/close terminal shortcuts", () => {
215224});
216225
217226describe("shortcutLabelForCommand", () => {
218 it("returns the most recent binding label", () => {
227 it("returns the effective binding label", () => {
219228 const bindings = compile([
220229 {
221230 shortcut: modShortcut("\\"),
......@@ -229,18 +238,107 @@ describe("shortcutLabelForCommand", () => {
229238 },
230239 ]);
231240 assert.strictEqual(
232 shortcutLabelForCommand(bindings, "terminal.split", "Linux"),
241 shortcutLabelForCommand(bindings, "terminal.split", {
242 platform: "Linux",
243 context: { terminalFocus: false },
244 }),
233245 "Ctrl+Shift+\\",
234246 );
235247 });
236248
237 it("returns labels for non-terminal commands", () => {
249 it("returns effective labels for non-terminal commands", () => {
238250 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");
239251 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D");
240252 assert.strictEqual(
241253 shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"),
242254 "Ctrl+O",
243255 );
256 assert.strictEqual(
257 shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.jump.3", "MacIntel"),
258 "⌘3",
259 );
260 assert.strictEqual(
261 shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.previous", "Linux"),
262 "Ctrl+Shift+[",
263 );
264 });
265
266 it("returns null for commands shadowed by a later conflicting shortcut", () => {
267 const bindings = compile([
268 { shortcut: modShortcut("1", { shiftKey: true }), command: "thread.jump.1" },
269 { shortcut: modShortcut("1", { shiftKey: true }), command: "thread.jump.7" },
270 ]);
271
272 assert.isNull(shortcutLabelForCommand(bindings, "thread.jump.1", "MacIntel"));
273 assert.strictEqual(shortcutLabelForCommand(bindings, "thread.jump.7", "MacIntel"), "⇧⌘1");
274 });
275
276 it("respects when-context while resolving labels", () => {
277 const bindings = compile([
278 { shortcut: modShortcut("d"), command: "diff.toggle" },
279 {
280 shortcut: modShortcut("d"),
281 command: "terminal.split",
282 whenAst: whenIdentifier("terminalFocus"),
283 },
284 ]);
285
286 assert.strictEqual(
287 shortcutLabelForCommand(bindings, "diff.toggle", {
288 platform: "Linux",
289 context: { terminalFocus: false },
290 }),
291 "Ctrl+D",
292 );
293 assert.isNull(
294 shortcutLabelForCommand(bindings, "diff.toggle", {
295 platform: "Linux",
296 context: { terminalFocus: true },
297 }),
298 );
299 assert.strictEqual(
300 shortcutLabelForCommand(bindings, "terminal.split", {
301 platform: "Linux",
302 context: { terminalFocus: true },
303 }),
304 "Ctrl+D",
305 );
306 });
307});
308
309describe("thread navigation helpers", () => {
310 it("maps jump commands to visible thread indices", () => {
311 assert.strictEqual(threadJumpCommandForIndex(0), "thread.jump.1");
312 assert.strictEqual(threadJumpCommandForIndex(2), "thread.jump.3");
313 assert.isNull(threadJumpCommandForIndex(9));
314 assert.strictEqual(threadJumpIndexFromCommand("thread.jump.1"), 0);
315 assert.strictEqual(threadJumpIndexFromCommand("thread.jump.3"), 2);
316 assert.isNull(threadJumpIndexFromCommand("thread.next"));
317 });
318
319 it("maps traversal commands to directions", () => {
320 assert.strictEqual(threadTraversalDirectionFromCommand("thread.previous"), "previous");
321 assert.strictEqual(threadTraversalDirectionFromCommand("thread.next"), "next");
322 assert.isNull(threadTraversalDirectionFromCommand("thread.jump.1"));
323 assert.isNull(threadTraversalDirectionFromCommand(null));
324 });
325
326 it("shows jump hints only when configured modifiers match", () => {
327 assert.isTrue(
328 shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, {
329 platform: "MacIntel",
330 }),
331 );
332 assert.isFalse(
333 shouldShowThreadJumpHints(event({ metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
334 platform: "MacIntel",
335 }),
336 );
337 assert.isTrue(
338 shouldShowThreadJumpHints(event({ ctrlKey: true }), DEFAULT_BINDINGS, {
339 platform: "Linux",
340 }),
341 );
244342 });
245343});
246344
......@@ -373,6 +471,29 @@ describe("resolveShortcutCommand", () => {
373471 "script.setup.run",
374472 );
375473 });
474
475 it("matches bracket shortcuts using the physical key code", () => {
476 assert.strictEqual(
477 resolveShortcutCommand(
478 event({ key: "{", code: "BracketLeft", metaKey: true, shiftKey: true }),
479 DEFAULT_BINDINGS,
480 {
481 platform: "MacIntel",
482 },
483 ),
484 "thread.previous",
485 );
486 assert.strictEqual(
487 resolveShortcutCommand(
488 event({ key: "}", code: "BracketRight", ctrlKey: true, shiftKey: true }),
489 DEFAULT_BINDINGS,
490 {
491 platform: "Linux",
492 },
493 ),
494 "thread.next",
495 );
496 });
376497});
377498
378499describe("formatShortcutLabel", () => {
apps/web/src/keybindings.ts+127-11
......@@ -3,11 +3,14 @@ import {
33 type KeybindingShortcut,
44 type KeybindingWhenNode,
55 type ResolvedKeybindingsConfig,
6 THREAD_JUMP_KEYBINDING_COMMANDS,
7 type ThreadJumpKeybindingCommand,
68} from "@t3tools/contracts";
79import { isMacPlatform } from "./lib/utils";
810
911export interface ShortcutEventLike {
1012 type?: string;
13 code?: string;
1114 key: string;
1215 metaKey: boolean;
1316 ctrlKey: boolean;
......@@ -26,10 +29,28 @@ interface ShortcutMatchOptions {
2629 context?: Partial<ShortcutMatchContext>;
2730}
2831
32interface ResolvedShortcutLabelOptions extends ShortcutMatchOptions {
33 platform?: string;
34}
35
2936const TERMINAL_WORD_BACKWARD = "\u001bb";
3037const TERMINAL_WORD_FORWARD = "\u001bf";
3138const TERMINAL_LINE_START = "\u0001";
3239const TERMINAL_LINE_END = "\u0005";
40const EVENT_CODE_KEY_ALIASES: Readonly<Record<string, readonly string[]>> = {
41 BracketLeft: ["["],
42 BracketRight: ["]"],
43 Digit0: ["0"],
44 Digit1: ["1"],
45 Digit2: ["2"],
46 Digit3: ["3"],
47 Digit4: ["4"],
48 Digit5: ["5"],
49 Digit6: ["6"],
50 Digit7: ["7"],
51 Digit8: ["8"],
52 Digit9: ["9"],
53};
3354
3455function normalizeEventKey(key: string): string {
3556 const normalized = key.toLowerCase();
......@@ -37,14 +58,22 @@ function normalizeEventKey(key: string): string {
3758 return normalized;
3859}
3960
40function matchesShortcut(
61function resolveEventKeys(event: ShortcutEventLike): Set<string> {
62 const keys = new Set([normalizeEventKey(event.key)]);
63 const aliases = event.code ? EVENT_CODE_KEY_ALIASES[event.code] : undefined;
64 if (!aliases) return keys;
65
66 for (const alias of aliases) {
67 keys.add(alias);
68 }
69 return keys;
70}
71
72function matchesShortcutModifiers(
4173 event: ShortcutEventLike,
4274 shortcut: KeybindingShortcut,
4375 platform = navigator.platform,
4476): boolean {
45 const key = normalizeEventKey(event.key);
46 if (key !== shortcut.key) return false;
47
4877 const useMetaForMod = isMacPlatform(platform);
4978 const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
5079 const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
......@@ -56,6 +85,15 @@ function matchesShortcut(
5685 );
5786}
5887
88function matchesShortcut(
89 event: ShortcutEventLike,
90 shortcut: KeybindingShortcut,
91 platform = navigator.platform,
92): boolean {
93 if (!matchesShortcutModifiers(event, shortcut, platform)) return false;
94 return resolveEventKeys(event).has(shortcut.key);
95}
96
5997function resolvePlatform(options: ShortcutMatchOptions | undefined): string {
6098 return options?.platform ?? navigator.platform;
6199}
......@@ -91,6 +129,48 @@ function matchesWhenClause(
91129 return evaluateWhenNode(whenAst, context);
92130}
93131
132function shortcutConflictKey(shortcut: KeybindingShortcut, platform = navigator.platform): string {
133 const useMetaForMod = isMacPlatform(platform);
134 const metaKey = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
135 const ctrlKey = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
136
137 return [
138 shortcut.key,
139 metaKey ? "meta" : "",
140 ctrlKey ? "ctrl" : "",
141 shortcut.shiftKey ? "shift" : "",
142 shortcut.altKey ? "alt" : "",
143 ].join("|");
144}
145
146function findEffectiveShortcutForCommand(
147 keybindings: ResolvedKeybindingsConfig,
148 command: KeybindingCommand,
149 options?: ShortcutMatchOptions,
150): KeybindingShortcut | null {
151 const platform = resolvePlatform(options);
152 const context = resolveContext(options);
153 const claimedShortcuts = new Set<string>();
154
155 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
156 const binding = keybindings[index];
157 if (!binding) continue;
158 if (!matchesWhenClause(binding.whenAst, context)) continue;
159
160 const conflictKey = shortcutConflictKey(binding.shortcut, platform);
161 if (claimedShortcuts.has(conflictKey)) {
162 continue;
163 }
164
165 claimedShortcuts.add(conflictKey);
166 if (binding.command === command) {
167 return binding.shortcut;
168 }
169 }
170
171 return null;
172}
173
94174function matchesCommandShortcut(
95175 event: ShortcutEventLike,
96176 keybindings: ResolvedKeybindingsConfig,
......@@ -104,7 +184,7 @@ export function resolveShortcutCommand(
104184 event: ShortcutEventLike,
105185 keybindings: ResolvedKeybindingsConfig,
106186 options?: ShortcutMatchOptions,
107): string | null {
187): KeybindingCommand | null {
108188 const platform = resolvePlatform(options);
109189 const context = resolveContext(options);
110190
......@@ -156,16 +236,52 @@ export function formatShortcutLabel(
156236export function shortcutLabelForCommand(
157237 keybindings: ResolvedKeybindingsConfig,
158238 command: KeybindingCommand,
159 platform = navigator.platform,
239 options?: string | ResolvedShortcutLabelOptions,
160240): string | null {
161 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
162 const binding = keybindings[index];
163 if (!binding || binding.command !== command) continue;
164 return formatShortcutLabel(binding.shortcut, platform);
165 }
241 const resolvedOptions =
242 typeof options === "string"
243 ? ({ platform: options } satisfies ResolvedShortcutLabelOptions)
244 : options;
245 const platform = resolvePlatform(resolvedOptions);
246 const shortcut = findEffectiveShortcutForCommand(keybindings, command, resolvedOptions);
247 return shortcut ? formatShortcutLabel(shortcut, platform) : null;
248}
249
250export function threadJumpCommandForIndex(index: number): ThreadJumpKeybindingCommand | null {
251 return THREAD_JUMP_KEYBINDING_COMMANDS[index] ?? null;
252}
253
254export function threadJumpIndexFromCommand(command: string): number | null {
255 const index = THREAD_JUMP_KEYBINDING_COMMANDS.indexOf(command as ThreadJumpKeybindingCommand);
256 return index === -1 ? null : index;
257}
258
259export function threadTraversalDirectionFromCommand(
260 command: string | null,
261): "previous" | "next" | null {
262 if (command === "thread.previous") return "previous";
263 if (command === "thread.next") return "next";
166264 return null;
167265}
168266
267export function shouldShowThreadJumpHints(
268 event: ShortcutEventLike,
269 keybindings: ResolvedKeybindingsConfig,
270 options?: ShortcutMatchOptions,
271): boolean {
272 const platform = resolvePlatform(options);
273
274 for (const command of THREAD_JUMP_KEYBINDING_COMMANDS) {
275 const shortcut = findEffectiveShortcutForCommand(keybindings, command, options);
276 if (!shortcut) continue;
277 if (matchesShortcutModifiers(event, shortcut, platform)) {
278 return true;
279 }
280 }
281
282 return false;
283}
284
169285export function isTerminalToggleShortcut(
170286 event: ShortcutEventLike,
171287 keybindings: ResolvedKeybindingsConfig,
packages/contracts/src/keybindings.test.ts+18-1
......@@ -46,6 +46,12 @@ it.effect("parses keybinding rules", () =>
4646 command: "chat.newLocal",
4747 });
4848 assert.strictEqual(parsedLocal.command, "chat.newLocal");
49
50 const parsedThreadPrevious = yield* decode(KeybindingRule, {
51 key: "mod+shift+[",
52 command: "thread.previous",
53 });
54 assert.strictEqual(parsedThreadPrevious.command, "thread.previous");
4955 }),
5056);
5157
......@@ -120,8 +126,19 @@ it.effect("parses resolved keybindings arrays", () =>
120126 modKey: true,
121127 },
122128 },
129 {
130 command: "thread.jump.3",
131 shortcut: {
132 key: "3",
133 metaKey: false,
134 ctrlKey: false,
135 shiftKey: false,
136 altKey: false,
137 modKey: true,
138 },
139 },
123140 ]);
124 assert.lengthOf(parsed, 1);
141 assert.lengthOf(parsed, 2);
125142 }),
126143);
127144
packages/contracts/src/keybindings.ts+21
......@@ -7,6 +7,26 @@ export const MAX_WHEN_EXPRESSION_DEPTH = 64;
77export const MAX_SCRIPT_ID_LENGTH = 24;
88export const MAX_KEYBINDINGS_COUNT = 256;
99
10export const THREAD_JUMP_KEYBINDING_COMMANDS = [
11 "thread.jump.1",
12 "thread.jump.2",
13 "thread.jump.3",
14 "thread.jump.4",
15 "thread.jump.5",
16 "thread.jump.6",
17 "thread.jump.7",
18 "thread.jump.8",
19 "thread.jump.9",
20] as const;
21export type ThreadJumpKeybindingCommand = (typeof THREAD_JUMP_KEYBINDING_COMMANDS)[number];
22
23export const THREAD_KEYBINDING_COMMANDS = [
24 "thread.previous",
25 "thread.next",
26 ...THREAD_JUMP_KEYBINDING_COMMANDS,
27] as const;
28export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];
29
1030const STATIC_KEYBINDING_COMMANDS = [
1131 "terminal.toggle",
1232 "terminal.split",
......@@ -16,6 +36,7 @@ const STATIC_KEYBINDING_COMMANDS = [
1636 "chat.new",
1737 "chat.newLocal",
1838 "editor.openFavorite",
39 ...THREAD_KEYBINDING_COMMANDS,
1940] as const;
2041
2142export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([