| author | |
| committer | |
| log | fcbf3f3c225ab41c44af3759ae27d8520ced61a4 |
| tree | d85a771d4b2672fd10632a0c20e3ca2403a4af27 |
| parent | ab0002f99d6fb5555c57bc91f696e6a9f8f4dfd7 |
- move chat-wide key handling into `_chat` route-level shortcut handler
- extract reusable `useHandleNewThread` hook and `isTerminalFocused` helper
- update browser WS fixture to support `terminalOpen` RPC shape6 files changed, 221 insertions(+), 133 deletions(-)
apps/web/src/components/ChatView.browser.tsx+16-2| ... | @@ -353,7 +353,8 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { | ... | @@ -353,7 +353,8 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { |
| 353 | }; | 353 | }; |
| 354 | } | 354 | } |
| 355 | 355 | ||
| 356 | function resolveWsRpc(tag: string): unknown { | 356 | function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { |
| 357 | const tag = body._tag; | ||
| 357 | if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { | 358 | if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { |
| 358 | return fixture.snapshot; | 359 | return fixture.snapshot; |
| 359 | } | 360 | } |
| ... | @@ -395,6 +396,19 @@ function resolveWsRpc(tag: string): unknown { | ... | @@ -395,6 +396,19 @@ function resolveWsRpc(tag: string): unknown { |
| 395 | truncated: false, | 396 | truncated: false, |
| 396 | }; | 397 | }; |
| 397 | } | 398 | } |
| 399 | if (tag === WS_METHODS.terminalOpen) { | ||
| 400 | return { | ||
| 401 | threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID, | ||
| 402 | terminalId: typeof body.terminalId === "string" ? body.terminalId : "default", | ||
| 403 | cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project", | ||
| 404 | status: "running", | ||
| 405 | pid: 123, | ||
| 406 | history: "", | ||
| 407 | exitCode: null, | ||
| 408 | exitSignal: null, | ||
| 409 | updatedAt: NOW_ISO, | ||
| 410 | }; | ||
| 411 | } | ||
| 398 | return {}; | 412 | return {}; |
| 399 | } | 413 | } |
| 400 | 414 | ||
| ... | @@ -423,7 +437,7 @@ const worker = setupWorker( | ... | @@ -423,7 +437,7 @@ const worker = setupWorker( |
| 423 | client.send( | 437 | client.send( |
| 424 | JSON.stringify({ | 438 | JSON.stringify({ |
| 425 | id: request.id, | 439 | id: request.id, |
| 426 | result: resolveWsRpc(method), | 440 | result: resolveWsRpc(request.body), |
| 427 | }), | 441 | }), |
| 428 | ); | 442 | ); |
| 429 | }); | 443 | }); |
apps/web/src/components/ChatView.tsx+1-7| ... | @@ -117,6 +117,7 @@ import { SidebarTrigger } from "./ui/sidebar"; | ... | @@ -117,6 +117,7 @@ import { SidebarTrigger } from "./ui/sidebar"; |
| 117 | import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; | 117 | import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; |
| 118 | import { readNativeApi } from "~/nativeApi"; | 118 | import { readNativeApi } from "~/nativeApi"; |
| 119 | import { resolveAppModelSelection, useAppSettings } from "../appSettings"; | 119 | import { resolveAppModelSelection, useAppSettings } from "../appSettings"; |
| 120 | import { isTerminalFocused } from "../lib/terminalFocus"; | ||
| 120 | import { | 121 | import { |
| 121 | type ComposerImageAttachment, | 122 | type ComposerImageAttachment, |
| 122 | type DraftThreadEnvMode, | 123 | type DraftThreadEnvMode, |
| ... | @@ -1937,13 +1938,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1937,13 +1938,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1937 | }, [activeThreadId, focusComposer, terminalState.terminalOpen]); | 1938 | }, [activeThreadId, focusComposer, terminalState.terminalOpen]); |
| 1938 | 1939 | ||
| 1939 | useEffect(() => { | 1940 | useEffect(() => { |
| 1940 | const isTerminalFocused = (): boolean => { | ||
| 1941 | const activeElement = document.activeElement; | ||
| 1942 | if (!(activeElement instanceof HTMLElement)) return false; | ||
| 1943 | if (activeElement.classList.contains("xterm-helper-textarea")) return true; | ||
| 1944 | return activeElement.closest(".thread-terminal-drawer .xterm") !== null; | ||
| 1945 | }; | ||
| 1946 | |||
| 1947 | const handler = (event: globalThis.KeyboardEvent) => { | 1941 | const handler = (event: globalThis.KeyboardEvent) => { |
| 1948 | if (!activeThreadId || event.defaultPrevented) return; | 1942 | if (!activeThreadId || event.defaultPrevented) return; |
| 1949 | const shortcutContext = { | 1943 | const shortcutContext = { |
apps/web/src/components/Sidebar.tsx+6-124| ... | @@ -27,7 +27,6 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- | ... | @@ -27,7 +27,6 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- |
| 27 | import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers"; | 27 | import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers"; |
| 28 | import { CSS } from "@dnd-kit/utilities"; | 28 | import { CSS } from "@dnd-kit/utilities"; |
| 29 | import { | 29 | import { |
| 30 | DEFAULT_RUNTIME_MODE, | ||
| 31 | DEFAULT_MODEL_BY_PROVIDER, | 30 | DEFAULT_MODEL_BY_PROVIDER, |
| 32 | type DesktopUpdateState, | 31 | type DesktopUpdateState, |
| 33 | ProjectId, | 32 | ProjectId, |
| ... | @@ -40,14 +39,15 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; | ... | @@ -40,14 +39,15 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 40 | import { useAppSettings } from "../appSettings"; | 39 | import { useAppSettings } from "../appSettings"; |
| 41 | import { isElectron } from "../env"; | 40 | import { isElectron } from "../env"; |
| 42 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; | 41 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; |
| 43 | import { isMacPlatform, newCommandId, newProjectId, newThreadId } from "../lib/utils"; | 42 | import { isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; |
| 44 | import { useStore } from "../store"; | 43 | import { useStore } from "../store"; |
| 45 | import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; | 44 | import { shortcutLabelForCommand } from "../keybindings"; |
| 46 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; | 45 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; |
| 47 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; | 46 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 48 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 47 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 49 | import { readNativeApi } from "../nativeApi"; | 48 | import { readNativeApi } from "../nativeApi"; |
| 50 | import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; | 49 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 50 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | ||
| 51 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 51 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 52 | import { toastManager } from "./ui/toast"; | 52 | import { toastManager } from "./ui/toast"; |
| 53 | import { | 53 | import { |
| ... | @@ -262,11 +262,8 @@ export default function Sidebar() { | ... | @@ -262,11 +262,8 @@ export default function Sidebar() { |
| 262 | const getDraftThreadByProjectId = useComposerDraftStore( | 262 | const getDraftThreadByProjectId = useComposerDraftStore( |
| 263 | (store) => store.getDraftThreadByProjectId, | 263 | (store) => store.getDraftThreadByProjectId, |
| 264 | ); | 264 | ); |
| 265 | const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); | ||
| 266 | const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); | 265 | const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); |
| 267 | const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); | 266 | const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); |
| 268 | const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); | ||
| 269 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | ||
| 270 | const clearProjectDraftThreadId = useComposerDraftStore( | 267 | const clearProjectDraftThreadId = useComposerDraftStore( |
| 271 | (store) => store.clearProjectDraftThreadId, | 268 | (store) => store.clearProjectDraftThreadId, |
| 272 | ); | 269 | ); |
| ... | @@ -276,6 +273,7 @@ export default function Sidebar() { | ... | @@ -276,6 +273,7 @@ export default function Sidebar() { |
| 276 | const navigate = useNavigate(); | 273 | const navigate = useNavigate(); |
| 277 | const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); | 274 | const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); |
| 278 | const { settings: appSettings } = useAppSettings(); | 275 | const { settings: appSettings } = useAppSettings(); |
| 276 | const { handleNewThread } = useHandleNewThread(); | ||
| 279 | const routeThreadId = useParams({ | 277 | const routeThreadId = useParams({ |
| 280 | strict: false, | 278 | strict: false, |
| 281 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | 279 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), |
| ... | @@ -384,80 +382,6 @@ export default function Sidebar() { | ... | @@ -384,80 +382,6 @@ export default function Sidebar() { |
| 384 | }); | 382 | }); |
| 385 | }, []); | 383 | }, []); |
| 386 | 384 | ||
| 387 | const handleNewThread = useCallback( | ||
| 388 | ( | ||
| 389 | projectId: ProjectId, | ||
| 390 | options?: { | ||
| 391 | branch?: string | null; | ||
| 392 | worktreePath?: string | null; | ||
| 393 | envMode?: DraftThreadEnvMode; | ||
| 394 | }, | ||
| 395 | ): Promise<void> => { | ||
| 396 | const hasBranchOption = options?.branch !== undefined; | ||
| 397 | const hasWorktreePathOption = options?.worktreePath !== undefined; | ||
| 398 | const hasEnvModeOption = options?.envMode !== undefined; | ||
| 399 | const storedDraftThread = getDraftThreadByProjectId(projectId); | ||
| 400 | if (storedDraftThread) { | ||
| 401 | return (async () => { | ||
| 402 | if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { | ||
| 403 | setDraftThreadContext(storedDraftThread.threadId, { | ||
| 404 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), | ||
| 405 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), | ||
| 406 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), | ||
| 407 | }); | ||
| 408 | } | ||
| 409 | setProjectDraftThreadId(projectId, storedDraftThread.threadId); | ||
| 410 | if (routeThreadId === storedDraftThread.threadId) { | ||
| 411 | return; | ||
| 412 | } | ||
| 413 | await navigate({ | ||
| 414 | to: "/$threadId", | ||
| 415 | params: { threadId: storedDraftThread.threadId }, | ||
| 416 | }); | ||
| 417 | })(); | ||
| 418 | } | ||
| 419 | clearProjectDraftThreadId(projectId); | ||
| 420 | |||
| 421 | const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; | ||
| 422 | if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { | ||
| 423 | if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { | ||
| 424 | setDraftThreadContext(routeThreadId, { | ||
| 425 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), | ||
| 426 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), | ||
| 427 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), | ||
| 428 | }); | ||
| 429 | } | ||
| 430 | setProjectDraftThreadId(projectId, routeThreadId); | ||
| 431 | return Promise.resolve(); | ||
| 432 | } | ||
| 433 | const threadId = newThreadId(); | ||
| 434 | const createdAt = new Date().toISOString(); | ||
| 435 | return (async () => { | ||
| 436 | setProjectDraftThreadId(projectId, threadId, { | ||
| 437 | createdAt, | ||
| 438 | branch: options?.branch ?? null, | ||
| 439 | worktreePath: options?.worktreePath ?? null, | ||
| 440 | envMode: options?.envMode ?? "local", | ||
| 441 | runtimeMode: DEFAULT_RUNTIME_MODE, | ||
| 442 | }); | ||
| 443 | |||
| 444 | await navigate({ | ||
| 445 | to: "/$threadId", | ||
| 446 | params: { threadId }, | ||
| 447 | }); | ||
| 448 | })(); | ||
| 449 | }, | ||
| 450 | [ | ||
| 451 | clearProjectDraftThreadId, | ||
| 452 | getDraftThreadByProjectId, | ||
| 453 | navigate, | ||
| 454 | getDraftThread, | ||
| 455 | routeThreadId, | ||
| 456 | setDraftThreadContext, | ||
| 457 | setProjectDraftThreadId, | ||
| 458 | ], | ||
| 459 | ); | ||
| 460 | |||
| 461 | const focusMostRecentThreadForProject = useCallback( | 385 | const focusMostRecentThreadForProject = useCallback( |
| 462 | (projectId: ProjectId) => { | 386 | (projectId: ProjectId) => { |
| 463 | const latestThread = threads | 387 | const latestThread = threads |
| ... | @@ -1021,37 +945,6 @@ export default function Sidebar() { | ... | @@ -1021,37 +945,6 @@ export default function Sidebar() { |
| 1021 | ); | 945 | ); |
| 1022 | 946 | ||
| 1023 | useEffect(() => { | 947 | useEffect(() => { |
| 1024 | const onWindowKeyDown = (event: KeyboardEvent) => { | ||
| 1025 | if (event.key === "Escape" && selectedThreadIds.size > 0) { | ||
| 1026 | event.preventDefault(); | ||
| 1027 | clearSelection(); | ||
| 1028 | return; | ||
| 1029 | } | ||
| 1030 | |||
| 1031 | const activeThread = routeThreadId | ||
| 1032 | ? threads.find((thread) => thread.id === routeThreadId) | ||
| 1033 | : undefined; | ||
| 1034 | const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; | ||
| 1035 | if (isChatNewLocalShortcut(event, keybindings)) { | ||
| 1036 | const projectId = | ||
| 1037 | activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; | ||
| 1038 | if (!projectId) return; | ||
| 1039 | event.preventDefault(); | ||
| 1040 | void handleNewThread(projectId); | ||
| 1041 | return; | ||
| 1042 | } | ||
| 1043 | |||
| 1044 | if (!isChatNewShortcut(event, keybindings)) return; | ||
| 1045 | const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; | ||
| 1046 | if (!projectId) return; | ||
| 1047 | event.preventDefault(); | ||
| 1048 | void handleNewThread(projectId, { | ||
| 1049 | branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, | ||
| 1050 | worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, | ||
| 1051 | envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), | ||
| 1052 | }); | ||
| 1053 | }; | ||
| 1054 | |||
| 1055 | const onMouseDown = (event: globalThis.MouseEvent) => { | 948 | const onMouseDown = (event: globalThis.MouseEvent) => { |
| 1056 | if (selectedThreadIds.size === 0) return; | 949 | if (selectedThreadIds.size === 0) return; |
| 1057 | const target = event.target instanceof HTMLElement ? event.target : null; | 950 | const target = event.target instanceof HTMLElement ? event.target : null; |
| ... | @@ -1059,22 +952,11 @@ export default function Sidebar() { | ... | @@ -1059,22 +952,11 @@ export default function Sidebar() { |
| 1059 | clearSelection(); | 952 | clearSelection(); |
| 1060 | }; | 953 | }; |
| 1061 | 954 | ||
| 1062 | window.addEventListener("keydown", onWindowKeyDown); | ||
| 1063 | window.addEventListener("mousedown", onMouseDown); | 955 | window.addEventListener("mousedown", onMouseDown); |
| 1064 | return () => { | 956 | return () => { |
| 1065 | window.removeEventListener("keydown", onWindowKeyDown); | ||
| 1066 | window.removeEventListener("mousedown", onMouseDown); | 957 | window.removeEventListener("mousedown", onMouseDown); |
| 1067 | }; | 958 | }; |
| 1068 | }, [ | 959 | }, [clearSelection, selectedThreadIds.size]); |
| 1069 | clearSelection, | ||
| 1070 | getDraftThread, | ||
| 1071 | handleNewThread, | ||
| 1072 | keybindings, | ||
| 1073 | projects, | ||
| 1074 | routeThreadId, | ||
| 1075 | selectedThreadIds.size, | ||
| 1076 | threads, | ||
| 1077 | ]); | ||
| 1078 | 960 | ||
| 1079 | useEffect(() => { | 961 | useEffect(() => { |
| 1080 | if (!isElectron) return; | 962 | if (!isElectron) return; |
apps/web/src/hooks/useHandleNewThread.ts created+113| ... | @@ -0,0 +1,113 @@ | ||
| 1 | import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { useNavigate, useParams } from "@tanstack/react-router"; | ||
| 3 | import { useCallback } from "react"; | ||
| 4 | import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; | ||
| 5 | import { newThreadId } from "../lib/utils"; | ||
| 6 | import { useStore } from "../store"; | ||
| 7 | |||
| 8 | export function useHandleNewThread() { | ||
| 9 | const projects = useStore((store) => store.projects); | ||
| 10 | const threads = useStore((store) => store.threads); | ||
| 11 | const getDraftThreadByProjectId = useComposerDraftStore( | ||
| 12 | (store) => store.getDraftThreadByProjectId, | ||
| 13 | ); | ||
| 14 | const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); | ||
| 15 | const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); | ||
| 16 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | ||
| 17 | const clearProjectDraftThreadId = useComposerDraftStore( | ||
| 18 | (store) => store.clearProjectDraftThreadId, | ||
| 19 | ); | ||
| 20 | const navigate = useNavigate(); | ||
| 21 | const routeThreadId = useParams({ | ||
| 22 | strict: false, | ||
| 23 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | ||
| 24 | }); | ||
| 25 | |||
| 26 | const activeThread = routeThreadId | ||
| 27 | ? threads.find((thread) => thread.id === routeThreadId) | ||
| 28 | : undefined; | ||
| 29 | const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; | ||
| 30 | |||
| 31 | const handleNewThread = useCallback( | ||
| 32 | ( | ||
| 33 | projectId: ProjectId, | ||
| 34 | options?: { | ||
| 35 | branch?: string | null; | ||
| 36 | worktreePath?: string | null; | ||
| 37 | envMode?: DraftThreadEnvMode; | ||
| 38 | }, | ||
| 39 | ): Promise<void> => { | ||
| 40 | const hasBranchOption = options?.branch !== undefined; | ||
| 41 | const hasWorktreePathOption = options?.worktreePath !== undefined; | ||
| 42 | const hasEnvModeOption = options?.envMode !== undefined; | ||
| 43 | const storedDraftThread = getDraftThreadByProjectId(projectId); | ||
| 44 | if (storedDraftThread) { | ||
| 45 | return (async () => { | ||
| 46 | if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { | ||
| 47 | setDraftThreadContext(storedDraftThread.threadId, { | ||
| 48 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), | ||
| 49 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), | ||
| 50 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), | ||
| 51 | }); | ||
| 52 | } | ||
| 53 | setProjectDraftThreadId(projectId, storedDraftThread.threadId); | ||
| 54 | if (routeThreadId === storedDraftThread.threadId) { | ||
| 55 | return; | ||
| 56 | } | ||
| 57 | await navigate({ | ||
| 58 | to: "/$threadId", | ||
| 59 | params: { threadId: storedDraftThread.threadId }, | ||
| 60 | }); | ||
| 61 | })(); | ||
| 62 | } | ||
| 63 | |||
| 64 | clearProjectDraftThreadId(projectId); | ||
| 65 | |||
| 66 | if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { | ||
| 67 | if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { | ||
| 68 | setDraftThreadContext(routeThreadId, { | ||
| 69 | ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), | ||
| 70 | ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), | ||
| 71 | ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), | ||
| 72 | }); | ||
| 73 | } | ||
| 74 | setProjectDraftThreadId(projectId, routeThreadId); | ||
| 75 | return Promise.resolve(); | ||
| 76 | } | ||
| 77 | |||
| 78 | const threadId = newThreadId(); | ||
| 79 | const createdAt = new Date().toISOString(); | ||
| 80 | return (async () => { | ||
| 81 | setProjectDraftThreadId(projectId, threadId, { | ||
| 82 | createdAt, | ||
| 83 | branch: options?.branch ?? null, | ||
| 84 | worktreePath: options?.worktreePath ?? null, | ||
| 85 | envMode: options?.envMode ?? "local", | ||
| 86 | runtimeMode: DEFAULT_RUNTIME_MODE, | ||
| 87 | }); | ||
| 88 | |||
| 89 | await navigate({ | ||
| 90 | to: "/$threadId", | ||
| 91 | params: { threadId }, | ||
| 92 | }); | ||
| 93 | })(); | ||
| 94 | }, | ||
| 95 | [ | ||
| 96 | activeDraftThread, | ||
| 97 | clearProjectDraftThreadId, | ||
| 98 | getDraftThreadByProjectId, | ||
| 99 | navigate, | ||
| 100 | routeThreadId, | ||
| 101 | setDraftThreadContext, | ||
| 102 | setProjectDraftThreadId, | ||
| 103 | ], | ||
| 104 | ); | ||
| 105 | |||
| 106 | return { | ||
| 107 | activeDraftThread, | ||
| 108 | activeThread, | ||
| 109 | handleNewThread, | ||
| 110 | projects, | ||
| 111 | routeThreadId, | ||
| 112 | }; | ||
| 113 | } | ||
apps/web/src/lib/terminalFocus.ts created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | export function isTerminalFocused(): boolean { | ||
| 2 | const activeElement = document.activeElement; | ||
| 3 | if (!(activeElement instanceof HTMLElement)) return false; | ||
| 4 | if (activeElement.classList.contains("xterm-helper-textarea")) return true; | ||
| 5 | return activeElement.closest(".thread-terminal-drawer .xterm") !== null; | ||
| 6 | } | ||
apps/web/src/routes/_chat.tsx+79| ... | @@ -1,10 +1,88 @@ | ... | @@ -1,10 +1,88 @@ |
| 1 | import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; | ||
| 2 | import { useQuery } from "@tanstack/react-query"; | ||
| 1 | import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; | 3 | import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 2 | import { useEffect } from "react"; | 4 | import { useEffect } from "react"; |
| 3 | 5 | ||
| 4 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; | 6 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; |
| 5 | import ThreadSidebar from "../components/Sidebar"; | 7 | import ThreadSidebar from "../components/Sidebar"; |
| 8 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | ||
| 9 | import { isTerminalFocused } from "../lib/terminalFocus"; | ||
| 10 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | ||
| 11 | import { resolveShortcutCommand } from "../keybindings"; | ||
| 12 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | ||
| 13 | import { useThreadSelectionStore } from "../threadSelectionStore"; | ||
| 6 | import { Sidebar, SidebarProvider } from "~/components/ui/sidebar"; | 14 | import { Sidebar, SidebarProvider } from "~/components/ui/sidebar"; |
| 7 | 15 | ||
| 16 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | ||
| 17 | |||
| 18 | function ChatRouteGlobalShortcuts() { | ||
| 19 | const clearSelection = useThreadSelectionStore((state) => state.clearSelection); | ||
| 20 | const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); | ||
| 21 | const { activeDraftThread, activeThread, handleNewThread, projects, routeThreadId } = | ||
| 22 | useHandleNewThread(); | ||
| 23 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); | ||
| 24 | const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; | ||
| 25 | const terminalOpen = useTerminalStateStore((state) => | ||
| 26 | routeThreadId | ||
| 27 | ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen | ||
| 28 | : false, | ||
| 29 | ); | ||
| 30 | |||
| 31 | useEffect(() => { | ||
| 32 | const onWindowKeyDown = (event: KeyboardEvent) => { | ||
| 33 | if (event.defaultPrevented) return; | ||
| 34 | |||
| 35 | if (event.key === "Escape" && selectedThreadIdsSize > 0) { | ||
| 36 | event.preventDefault(); | ||
| 37 | clearSelection(); | ||
| 38 | return; | ||
| 39 | } | ||
| 40 | |||
| 41 | const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; | ||
| 42 | if (!projectId) return; | ||
| 43 | |||
| 44 | const command = resolveShortcutCommand(event, keybindings, { | ||
| 45 | context: { | ||
| 46 | terminalFocus: isTerminalFocused(), | ||
| 47 | terminalOpen, | ||
| 48 | }, | ||
| 49 | }); | ||
| 50 | |||
| 51 | if (command === "chat.newLocal") { | ||
| 52 | event.preventDefault(); | ||
| 53 | event.stopPropagation(); | ||
| 54 | void handleNewThread(projectId); | ||
| 55 | return; | ||
| 56 | } | ||
| 57 | |||
| 58 | if (command !== "chat.new") return; | ||
| 59 | event.preventDefault(); | ||
| 60 | event.stopPropagation(); | ||
| 61 | void handleNewThread(projectId, { | ||
| 62 | branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, | ||
| 63 | worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, | ||
| 64 | envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), | ||
| 65 | }); | ||
| 66 | }; | ||
| 67 | |||
| 68 | window.addEventListener("keydown", onWindowKeyDown); | ||
| 69 | return () => { | ||
| 70 | window.removeEventListener("keydown", onWindowKeyDown); | ||
| 71 | }; | ||
| 72 | }, [ | ||
| 73 | activeDraftThread, | ||
| 74 | activeThread, | ||
| 75 | clearSelection, | ||
| 76 | handleNewThread, | ||
| 77 | keybindings, | ||
| 78 | projects, | ||
| 79 | selectedThreadIdsSize, | ||
| 80 | terminalOpen, | ||
| 81 | ]); | ||
| 82 | |||
| 83 | return null; | ||
| 84 | } | ||
| 85 | |||
| 8 | function ChatRouteLayout() { | 86 | function ChatRouteLayout() { |
| 9 | const navigate = useNavigate(); | 87 | const navigate = useNavigate(); |
| 10 | 88 | ||
| ... | @@ -26,6 +104,7 @@ function ChatRouteLayout() { | ... | @@ -26,6 +104,7 @@ function ChatRouteLayout() { |
| 26 | 104 | ||
| 27 | return ( | 105 | return ( |
| 28 | <SidebarProvider defaultOpen> | 106 | <SidebarProvider defaultOpen> |
| 107 | <ChatRouteGlobalShortcuts /> | ||
| 29 | <Sidebar | 108 | <Sidebar |
| 30 | side="left" | 109 | side="left" |
| 31 | collapsible="offcanvas" | 110 | collapsible="offcanvas" |