diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d8b74c8cc9f488569667046582e832d8d971f522..084e835ac81070cb7f13809d4d1db7f62292ea35 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -353,7 +353,8 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } -function resolveWsRpc(tag: string): unknown { +function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { + const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { return fixture.snapshot; } @@ -395,6 +396,19 @@ function resolveWsRpc(tag: string): unknown { truncated: false, }; } + if (tag === WS_METHODS.terminalOpen) { + return { + threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID, + terminalId: typeof body.terminalId === "string" ? body.terminalId : "default", + cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project", + status: "running", + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: NOW_ISO, + }; + } return {}; } @@ -423,7 +437,7 @@ const worker = setupWorker( client.send( JSON.stringify({ id: request.id, - result: resolveWsRpc(method), + result: resolveWsRpc(request.body), }), ); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 22717291d8f42e72d519b2aa9b830a7d807e0230..f047f384ea208dac334163ecf1c620e14d599fee 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -117,6 +117,7 @@ import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { resolveAppModelSelection, useAppSettings } from "../appSettings"; +import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -1937,13 +1938,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeThreadId, focusComposer, terminalState.terminalOpen]); useEffect(() => { - const isTerminalFocused = (): boolean => { - const activeElement = document.activeElement; - if (!(activeElement instanceof HTMLElement)) return false; - if (activeElement.classList.contains("xterm-helper-textarea")) return true; - return activeElement.closest(".thread-terminal-drawer .xterm") !== null; - }; - const handler = (event: globalThis.KeyboardEvent) => { if (!activeThreadId || event.defaultPrevented) return; const shortcutContext = { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9d911e2f09d1bc4e84ac11d4024287e099900a7b..baf17fa996f02dd0657194b40427b019a3a6dfac 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -27,7 +27,6 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { - DEFAULT_RUNTIME_MODE, DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, ProjectId, @@ -40,14 +39,15 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; -import { isMacPlatform, newCommandId, newProjectId, newThreadId } from "../lib/utils"; +import { isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; -import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; +import { shortcutLabelForCommand } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; -import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { @@ -262,11 +262,8 @@ export default function Sidebar() { const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); - const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); - const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); - const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); @@ -276,6 +273,7 @@ export default function Sidebar() { const navigate = useNavigate(); const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); const { settings: appSettings } = useAppSettings(); + const { handleNewThread } = useHandleNewThread(); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), @@ -384,80 +382,6 @@ export default function Sidebar() { }); }, []); - const handleNewThread = useCallback( - ( - projectId: ProjectId, - options?: { - branch?: string | null; - worktreePath?: string | null; - envMode?: DraftThreadEnvMode; - }, - ): Promise => { - const hasBranchOption = options?.branch !== undefined; - const hasWorktreePathOption = options?.worktreePath !== undefined; - const hasEnvModeOption = options?.envMode !== undefined; - const storedDraftThread = getDraftThreadByProjectId(projectId); - if (storedDraftThread) { - return (async () => { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(storedDraftThread.threadId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - }); - } - setProjectDraftThreadId(projectId, storedDraftThread.threadId); - if (routeThreadId === storedDraftThread.threadId) { - return; - } - await navigate({ - to: "/$threadId", - params: { threadId: storedDraftThread.threadId }, - }); - })(); - } - clearProjectDraftThreadId(projectId); - - const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; - if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(routeThreadId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - }); - } - setProjectDraftThreadId(projectId, routeThreadId); - return Promise.resolve(); - } - const threadId = newThreadId(); - const createdAt = new Date().toISOString(); - return (async () => { - setProjectDraftThreadId(projectId, threadId, { - createdAt, - branch: options?.branch ?? null, - worktreePath: options?.worktreePath ?? null, - envMode: options?.envMode ?? "local", - runtimeMode: DEFAULT_RUNTIME_MODE, - }); - - await navigate({ - to: "/$threadId", - params: { threadId }, - }); - })(); - }, - [ - clearProjectDraftThreadId, - getDraftThreadByProjectId, - navigate, - getDraftThread, - routeThreadId, - setDraftThreadContext, - setProjectDraftThreadId, - ], - ); - const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { const latestThread = threads @@ -1021,37 +945,6 @@ export default function Sidebar() { ); useEffect(() => { - const onWindowKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape" && selectedThreadIds.size > 0) { - event.preventDefault(); - clearSelection(); - return; - } - - const activeThread = routeThreadId - ? threads.find((thread) => thread.id === routeThreadId) - : undefined; - const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; - if (isChatNewLocalShortcut(event, keybindings)) { - const projectId = - activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; - if (!projectId) return; - event.preventDefault(); - void handleNewThread(projectId); - return; - } - - if (!isChatNewShortcut(event, keybindings)) return; - const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; - if (!projectId) return; - event.preventDefault(); - void handleNewThread(projectId, { - branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, - worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, - envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), - }); - }; - const onMouseDown = (event: globalThis.MouseEvent) => { if (selectedThreadIds.size === 0) return; const target = event.target instanceof HTMLElement ? event.target : null; @@ -1059,22 +952,11 @@ export default function Sidebar() { clearSelection(); }; - window.addEventListener("keydown", onWindowKeyDown); window.addEventListener("mousedown", onMouseDown); return () => { - window.removeEventListener("keydown", onWindowKeyDown); window.removeEventListener("mousedown", onMouseDown); }; - }, [ - clearSelection, - getDraftThread, - handleNewThread, - keybindings, - projects, - routeThreadId, - selectedThreadIds.size, - threads, - ]); + }, [clearSelection, selectedThreadIds.size]); useEffect(() => { if (!isElectron) return; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts new file mode 100644 index 0000000000000000000000000000000000000000..e00fd6af2d3fd3c30203a19a96dce843e2fe6908 --- /dev/null +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -0,0 +1,113 @@ +import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { useCallback } from "react"; +import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; +import { newThreadId } from "../lib/utils"; +import { useStore } from "../store"; + +export function useHandleNewThread() { + const projects = useStore((store) => store.projects); + const threads = useStore((store) => store.threads); + const getDraftThreadByProjectId = useComposerDraftStore( + (store) => store.getDraftThreadByProjectId, + ); + const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); + const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const clearProjectDraftThreadId = useComposerDraftStore( + (store) => store.clearProjectDraftThreadId, + ); + const navigate = useNavigate(); + const routeThreadId = useParams({ + strict: false, + select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), + }); + + const activeThread = routeThreadId + ? threads.find((thread) => thread.id === routeThreadId) + : undefined; + const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; + + const handleNewThread = useCallback( + ( + projectId: ProjectId, + options?: { + branch?: string | null; + worktreePath?: string | null; + envMode?: DraftThreadEnvMode; + }, + ): Promise => { + const hasBranchOption = options?.branch !== undefined; + const hasWorktreePathOption = options?.worktreePath !== undefined; + const hasEnvModeOption = options?.envMode !== undefined; + const storedDraftThread = getDraftThreadByProjectId(projectId); + if (storedDraftThread) { + return (async () => { + if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { + setDraftThreadContext(storedDraftThread.threadId, { + ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), + ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), + ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + }); + } + setProjectDraftThreadId(projectId, storedDraftThread.threadId); + if (routeThreadId === storedDraftThread.threadId) { + return; + } + await navigate({ + to: "/$threadId", + params: { threadId: storedDraftThread.threadId }, + }); + })(); + } + + clearProjectDraftThreadId(projectId); + + if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { + if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { + setDraftThreadContext(routeThreadId, { + ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), + ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), + ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + }); + } + setProjectDraftThreadId(projectId, routeThreadId); + return Promise.resolve(); + } + + const threadId = newThreadId(); + const createdAt = new Date().toISOString(); + return (async () => { + setProjectDraftThreadId(projectId, threadId, { + createdAt, + branch: options?.branch ?? null, + worktreePath: options?.worktreePath ?? null, + envMode: options?.envMode ?? "local", + runtimeMode: DEFAULT_RUNTIME_MODE, + }); + + await navigate({ + to: "/$threadId", + params: { threadId }, + }); + })(); + }, + [ + activeDraftThread, + clearProjectDraftThreadId, + getDraftThreadByProjectId, + navigate, + routeThreadId, + setDraftThreadContext, + setProjectDraftThreadId, + ], + ); + + return { + activeDraftThread, + activeThread, + handleNewThread, + projects, + routeThreadId, + }; +} diff --git a/apps/web/src/lib/terminalFocus.ts b/apps/web/src/lib/terminalFocus.ts new file mode 100644 index 0000000000000000000000000000000000000000..d24c9572a381600a1bcbf5f75832abe7a2da61b9 --- /dev/null +++ b/apps/web/src/lib/terminalFocus.ts @@ -0,0 +1,6 @@ +export function isTerminalFocused(): boolean { + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement)) return false; + if (activeElement.classList.contains("xterm-helper-textarea")) return true; + return activeElement.closest(".thread-terminal-drawer .xterm") !== null; +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 0d7f1724b2017de3709b7f4145dd05730251d236..8e3145d999ca9b54f34f25fbd0f7326077229904 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,10 +1,88 @@ +import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { useQuery } from "@tanstack/react-query"; import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; import ThreadSidebar from "../components/Sidebar"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { resolveShortcutCommand } from "../keybindings"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { useThreadSelectionStore } from "../threadSelectionStore"; import { Sidebar, SidebarProvider } from "~/components/ui/sidebar"; +const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; + +function ChatRouteGlobalShortcuts() { + const clearSelection = useThreadSelectionStore((state) => state.clearSelection); + const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); + const { activeDraftThread, activeThread, handleNewThread, projects, routeThreadId } = + useHandleNewThread(); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; + const terminalOpen = useTerminalStateStore((state) => + routeThreadId + ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen + : false, + ); + + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + + if (event.key === "Escape" && selectedThreadIdsSize > 0) { + event.preventDefault(); + clearSelection(); + return; + } + + const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; + if (!projectId) return; + + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + }, + }); + + if (command === "chat.newLocal") { + event.preventDefault(); + event.stopPropagation(); + void handleNewThread(projectId); + return; + } + + if (command !== "chat.new") return; + event.preventDefault(); + event.stopPropagation(); + void handleNewThread(projectId, { + branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, + worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, + envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), + }); + }; + + window.addEventListener("keydown", onWindowKeyDown); + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + }; + }, [ + activeDraftThread, + activeThread, + clearSelection, + handleNewThread, + keybindings, + projects, + selectedThreadIdsSize, + terminalOpen, + ]); + + return null; +} + function ChatRouteLayout() { const navigate = useNavigate(); @@ -26,6 +104,7 @@ function ChatRouteLayout() { return ( +