1import { AuthPreviewOperateScope } from "@t3tools/contracts";
2import { Outlet, createFileRoute, redirect, useParams } from "@tanstack/react-router";
3import { useAtomValue } from "@effect/atom-react";
4import { useEffect, useMemo } from "react";
5
6import { isCommandPaletteOpen } from "../commandPaletteBus";
7import { ThreadRouteView } from "../components/ThreadRouteView";
8import { ThreadLinkPrompt } from "../components/promptLinks/ThreadLinkPrompt";
9import { resolveThreadRouteTarget } from "../threadRoutes";
10import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";
11import { openCommandPalette } from "../commandPaletteBus";
12import { useProjects } from "../state/entities";
13import { isPreviewAvailableFor } from "../browser/previewRuntime";
14import { usePrimaryEnvironmentId } from "../state/environments";
15import { useEnvironmentScope } from "../state/session";
16import { selectProjectGroupingSettings } from "../logicalProject";
17import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
18import { dispatchPreviewAction } from "../components/preview/previewActionBus";
19import { useHandleNewThread } from "../hooks/useHandleNewThread";
20import { useScratchProject } from "../hooks/useScratchProject";
21import { startNewThreadFromContext } from "../lib/chatThreadActions";
22import { isPreviewFocused } from "../lib/previewFocus";
23import { isTerminalFocused } from "../lib/terminalFocus";
24import { isEditableFocused } from "../lib/editableFocus";
25import { isModelPickerOpen } from "../modelPickerVisibility";
26import { undoLatestThreadAction } from "../hooks/showThreadUndoNotice";
27import { resolveShortcutCommand } from "../keybindings";
28import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
29import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
30import { useThreadSelectionStore } from "../threadSelectionStore";
31import { stackedThreadToast, toastManager } from "~/components/ui/toast";
32import { primaryServerKeybindingsAtom } from "~/state/server";
33
34function 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
218function 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
236export 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});