| author | |
| committer | |
| log | 58ba4f12633ef05f7a8ab707cba4cd7ccaad0d58 |
| tree | ac847e05ac0eaac9a77075c7de40d6e51b84ee27 |
| parent | 5513845fd56acf7a019d24e72fc4e11bf5654f50 |
| signature | Signed by PGP key B5690EEEBB952194 |
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) => { | ... | @@ -165,6 +165,19 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 165 | }).pipe(Effect.provide(makeKeybindingsLayer())), | 165 | }).pipe(Effect.provide(makeKeybindingsLayer())), |
| 166 | ); | 166 | ); |
| 167 | 167 | ||
| 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 | |||
| 168 | it.effect("uses defaults in runtime when config is malformed without overriding file", () => | 181 | it.effect("uses defaults in runtime when config is malformed without overriding file", () => |
| 169 | Effect.gen(function* () { | 182 | Effect.gen(function* () { |
| 170 | const fs = yield* FileSystem.FileSystem; | 183 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/keybindings.ts+7| ... | @@ -15,6 +15,7 @@ import { | ... | @@ -15,6 +15,7 @@ import { |
| 15 | MAX_WHEN_EXPRESSION_DEPTH, | 15 | MAX_WHEN_EXPRESSION_DEPTH, |
| 16 | ResolvedKeybindingRule, | 16 | ResolvedKeybindingRule, |
| 17 | ResolvedKeybindingsConfig, | 17 | ResolvedKeybindingsConfig, |
| 18 | THREAD_JUMP_KEYBINDING_COMMANDS, | ||
| 18 | type ServerConfigIssue, | 19 | type ServerConfigIssue, |
| 19 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| 20 | import { Mutable } from "effect/Types"; | 21 | import { Mutable } from "effect/Types"; |
| ... | @@ -76,6 +77,12 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ | ... | @@ -76,6 +77,12 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 76 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, | 77 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |
| 77 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, | 78 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, |
| 78 | { key: "mod+o", command: "editor.openFavorite" }, | 79 | { 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 | })), | ||
| 79 | ]; | 86 | ]; |
| 80 | 87 | ||
| 81 | function normalizeKeyToken(token: string): string { | 88 | function normalizeKeyToken(token: string): string { |
apps/web/src/components/ChatView.tsx+26-8| ... | @@ -1162,25 +1162,43 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1162,25 +1162,43 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1162 | }, [activeProjectCwd, activeThreadWorktreePath]); | 1162 | }, [activeProjectCwd, activeThreadWorktreePath]); |
| 1163 | // Default true while loading to avoid toolbar flicker. | 1163 | // Default true while loading to avoid toolbar flicker. |
| 1164 | const isGitRepo = branchesQuery.data?.isRepo ?? true; | 1164 | 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 | ); | ||
| 1165 | const terminalToggleShortcutLabel = useMemo( | 1183 | const terminalToggleShortcutLabel = useMemo( |
| 1166 | () => shortcutLabelForCommand(keybindings, "terminal.toggle"), | 1184 | () => shortcutLabelForCommand(keybindings, "terminal.toggle"), |
| 1167 | [keybindings], | 1185 | [keybindings], |
| 1168 | ); | 1186 | ); |
| 1169 | const splitTerminalShortcutLabel = useMemo( | 1187 | const splitTerminalShortcutLabel = useMemo( |
| 1170 | () => shortcutLabelForCommand(keybindings, "terminal.split"), | 1188 | () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), |
| 1171 | [keybindings], | 1189 | [keybindings, terminalShortcutLabelOptions], |
| 1172 | ); | 1190 | ); |
| 1173 | const newTerminalShortcutLabel = useMemo( | 1191 | const newTerminalShortcutLabel = useMemo( |
| 1174 | () => shortcutLabelForCommand(keybindings, "terminal.new"), | 1192 | () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), |
| 1175 | [keybindings], | 1193 | [keybindings, terminalShortcutLabelOptions], |
| 1176 | ); | 1194 | ); |
| 1177 | const closeTerminalShortcutLabel = useMemo( | 1195 | const closeTerminalShortcutLabel = useMemo( |
| 1178 | () => shortcutLabelForCommand(keybindings, "terminal.close"), | 1196 | () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), |
| 1179 | [keybindings], | 1197 | [keybindings, terminalShortcutLabelOptions], |
| 1180 | ); | 1198 | ); |
| 1181 | const diffPanelShortcutLabel = useMemo( | 1199 | const diffPanelShortcutLabel = useMemo( |
| 1182 | () => shortcutLabelForCommand(keybindings, "diff.toggle"), | 1200 | () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), |
| 1183 | [keybindings], | 1201 | [keybindings, nonTerminalShortcutLabelOptions], |
| 1184 | ); | 1202 | ); |
| 1185 | const onToggleDiff = useCallback(() => { | 1203 | const onToggleDiff = useCallback(() => { |
| 1186 | void navigate({ | 1204 | void navigate({ |
apps/web/src/components/Sidebar.logic.test.ts+76| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | getVisibleSidebarThreadIds, | ||
| 5 | resolveAdjacentThreadId, | ||
| 4 | getFallbackThreadIdAfterDelete, | 6 | getFallbackThreadIdAfterDelete, |
| 5 | getVisibleThreadsForProject, | 7 | getVisibleThreadsForProject, |
| 6 | getProjectSortTimestamp, | 8 | getProjectSortTimestamp, |
| ... | @@ -97,6 +99,80 @@ describe("resolveSidebarNewThreadEnvMode", () => { | ... | @@ -97,6 +99,80 @@ describe("resolveSidebarNewThreadEnvMode", () => { |
| 97 | }); | 99 | }); |
| 98 | }); | 100 | }); |
| 99 | 101 | ||
| 102 | describe("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 | |||
| 148 | describe("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 | |||
| 100 | describe("isContextMenuPointerDown", () => { | 176 | describe("isContextMenuPointerDown", () => { |
| 101 | it("treats secondary-button presses as context menu gestures on all platforms", () => { | 177 | it("treats secondary-button presses as context menu gestures on all platforms", () => { |
| 102 | expect( | 178 | expect( |
apps/web/src/components/Sidebar.logic.ts+41| ... | @@ -17,6 +17,8 @@ type SidebarProject = { | ... | @@ -17,6 +17,8 @@ type SidebarProject = { |
| 17 | }; | 17 | }; |
| 18 | type SidebarThreadSortInput = Pick<Thread, "createdAt" | "updatedAt" | "messages">; | 18 | type SidebarThreadSortInput = Pick<Thread, "createdAt" | "updatedAt" | "messages">; |
| 19 | 19 | ||
| 20 | export type ThreadTraversalDirection = "previous" | "next"; | ||
| 21 | |||
| 20 | export interface ThreadStatusPill { | 22 | export interface ThreadStatusPill { |
| 21 | label: | 23 | label: |
| 22 | | "Working" | 24 | | "Working" |
| ... | @@ -67,6 +69,45 @@ export function resolveSidebarNewThreadEnvMode(input: { | ... | @@ -67,6 +69,45 @@ export function resolveSidebarNewThreadEnvMode(input: { |
| 67 | return input.requestedEnvMode ?? input.defaultEnvMode; | 69 | return input.requestedEnvMode ?? input.defaultEnvMode; |
| 68 | } | 70 | } |
| 69 | 71 | ||
| 72 | export 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 | |||
| 84 | export 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 | |||
| 70 | export function isContextMenuPointerDown(input: { | 111 | export function isContextMenuPointerDown(input: { |
| 71 | button: number; | 112 | button: number; |
| 72 | ctrlKey: boolean; | 113 | ctrlKey: boolean; |
apps/web/src/components/Sidebar.tsx+260-58| ... | @@ -53,9 +53,17 @@ import { | ... | @@ -53,9 +53,17 @@ import { |
| 53 | } from "@t3tools/contracts/settings"; | 53 | } from "@t3tools/contracts/settings"; |
| 54 | import { isElectron } from "../env"; | 54 | import { isElectron } from "../env"; |
| 55 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; | 55 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; |
| 56 | import { isTerminalFocused } from "../lib/terminalFocus"; | ||
| 56 | import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; | 57 | import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; |
| 57 | import { useStore } from "../store"; | 58 | import { useStore } from "../store"; |
| 58 | import { shortcutLabelForCommand } from "../keybindings"; | 59 | import { |
| 60 | resolveShortcutCommand, | ||
| 61 | shortcutLabelForCommand, | ||
| 62 | shouldShowThreadJumpHints, | ||
| 63 | threadJumpCommandForIndex, | ||
| 64 | threadJumpIndexFromCommand, | ||
| 65 | threadTraversalDirectionFromCommand, | ||
| 66 | } from "../keybindings"; | ||
| 59 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; | 67 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; |
| 60 | import { gitStatusQueryOptions } from "../lib/gitReactQuery"; | 68 | import { gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 61 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 69 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| ... | @@ -100,7 +108,9 @@ import { | ... | @@ -100,7 +108,9 @@ import { |
| 100 | import { useThreadSelectionStore } from "../threadSelectionStore"; | 108 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 101 | import { isNonEmpty as isNonEmptyString } from "effect/String"; | 109 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 102 | import { | 110 | import { |
| 111 | getVisibleSidebarThreadIds, | ||
| 103 | getVisibleThreadsForProject, | 112 | getVisibleThreadsForProject, |
| 113 | resolveAdjacentThreadId, | ||
| 104 | isContextMenuPointerDown, | 114 | isContextMenuPointerDown, |
| 105 | resolveProjectStatusIndicator, | 115 | resolveProjectStatusIndicator, |
| 106 | resolveSidebarNewThreadEnvMode, | 116 | resolveSidebarNewThreadEnvMode, |
| ... | @@ -353,6 +363,7 @@ export default function Sidebar() { | ... | @@ -353,6 +363,7 @@ export default function Sidebar() { |
| 353 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< | 363 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< |
| 354 | ReadonlySet<ProjectId> | 364 | ReadonlySet<ProjectId> |
| 355 | >(() => new Set()); | 365 | >(() => new Set()); |
| 366 | const [showThreadJumpHints, setShowThreadJumpHints] = useState(false); | ||
| 356 | const renamingCommittedRef = useRef(false); | 367 | const renamingCommittedRef = useRef(false); |
| 357 | const renamingInputRef = useRef<HTMLInputElement | null>(null); | 368 | const renamingInputRef = useRef<HTMLInputElement | null>(null); |
| 358 | const dragInProgressRef = useRef(false); | 369 | const dragInProgressRef = useRef(false); |
| ... | @@ -366,12 +377,26 @@ export default function Sidebar() { | ... | @@ -366,12 +377,26 @@ export default function Sidebar() { |
| 366 | const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); | 377 | const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); |
| 367 | const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); | 378 | const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); |
| 368 | const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); | 379 | const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); |
| 380 | const platform = navigator.platform; | ||
| 369 | const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; | 381 | const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; |
| 370 | const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; | 382 | const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; |
| 371 | const projectCwdById = useMemo( | 383 | const projectCwdById = useMemo( |
| 372 | () => new Map(projects.map((project) => [project.id, project.cwd] as const)), | 384 | () => new Map(projects.map((project) => [project.id, project.cwd] as const)), |
| 373 | [projects], | 385 | [projects], |
| 374 | ); | 386 | ); |
| 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 | ); | ||
| 375 | const threadGitTargets = useMemo( | 400 | const threadGitTargets = useMemo( |
| 376 | () => | 401 | () => |
| 377 | threads.map((thread) => ({ | 402 | threads.map((thread) => ({ |
| ... | @@ -823,6 +848,20 @@ export default function Sidebar() { | ... | @@ -823,6 +848,20 @@ export default function Sidebar() { |
| 823 | ], | 848 | ], |
| 824 | ); | 849 | ); |
| 825 | 850 | ||
| 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 | |||
| 826 | const handleProjectContextMenu = useCallback( | 865 | const handleProjectContextMenu = useCallback( |
| 827 | async (projectId: ProjectId, position: { x: number; y: number }) => { | 866 | async (projectId: ProjectId, position: { x: number; y: number }) => { |
| 828 | const api = readNativeApi(); | 867 | const api = readNativeApi(); |
| ... | @@ -979,45 +1018,201 @@ export default function Sidebar() { | ... | @@ -979,45 +1018,201 @@ export default function Sidebar() { |
| 979 | [appSettings.sidebarProjectSortOrder, projects, visibleThreads], | 1018 | [appSettings.sidebarProjectSortOrder, projects, visibleThreads], |
| 980 | ); | 1019 | ); |
| 981 | const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; | 1020 | 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 | ]); | ||
| 982 | 1196 | ||
| 983 | function renderProjectItem( | 1197 | function renderProjectItem( |
| 984 | project: (typeof sortedProjects)[number], | 1198 | renderedProject: (typeof renderedProjects)[number], |
| 985 | dragHandleProps: SortableProjectHandleProps | null, | 1199 | dragHandleProps: SortableProjectHandleProps | null, |
| 986 | ) { | 1200 | ) { |
| 987 | const projectThreads = sortThreadsForSidebar( | 1201 | const { |
| 988 | visibleThreads.filter((thread) => thread.projectId === project.id), | 1202 | hasHiddenThreads, |
| 989 | appSettings.sidebarThreadSortOrder, | 1203 | orderedProjectThreadIds, |
| 990 | ); | 1204 | project, |
| 991 | const projectStatus = resolveProjectStatusIndicator( | 1205 | projectStatus, |
| 992 | projectThreads.map((thread) => | 1206 | projectThreads, |
| 993 | resolveThreadStatusPill({ | 1207 | renderedThreads, |
| 994 | thread, | 1208 | shouldShowThreadPanel, |
| 995 | hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, | 1209 | isThreadListExpanded, |
| 996 | hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, | 1210 | } = renderedProject; |
| 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; | ||
| 1017 | const renderThreadRow = (thread: (typeof projectThreads)[number]) => { | 1211 | const renderThreadRow = (thread: (typeof projectThreads)[number]) => { |
| 1018 | const isActive = routeThreadId === thread.id; | 1212 | const isActive = routeThreadId === thread.id; |
| 1019 | const isSelected = selectedThreadIds.has(thread.id); | 1213 | const isSelected = selectedThreadIds.has(thread.id); |
| 1020 | const isHighlighted = isActive || isSelected; | 1214 | const isHighlighted = isActive || isSelected; |
| 1215 | const jumpLabel = threadJumpLabelById.get(thread.id) ?? null; | ||
| 1021 | const isThreadRunning = | 1216 | const isThreadRunning = |
| 1022 | thread.session?.status === "running" && thread.session.activeTurnId != null; | 1217 | thread.session?.status === "running" && thread.session.activeTurnId != null; |
| 1023 | const threadStatus = resolveThreadStatusPill({ | 1218 | const threadStatus = resolveThreadStatusPill({ |
| ... | @@ -1057,14 +1252,7 @@ export default function Sidebar() { | ... | @@ -1057,14 +1252,7 @@ export default function Sidebar() { |
| 1057 | onKeyDown={(event) => { | 1252 | onKeyDown={(event) => { |
| 1058 | if (event.key !== "Enter" && event.key !== " ") return; | 1253 | if (event.key !== "Enter" && event.key !== " ") return; |
| 1059 | event.preventDefault(); | 1254 | event.preventDefault(); |
| 1060 | if (selectedThreadIds.size > 0) { | 1255 | navigateToThread(thread.id); |
| 1061 | clearSelection(); | ||
| 1062 | } | ||
| 1063 | setSelectionAnchor(thread.id); | ||
| 1064 | void navigate({ | ||
| 1065 | to: "/$threadId", | ||
| 1066 | params: { threadId: thread.id }, | ||
| 1067 | }); | ||
| 1068 | }} | 1256 | }} |
| 1069 | onContextMenu={(event) => { | 1257 | onContextMenu={(event) => { |
| 1070 | event.preventDefault(); | 1258 | event.preventDefault(); |
| ... | @@ -1234,15 +1422,26 @@ export default function Sidebar() { | ... | @@ -1234,15 +1422,26 @@ export default function Sidebar() { |
| 1234 | </Tooltip> | 1422 | </Tooltip> |
| 1235 | ) | 1423 | ) |
| 1236 | ) : ( | 1424 | ) : ( |
| 1237 | <span | 1425 | <> |
| 1238 | className={`text-[10px] ${ | 1426 | {showThreadJumpHints && jumpLabel ? ( |
| 1239 | isHighlighted | 1427 | <span |
| 1240 | ? "text-foreground/72 dark:text-foreground/82" | 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" |
| 1241 | : "text-muted-foreground/40" | 1429 | title={jumpLabel} |
| 1242 | }`} | 1430 | > |
| 1243 | > | 1431 | {jumpLabel} |
| 1244 | {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} | 1432 | </span> |
| 1245 | </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 | </> | ||
| 1246 | )} | 1445 | )} |
| 1247 | </div> | 1446 | </div> |
| 1248 | </div> | 1447 | </div> |
| ... | @@ -1490,8 +1689,8 @@ export default function Sidebar() { | ... | @@ -1490,8 +1689,8 @@ export default function Sidebar() { |
| 1490 | ? "text-rose-500 animate-pulse" | 1689 | ? "text-rose-500 animate-pulse" |
| 1491 | : "text-amber-500 animate-pulse"; | 1690 | : "text-amber-500 animate-pulse"; |
| 1492 | const newThreadShortcutLabel = | 1691 | const newThreadShortcutLabel = |
| 1493 | shortcutLabelForCommand(keybindings, "chat.newLocal") ?? | 1692 | shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? |
| 1494 | shortcutLabelForCommand(keybindings, "chat.new"); | 1693 | shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); |
| 1495 | 1694 | ||
| 1496 | const handleDesktopUpdateButtonClick = useCallback(() => { | 1695 | const handleDesktopUpdateButtonClick = useCallback(() => { |
| 1497 | const bridge = window.desktopBridge; | 1696 | const bridge = window.desktopBridge; |
| ... | @@ -1773,12 +1972,15 @@ export default function Sidebar() { | ... | @@ -1773,12 +1972,15 @@ export default function Sidebar() { |
| 1773 | > | 1972 | > |
| 1774 | <SidebarMenu> | 1973 | <SidebarMenu> |
| 1775 | <SortableContext | 1974 | <SortableContext |
| 1776 | items={sortedProjects.map((project) => project.id)} | 1975 | items={renderedProjects.map((renderedProject) => renderedProject.project.id)} |
| 1777 | strategy={verticalListSortingStrategy} | 1976 | strategy={verticalListSortingStrategy} |
| 1778 | > | 1977 | > |
| 1779 | {sortedProjects.map((project) => ( | 1978 | {renderedProjects.map((renderedProject) => ( |
| 1780 | <SortableProjectItem key={project.id} projectId={project.id}> | 1979 | <SortableProjectItem |
| 1781 | {(dragHandleProps) => renderProjectItem(project, dragHandleProps)} | 1980 | key={renderedProject.project.id} |
| 1981 | projectId={renderedProject.project.id} | ||
| 1982 | > | ||
| 1983 | {(dragHandleProps) => renderProjectItem(renderedProject, dragHandleProps)} | ||
| 1782 | </SortableProjectItem> | 1984 | </SortableProjectItem> |
| 1783 | ))} | 1985 | ))} |
| 1784 | </SortableContext> | 1986 | </SortableContext> |
| ... | @@ -1786,9 +1988,9 @@ export default function Sidebar() { | ... | @@ -1786,9 +1988,9 @@ export default function Sidebar() { |
| 1786 | </DndContext> | 1988 | </DndContext> |
| 1787 | ) : ( | 1989 | ) : ( |
| 1788 | <SidebarMenu ref={attachProjectListAutoAnimateRef}> | 1990 | <SidebarMenu ref={attachProjectListAutoAnimateRef}> |
| 1789 | {sortedProjects.map((project) => ( | 1991 | {renderedProjects.map((renderedProject) => ( |
| 1790 | <SidebarMenuItem key={project.id} className="rounded-md"> | 1992 | <SidebarMenuItem key={renderedProject.project.id} className="rounded-md"> |
| 1791 | {renderProjectItem(project, null)} | 1993 | {renderProjectItem(renderedProject, null)} |
| 1792 | </SidebarMenuItem> | 1994 | </SidebarMenuItem> |
| 1793 | ))} | 1995 | ))} |
| 1794 | </SidebarMenu> | 1996 | </SidebarMenu> |
apps/web/src/keybindings.test.ts+124-3| ... | @@ -18,8 +18,12 @@ import { | ... | @@ -18,8 +18,12 @@ import { |
| 18 | isTerminalSplitShortcut, | 18 | isTerminalSplitShortcut, |
| 19 | isTerminalToggleShortcut, | 19 | isTerminalToggleShortcut, |
| 20 | resolveShortcutCommand, | 20 | resolveShortcutCommand, |
| 21 | shouldShowThreadJumpHints, | ||
| 21 | shortcutLabelForCommand, | 22 | shortcutLabelForCommand, |
| 22 | terminalNavigationShortcutData, | 23 | terminalNavigationShortcutData, |
| 24 | threadJumpCommandForIndex, | ||
| 25 | threadJumpIndexFromCommand, | ||
| 26 | threadTraversalDirectionFromCommand, | ||
| 23 | type ShortcutEventLike, | 27 | type ShortcutEventLike, |
| 24 | } from "./keybindings"; | 28 | } from "./keybindings"; |
| 25 | 29 | ||
| ... | @@ -100,6 +104,11 @@ const DEFAULT_BINDINGS = compile([ | ... | @@ -100,6 +104,11 @@ const DEFAULT_BINDINGS = compile([ |
| 100 | { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, | 104 | { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, |
| 101 | { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, | 105 | { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, |
| 102 | { shortcut: modShortcut("o"), command: "editor.openFavorite" }, | 106 | { 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" }, | ||
| 103 | ]); | 112 | ]); |
| 104 | 113 | ||
| 105 | describe("isTerminalToggleShortcut", () => { | 114 | describe("isTerminalToggleShortcut", () => { |
| ... | @@ -215,7 +224,7 @@ describe("split/new/close terminal shortcuts", () => { | ... | @@ -215,7 +224,7 @@ describe("split/new/close terminal shortcuts", () => { |
| 215 | }); | 224 | }); |
| 216 | 225 | ||
| 217 | describe("shortcutLabelForCommand", () => { | 226 | describe("shortcutLabelForCommand", () => { |
| 218 | it("returns the most recent binding label", () => { | 227 | it("returns the effective binding label", () => { |
| 219 | const bindings = compile([ | 228 | const bindings = compile([ |
| 220 | { | 229 | { |
| 221 | shortcut: modShortcut("\\"), | 230 | shortcut: modShortcut("\\"), |
| ... | @@ -229,18 +238,107 @@ describe("shortcutLabelForCommand", () => { | ... | @@ -229,18 +238,107 @@ describe("shortcutLabelForCommand", () => { |
| 229 | }, | 238 | }, |
| 230 | ]); | 239 | ]); |
| 231 | assert.strictEqual( | 240 | assert.strictEqual( |
| 232 | shortcutLabelForCommand(bindings, "terminal.split", "Linux"), | 241 | shortcutLabelForCommand(bindings, "terminal.split", { |
| 242 | platform: "Linux", | ||
| 243 | context: { terminalFocus: false }, | ||
| 244 | }), | ||
| 233 | "Ctrl+Shift+\\", | 245 | "Ctrl+Shift+\\", |
| 234 | ); | 246 | ); |
| 235 | }); | 247 | }); |
| 236 | 248 | ||
| 237 | it("returns labels for non-terminal commands", () => { | 249 | it("returns effective labels for non-terminal commands", () => { |
| 238 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); | 250 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); |
| 239 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); | 251 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); |
| 240 | assert.strictEqual( | 252 | assert.strictEqual( |
| 241 | shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), | 253 | shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), |
| 242 | "Ctrl+O", | 254 | "Ctrl+O", |
| 243 | ); | 255 | ); |
| 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 | |||
| 309 | describe("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 | ); | ||
| 244 | }); | 342 | }); |
| 245 | }); | 343 | }); |
| 246 | 344 | ||
| ... | @@ -373,6 +471,29 @@ describe("resolveShortcutCommand", () => { | ... | @@ -373,6 +471,29 @@ describe("resolveShortcutCommand", () => { |
| 373 | "script.setup.run", | 471 | "script.setup.run", |
| 374 | ); | 472 | ); |
| 375 | }); | 473 | }); |
| 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 | }); | ||
| 376 | }); | 497 | }); |
| 377 | 498 | ||
| 378 | describe("formatShortcutLabel", () => { | 499 | describe("formatShortcutLabel", () => { |
apps/web/src/keybindings.ts+127-11| ... | @@ -3,11 +3,14 @@ import { | ... | @@ -3,11 +3,14 @@ import { |
| 3 | type KeybindingShortcut, | 3 | type KeybindingShortcut, |
| 4 | type KeybindingWhenNode, | 4 | type KeybindingWhenNode, |
| 5 | type ResolvedKeybindingsConfig, | 5 | type ResolvedKeybindingsConfig, |
| 6 | THREAD_JUMP_KEYBINDING_COMMANDS, | ||
| 7 | type ThreadJumpKeybindingCommand, | ||
| 6 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 7 | import { isMacPlatform } from "./lib/utils"; | 9 | import { isMacPlatform } from "./lib/utils"; |
| 8 | 10 | ||
| 9 | export interface ShortcutEventLike { | 11 | export interface ShortcutEventLike { |
| 10 | type?: string; | 12 | type?: string; |
| 13 | code?: string; | ||
| 11 | key: string; | 14 | key: string; |
| 12 | metaKey: boolean; | 15 | metaKey: boolean; |
| 13 | ctrlKey: boolean; | 16 | ctrlKey: boolean; |
| ... | @@ -26,10 +29,28 @@ interface ShortcutMatchOptions { | ... | @@ -26,10 +29,28 @@ interface ShortcutMatchOptions { |
| 26 | context?: Partial<ShortcutMatchContext>; | 29 | context?: Partial<ShortcutMatchContext>; |
| 27 | } | 30 | } |
| 28 | 31 | ||
| 32 | interface ResolvedShortcutLabelOptions extends ShortcutMatchOptions { | ||
| 33 | platform?: string; | ||
| 34 | } | ||
| 35 | |||
| 29 | const TERMINAL_WORD_BACKWARD = "\u001bb"; | 36 | const TERMINAL_WORD_BACKWARD = "\u001bb"; |
| 30 | const TERMINAL_WORD_FORWARD = "\u001bf"; | 37 | const TERMINAL_WORD_FORWARD = "\u001bf"; |
| 31 | const TERMINAL_LINE_START = "\u0001"; | 38 | const TERMINAL_LINE_START = "\u0001"; |
| 32 | const TERMINAL_LINE_END = "\u0005"; | 39 | const TERMINAL_LINE_END = "\u0005"; |
| 40 | const 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 | }; | ||
| 33 | 54 | ||
| 34 | function normalizeEventKey(key: string): string { | 55 | function normalizeEventKey(key: string): string { |
| 35 | const normalized = key.toLowerCase(); | 56 | const normalized = key.toLowerCase(); |
| ... | @@ -37,14 +58,22 @@ function normalizeEventKey(key: string): string { | ... | @@ -37,14 +58,22 @@ function normalizeEventKey(key: string): string { |
| 37 | return normalized; | 58 | return normalized; |
| 38 | } | 59 | } |
| 39 | 60 | ||
| 40 | function matchesShortcut( | 61 | function 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 | |||
| 72 | function matchesShortcutModifiers( | ||
| 41 | event: ShortcutEventLike, | 73 | event: ShortcutEventLike, |
| 42 | shortcut: KeybindingShortcut, | 74 | shortcut: KeybindingShortcut, |
| 43 | platform = navigator.platform, | 75 | platform = navigator.platform, |
| 44 | ): boolean { | 76 | ): boolean { |
| 45 | const key = normalizeEventKey(event.key); | ||
| 46 | if (key !== shortcut.key) return false; | ||
| 47 | |||
| 48 | const useMetaForMod = isMacPlatform(platform); | 77 | const useMetaForMod = isMacPlatform(platform); |
| 49 | const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); | 78 | const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); |
| 50 | const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); | 79 | const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); |
| ... | @@ -56,6 +85,15 @@ function matchesShortcut( | ... | @@ -56,6 +85,15 @@ function matchesShortcut( |
| 56 | ); | 85 | ); |
| 57 | } | 86 | } |
| 58 | 87 | ||
| 88 | function 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 | |||
| 59 | function resolvePlatform(options: ShortcutMatchOptions | undefined): string { | 97 | function resolvePlatform(options: ShortcutMatchOptions | undefined): string { |
| 60 | return options?.platform ?? navigator.platform; | 98 | return options?.platform ?? navigator.platform; |
| 61 | } | 99 | } |
| ... | @@ -91,6 +129,48 @@ function matchesWhenClause( | ... | @@ -91,6 +129,48 @@ function matchesWhenClause( |
| 91 | return evaluateWhenNode(whenAst, context); | 129 | return evaluateWhenNode(whenAst, context); |
| 92 | } | 130 | } |
| 93 | 131 | ||
| 132 | function 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 | |||
| 146 | function 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 | |||
| 94 | function matchesCommandShortcut( | 174 | function matchesCommandShortcut( |
| 95 | event: ShortcutEventLike, | 175 | event: ShortcutEventLike, |
| 96 | keybindings: ResolvedKeybindingsConfig, | 176 | keybindings: ResolvedKeybindingsConfig, |
| ... | @@ -104,7 +184,7 @@ export function resolveShortcutCommand( | ... | @@ -104,7 +184,7 @@ export function resolveShortcutCommand( |
| 104 | event: ShortcutEventLike, | 184 | event: ShortcutEventLike, |
| 105 | keybindings: ResolvedKeybindingsConfig, | 185 | keybindings: ResolvedKeybindingsConfig, |
| 106 | options?: ShortcutMatchOptions, | 186 | options?: ShortcutMatchOptions, |
| 107 | ): string | null { | 187 | ): KeybindingCommand | null { |
| 108 | const platform = resolvePlatform(options); | 188 | const platform = resolvePlatform(options); |
| 109 | const context = resolveContext(options); | 189 | const context = resolveContext(options); |
| 110 | 190 | ||
| ... | @@ -156,16 +236,52 @@ export function formatShortcutLabel( | ... | @@ -156,16 +236,52 @@ export function formatShortcutLabel( |
| 156 | export function shortcutLabelForCommand( | 236 | export function shortcutLabelForCommand( |
| 157 | keybindings: ResolvedKeybindingsConfig, | 237 | keybindings: ResolvedKeybindingsConfig, |
| 158 | command: KeybindingCommand, | 238 | command: KeybindingCommand, |
| 159 | platform = navigator.platform, | 239 | options?: string | ResolvedShortcutLabelOptions, |
| 160 | ): string | null { | 240 | ): string | null { |
| 161 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | 241 | const resolvedOptions = |
| 162 | const binding = keybindings[index]; | 242 | typeof options === "string" |
| 163 | if (!binding || binding.command !== command) continue; | 243 | ? ({ platform: options } satisfies ResolvedShortcutLabelOptions) |
| 164 | return formatShortcutLabel(binding.shortcut, platform); | 244 | : options; |
| 165 | } | 245 | const platform = resolvePlatform(resolvedOptions); |
| 246 | const shortcut = findEffectiveShortcutForCommand(keybindings, command, resolvedOptions); | ||
| 247 | return shortcut ? formatShortcutLabel(shortcut, platform) : null; | ||
| 248 | } | ||
| 249 | |||
| 250 | export function threadJumpCommandForIndex(index: number): ThreadJumpKeybindingCommand | null { | ||
| 251 | return THREAD_JUMP_KEYBINDING_COMMANDS[index] ?? null; | ||
| 252 | } | ||
| 253 | |||
| 254 | export 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 | |||
| 259 | export function threadTraversalDirectionFromCommand( | ||
| 260 | command: string | null, | ||
| 261 | ): "previous" | "next" | null { | ||
| 262 | if (command === "thread.previous") return "previous"; | ||
| 263 | if (command === "thread.next") return "next"; | ||
| 166 | return null; | 264 | return null; |
| 167 | } | 265 | } |
| 168 | 266 | ||
| 267 | export 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 | |||
| 169 | export function isTerminalToggleShortcut( | 285 | export function isTerminalToggleShortcut( |
| 170 | event: ShortcutEventLike, | 286 | event: ShortcutEventLike, |
| 171 | keybindings: ResolvedKeybindingsConfig, | 287 | keybindings: ResolvedKeybindingsConfig, |
packages/contracts/src/keybindings.test.ts+18-1| ... | @@ -46,6 +46,12 @@ it.effect("parses keybinding rules", () => | ... | @@ -46,6 +46,12 @@ it.effect("parses keybinding rules", () => |
| 46 | command: "chat.newLocal", | 46 | command: "chat.newLocal", |
| 47 | }); | 47 | }); |
| 48 | assert.strictEqual(parsedLocal.command, "chat.newLocal"); | 48 | 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"); | ||
| 49 | }), | 55 | }), |
| 50 | ); | 56 | ); |
| 51 | 57 | ||
| ... | @@ -120,8 +126,19 @@ it.effect("parses resolved keybindings arrays", () => | ... | @@ -120,8 +126,19 @@ it.effect("parses resolved keybindings arrays", () => |
| 120 | modKey: true, | 126 | modKey: true, |
| 121 | }, | 127 | }, |
| 122 | }, | 128 | }, |
| 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 | }, | ||
| 123 | ]); | 140 | ]); |
| 124 | assert.lengthOf(parsed, 1); | 141 | assert.lengthOf(parsed, 2); |
| 125 | }), | 142 | }), |
| 126 | ); | 143 | ); |
| 127 | 144 |
packages/contracts/src/keybindings.ts+21| ... | @@ -7,6 +7,26 @@ export const MAX_WHEN_EXPRESSION_DEPTH = 64; | ... | @@ -7,6 +7,26 @@ export const MAX_WHEN_EXPRESSION_DEPTH = 64; |
| 7 | export const MAX_SCRIPT_ID_LENGTH = 24; | 7 | export const MAX_SCRIPT_ID_LENGTH = 24; |
| 8 | export const MAX_KEYBINDINGS_COUNT = 256; | 8 | export const MAX_KEYBINDINGS_COUNT = 256; |
| 9 | 9 | ||
| 10 | export 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; | ||
| 21 | export type ThreadJumpKeybindingCommand = (typeof THREAD_JUMP_KEYBINDING_COMMANDS)[number]; | ||
| 22 | |||
| 23 | export const THREAD_KEYBINDING_COMMANDS = [ | ||
| 24 | "thread.previous", | ||
| 25 | "thread.next", | ||
| 26 | ...THREAD_JUMP_KEYBINDING_COMMANDS, | ||
| 27 | ] as const; | ||
| 28 | export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; | ||
| 29 | |||
| 10 | const STATIC_KEYBINDING_COMMANDS = [ | 30 | const STATIC_KEYBINDING_COMMANDS = [ |
| 11 | "terminal.toggle", | 31 | "terminal.toggle", |
| 12 | "terminal.split", | 32 | "terminal.split", |
| ... | @@ -16,6 +36,7 @@ const STATIC_KEYBINDING_COMMANDS = [ | ... | @@ -16,6 +36,7 @@ const STATIC_KEYBINDING_COMMANDS = [ |
| 16 | "chat.new", | 36 | "chat.new", |
| 17 | "chat.newLocal", | 37 | "chat.newLocal", |
| 18 | "editor.openFavorite", | 38 | "editor.openFavorite", |
| 39 | ...THREAD_KEYBINDING_COMMANDS, | ||
| 19 | ] as const; | 40 | ] as const; |
| 20 | 41 | ||
| 21 | export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([ | 42 | export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([ |