| 1 | import { AuthPreviewOperateScope } from "@t3tools/contracts"; |
| 2 | import { Outlet, createFileRoute, redirect, useParams } from "@tanstack/react-router"; |
| 3 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | import { useEffect, useMemo } from "react"; |
| 5 | |
| 6 | import { isCommandPaletteOpen } from "../commandPaletteBus"; |
| 7 | import { ThreadRouteView } from "../components/ThreadRouteView"; |
| 8 | import { ThreadLinkPrompt } from "../components/promptLinks/ThreadLinkPrompt"; |
| 9 | import { resolveThreadRouteTarget } from "../threadRoutes"; |
| 10 | import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; |
| 11 | import { openCommandPalette } from "../commandPaletteBus"; |
| 12 | import { useProjects } from "../state/entities"; |
| 13 | import { isPreviewAvailableFor } from "../browser/previewRuntime"; |
| 14 | import { usePrimaryEnvironmentId } from "../state/environments"; |
| 15 | import { useEnvironmentScope } from "../state/session"; |
| 16 | import { selectProjectGroupingSettings } from "../logicalProject"; |
| 17 | import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; |
| 18 | import { dispatchPreviewAction } from "../components/preview/previewActionBus"; |
| 19 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 20 | import { useScratchProject } from "../hooks/useScratchProject"; |
| 21 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 22 | import { isPreviewFocused } from "../lib/previewFocus"; |
| 23 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 24 | import { isEditableFocused } from "../lib/editableFocus"; |
| 25 | import { isModelPickerOpen } from "../modelPickerVisibility"; |
| 26 | import { undoLatestThreadAction } from "../hooks/showThreadUndoNotice"; |
| 27 | import { resolveShortcutCommand } from "../keybindings"; |
| 28 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 29 | import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; |
| 30 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 31 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; |
| 32 | import { primaryServerKeybindingsAtom } from "~/state/server"; |
| 33 | |
| 34 | function ChatRouteGlobalShortcuts() { |
| 35 | const clearSelection = useThreadSelectionStore((state) => state.clearSelection); |
| 36 | const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); |
| 37 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = |
| 38 | useHandleNewThread(); |
| 39 | const canOperatePreview = useEnvironmentScope( |
| 40 | routeThreadRef?.environmentId ?? null, |
| 41 | AuthPreviewOperateScope, |
| 42 | ); |
| 43 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 44 | const legacySidebarEnabled = useLegacySidebarEnabled(); |
| 45 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 46 | const projects = useProjects(); |
| 47 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 48 | const { scratchEnvironmentId, startScratchThread } = useScratchProject(); |
| 49 | const projectGroupCount = useMemo( |
| 50 | () => |
| 51 | buildSidebarProjectSnapshots({ |
| 52 | projects, |
| 53 | settings: projectGroupingSettings, |
| 54 | primaryEnvironmentId, |
| 55 | resolveEnvironmentLabel: () => null, |
| 56 | }).length, |
| 57 | [primaryEnvironmentId, projectGroupingSettings, projects], |
| 58 | ); |
| 59 | const terminalOpen = useTerminalUiStateStore((state) => |
| 60 | routeThreadRef |
| 61 | ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen |
| 62 | : false, |
| 63 | ); |
| 64 | // The `previewOpen` shortcut-context flag here uses the store-only value; |
| 65 | // the URL-aware arbitration lives inside ChatView's `onTogglePreview`, |
| 66 | // which we invoke via the action bus to avoid duplicating the rule. |
| 67 | const previewOpen = useRightPanelStore((state) => |
| 68 | routeThreadRef |
| 69 | ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" |
| 70 | : false, |
| 71 | ); |
| 72 | useEffect(() => { |
| 73 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 74 | if (event.defaultPrevented) return; |
| 75 | const command = resolveShortcutCommand(event, keybindings, { |
| 76 | context: { |
| 77 | terminalFocus: isTerminalFocused(), |
| 78 | terminalOpen, |
| 79 | previewFocus: isPreviewFocused(), |
| 80 | previewOpen, |
| 81 | editableFocus: isEditableFocused(event.target), |
| 82 | modelPickerOpen: isModelPickerOpen(), |
| 83 | }, |
| 84 | }); |
| 85 | |
| 86 | if (isCommandPaletteOpen()) { |
| 87 | return; |
| 88 | } |
| 89 | |
| 90 | if (command === "thread.undo") { |
| 91 | if (event.repeat || isModelPickerOpen()) return; |
| 92 | if (undoLatestThreadAction()) { |
| 93 | event.preventDefault(); |
| 94 | event.stopPropagation(); |
| 95 | } |
| 96 | return; |
| 97 | } |
| 98 | |
| 99 | if (event.key === "Escape" && selectedThreadKeysSize > 0) { |
| 100 | event.preventDefault(); |
| 101 | clearSelection(); |
| 102 | return; |
| 103 | } |
| 104 | |
| 105 | if (command === "chat.newLocal") { |
| 106 | event.preventDefault(); |
| 107 | event.stopPropagation(); |
| 108 | void startNewThreadFromContext({ |
| 109 | activeDraftThread, |
| 110 | activeThread: activeThread ?? undefined, |
| 111 | defaultProjectRef, |
| 112 | handleNewThread, |
| 113 | }); |
| 114 | return; |
| 115 | } |
| 116 | |
| 117 | if (command === "chat.newWithoutProject") { |
| 118 | const environmentId = scratchEnvironmentId( |
| 119 | activeThread?.environmentId ?? activeDraftThread?.environmentId ?? primaryEnvironmentId, |
| 120 | ); |
| 121 | if (environmentId === null) return; |
| 122 | event.preventDefault(); |
| 123 | event.stopPropagation(); |
| 124 | void startScratchThread(environmentId); |
| 125 | return; |
| 126 | } |
| 127 | |
| 128 | if (command === "chat.new") { |
| 129 | event.preventDefault(); |
| 130 | event.stopPropagation(); |
| 131 | // The default sidebar routes creation through the command palette |
| 132 | // whenever there is a real choice to make; the legacy sidebar (and |
| 133 | // single-project setups) keep the immediate contextual create. |
| 134 | if (!legacySidebarEnabled && projectGroupCount > 1) { |
| 135 | openCommandPalette({ open: "new-thread-in" }); |
| 136 | return; |
| 137 | } |
| 138 | void startNewThreadFromContext({ |
| 139 | activeDraftThread, |
| 140 | activeThread: activeThread ?? undefined, |
| 141 | defaultProjectRef, |
| 142 | handleNewThread, |
| 143 | }); |
| 144 | return; |
| 145 | } |
| 146 | |
| 147 | if (command === "preview.toggle") { |
| 148 | event.preventDefault(); |
| 149 | event.stopPropagation(); |
| 150 | if (!routeThreadRef || (!canOperatePreview && !previewOpen)) return; |
| 151 | if (!isPreviewAvailableFor(routeThreadRef.environmentId)) { |
| 152 | toastManager.add( |
| 153 | stackedThreadToast({ |
| 154 | type: "info", |
| 155 | title: "Preview is desktop-only", |
| 156 | description: "Open The Knife in the desktop app to use the in-app preview.", |
| 157 | }), |
| 158 | ); |
| 159 | return; |
| 160 | } |
| 161 | dispatchPreviewAction("toggle-panel"); |
| 162 | return; |
| 163 | } |
| 164 | |
| 165 | // The remaining preview commands only fire when the panel is the |
| 166 | // currently-focused tenant. The `when: previewFocus` rule already |
| 167 | // gates this, but defend against the keybinding being misconfigured. |
| 168 | if ( |
| 169 | command === "preview.refresh" || |
| 170 | command === "preview.focusUrl" || |
| 171 | command === "preview.zoomIn" || |
| 172 | command === "preview.zoomOut" || |
| 173 | command === "preview.resetZoom" |
| 174 | ) { |
| 175 | event.preventDefault(); |
| 176 | event.stopPropagation(); |
| 177 | if (!canOperatePreview) return; |
| 178 | const action = |
| 179 | command === "preview.refresh" |
| 180 | ? "refresh" |
| 181 | : command === "preview.focusUrl" |
| 182 | ? "focus-url" |
| 183 | : command === "preview.zoomIn" |
| 184 | ? "zoom-in" |
| 185 | : command === "preview.zoomOut" |
| 186 | ? "zoom-out" |
| 187 | : "reset-zoom"; |
| 188 | dispatchPreviewAction(action); |
| 189 | } |
| 190 | }; |
| 191 | |
| 192 | window.addEventListener("keydown", onWindowKeyDown); |
| 193 | return () => { |
| 194 | window.removeEventListener("keydown", onWindowKeyDown); |
| 195 | }; |
| 196 | }, [ |
| 197 | activeDraftThread, |
| 198 | activeThread, |
| 199 | clearSelection, |
| 200 | canOperatePreview, |
| 201 | handleNewThread, |
| 202 | keybindings, |
| 203 | defaultProjectRef, |
| 204 | previewOpen, |
| 205 | primaryEnvironmentId, |
| 206 | projectGroupCount, |
| 207 | routeThreadRef, |
| 208 | scratchEnvironmentId, |
| 209 | selectedThreadKeysSize, |
| 210 | startScratchThread, |
| 211 | legacySidebarEnabled, |
| 212 | terminalOpen, |
| 213 | ]); |
| 214 | |
| 215 | return null; |
| 216 | } |
| 217 | |
| 218 | function ChatRouteLayout() { |
| 219 | // Both thread routes render here, not in their own leaf components, so the |
| 220 | // draft-to-thread promotion keeps one ChatView mounted across the swap. |
| 221 | const threadTarget = useParams({ |
| 222 | strict: false, |
| 223 | select: (params) => resolveThreadRouteTarget(params), |
| 224 | }); |
| 225 | return ( |
| 226 | <> |
| 227 | <ChatRouteGlobalShortcuts /> |
| 228 | {threadTarget?.kind === "server" ? ( |
| 229 | <ThreadLinkPrompt threadRef={threadTarget.threadRef} /> |
| 230 | ) : null} |
| 231 | {threadTarget ? <ThreadRouteView target={threadTarget} /> : <Outlet />} |
| 232 | </> |
| 233 | ); |
| 234 | } |
| 235 | |
| 236 | export const Route = createFileRoute("/_chat")({ |
| 237 | beforeLoad: async ({ context }) => { |
| 238 | if ( |
| 239 | context.authGateState.status !== "authenticated" && |
| 240 | context.authGateState.status !== "hosted-static" |
| 241 | ) { |
| 242 | throw redirect({ to: "/pair", replace: true }); |
| 243 | } |
| 244 | }, |
| 245 | component: ChatRouteLayout, |
| 246 | }); |