Open History to see the last commit
1import { AuthPreviewOperateScope, type EnvironmentId } from "@t3tools/contracts";
2import type { AnyRouter } from "@tanstack/react-router";
3
4import { stackedThreadToast, toastManager } from "~/components/ui/toast";
5import { isPreviewAvailableFor } from "../browser/previewRuntime";
6import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
7import { useComposerDraftStore } from "../composerDraftStore";
8import { dispatchPreviewAction, type PreviewAction } from "../components/preview/previewActionBus";
9import { isAppModalPage } from "../components/sidebar/mainAppLocation";
10import { shouldCreateNewThreadInCurrentProject } from "../components/Sidebar.logic";
11import {
12 createNewThreadHandler,
13 readLastUsedProjectKey,
14 resolveDefaultProjectRef,
15} from "../hooks/useHandleNewThread";
16import { createScratchProjectActions } from "../hooks/useScratchProject";
17import { getClientSettings } from "../hooks/useSettings";
18import { startNewThreadFromContext } from "../lib/chatThreadActions";
19import { selectProjectGroupingSettings } from "@t3tools/client-runtime/state/project-grouping";
20import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
21import { appAtomRegistry } from "../rpc/atomRegistry";
22import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
23import { readProjects, readThreadShell } from "../state/entities";
24import { defaultProjectFallbackReadyAtom } from "../state/projects";
25import { environmentPresentations } from "../state/presentation";
26import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment";
27import { readEnvironmentScope } from "../state/session";
28import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
29import { useThreadSelectionStore } from "../threadSelectionStore";
30import { resolveThreadRouteTarget } from "../threadRoutes";
31import { useUiStateStore } from "../uiStateStore";
32import { type CommandHandler, registerCommands } from "./dispatcher";
33
34const CHAT_ROUTE_ID = "/_chat";
35
36/**
37 * New-thread and preview commands, live while a chat route is showing and no
38 * modal page (Settings, Usage) covers it. Everything is read at event time, so
39 * the scope is registered once instead of re-registering on every store change.
40 */
41export function installChatRouteCommands(router: AnyRouter): () => void {
42 const handleNewThread = createNewThreadHandler(router);
43 const scratch = createScratchProjectActions({
44 environments: () =>
45 [...appAtomRegistry.get(environmentPresentations.presentationsAtom)].map(
46 ([environmentId, presentation]) => ({ ...presentation, environmentId }),
47 ),
48 primaryEnvironmentId: () => appAtomRegistry.get(primaryEnvironmentIdAtom),
49 registry: appAtomRegistry,
50 handleNewThread,
51 });
52
53 const readRouteTarget = () => resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {});
54 const readRouteThreadRef = () => {
55 const target = readRouteTarget();
56 return target?.kind === "server" ? target.threadRef : null;
57 };
58 const readPreviewOpen = () => {
59 const threadRef = readRouteThreadRef();
60 // Store-only on purpose: the URL-aware arbitration lives in ChatView's
61 // preview toggle, which the action bus reaches.
62 return threadRef
63 ? selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, threadRef) === "preview"
64 : false;
65 };
66 const readThreadContext = () => {
67 const lastUsedProjectKey = readLastUsedProjectKey();
68 const target = readRouteTarget();
69 const drafts = useComposerDraftStore.getState();
70 const activeThread = target?.kind === "server" ? readThreadShell(target.threadRef) : null;
71 const activeDraftThread = target
72 ? target.kind === "server"
73 ? drafts.getDraftThread(target.threadRef)
74 : drafts.getDraftSession(target.draftId)
75 : null;
76 return {
77 activeDraftThread,
78 activeThread: activeThread ?? undefined,
79 defaultProjectRef: resolveDefaultProjectRef(
80 readProjects(),
81 useUiStateStore.getState().projectOrder,
82 lastUsedProjectKey,
83 appAtomRegistry.get(defaultProjectFallbackReadyAtom(lastUsedProjectKey)),
84 ),
85 handleNewThread,
86 };
87 };
88 // Grouped like the sidebar, so the shortcut and the New thread button agree.
89 const readProjectGroups = () =>
90 buildSidebarProjectSnapshots({
91 projects: readProjects(),
92 groupScratchProjects: true,
93 settings: selectProjectGroupingSettings(getClientSettings()),
94 primaryEnvironmentId: appAtomRegistry.get(primaryEnvironmentIdAtom),
95 resolveEnvironmentLabel: () => null,
96 });
97 const canOperatePreview = (environmentId: EnvironmentId) =>
98 readEnvironmentScope(environmentId, AuthPreviewOperateScope);
99
100 const claim = (event: KeyboardEvent) => {
101 event.preventDefault();
102 event.stopPropagation();
103 };
104
105 const startNewLocal: CommandHandler = (event) => {
106 claim(event);
107 // With no current or open project, such as when every project is archived, pick one.
108 void startNewThreadFromContext(readThreadContext()).then((started) => {
109 if (!started) openCommandPalette({ open: "new-thread-in" });
110 });
111 };
112
113 const previewCommand =
114 (action: PreviewAction): CommandHandler =>
115 (event) => {
116 claim(event);
117 const threadRef = readRouteThreadRef();
118 if (threadRef && canOperatePreview(threadRef.environmentId)) dispatchPreviewAction(action);
119 };
120
121 return registerCommands(
122 {
123 "chat.newLocal": startNewLocal,
124 "chat.newWithoutProject": (event) => {
125 const { activeThread, activeDraftThread } = readThreadContext();
126 const environmentId = scratch.scratchEnvironmentId(
127 activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null,
128 );
129 if (environmentId === null) return;
130 claim(event);
131 void scratch.startScratchThread(environmentId);
132 },
133 "chat.new": (event) => {
134 const groups = readProjectGroups();
135 if (
136 !shouldCreateNewThreadInCurrentProject(
137 false,
138 groups.filter((group) => !group.archived).length,
139 groups.length,
140 )
141 ) {
142 claim(event);
143 openCommandPalette({ open: "new-thread-in" });
144 return;
145 }
146 startNewLocal(event);
147 },
148 "preview.toggle": (event) => {
149 claim(event);
150 const threadRef = readRouteThreadRef();
151 if (!threadRef || (!canOperatePreview(threadRef.environmentId) && !readPreviewOpen())) {
152 return;
153 }
154 if (!isPreviewAvailableFor(threadRef.environmentId)) {
155 toastManager.add(
156 stackedThreadToast({
157 type: "info",
158 title: "Preview is desktop-only",
159 description: "Open T3 Code in the desktop app to use the in-app preview.",
160 }),
161 );
162 return;
163 }
164 dispatchPreviewAction("toggle-panel");
165 },
166 "preview.refresh": previewCommand("refresh"),
167 "preview.focusUrl": previewCommand("focus-url"),
168 "preview.zoomIn": previewCommand("zoom-in"),
169 "preview.zoomOut": previewCommand("zoom-out"),
170 "preview.resetZoom": previewCommand("reset-zoom"),
171 "preview.find": previewCommand("find"),
172 },
173 {
174 when: () =>
175 router.state.matches.some((match) => match.routeId === CHAT_ROUTE_ID) &&
176 !router.state.matches.some((match) => isAppModalPage(match.pathname)) &&
177 !isCommandPaletteOpen(),
178 context: () => {
179 const threadRef = readRouteThreadRef();
180 return {
181 terminalOpen: threadRef
182 ? selectThreadTerminalUiState(
183 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
184 threadRef,
185 ).terminalOpen
186 : false,
187 previewOpen: readPreviewOpen(),
188 };
189 },
190 onKeyDown: (event) => {
191 if (event.key !== "Escape") return false;
192 const selection = useThreadSelectionStore.getState();
193 if (selection.selectedThreadKeys.size === 0) return false;
194 event.preventDefault();
195 selection.clearSelection();
196 },
197 },
198 );
199}