Skip to content
Source
Sign inSign up

appInit.tsx

307 lines13.0 KB

1import {
2 scopedThreadKey,
3 scopeProjectRef,
4 scopeThreadRef,
5} from "@t3tools/client-runtime/environment";
6import type { ScopedThreadRef } from "@t3tools/contracts";
7import type { ClientSettings } from "@t3tools/contracts/settings";
8import { createBrowserHistory, createHashHistory } from "@tanstack/react-router";
9import * as Schema from "effect/Schema";
10​
11import { applyAppearanceContrast } from "./appearanceContrast";
12import { applyAppearanceFontVariables, TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "./appearanceFonts";
13import { startElectronBrowserHost } from "./browser/ElectronBrowserHost";
14import { startBrowserProfileReporting } from "./browser/BrowserProfileReporter";
15import { startPreviewTitleRecording } from "./browserHistoryStore";
16import { retainThreadOverlaysForThread } from "./components/chat/threadOverlayRetention";
17import { resolveWindowTitleThreadStatus } from "./components/Sidebar.logic";
18import { startBackgroundThreadAnnouncements } from "./components/BackgroundThreadAnnouncer";
19import { startSshPasswordPromptQueue } from "./components/desktop/SshPasswordPromptDialog";
20import { startHostFreezeToasts } from "./components/HostFreezeToastCoordinator";
21import { startKeybindingsUpdateToasts } from "./components/KeybindingsUpdateToast";
22import { startLegacyThreadMigrationToast } from "./components/LegacyThreadMigrationToast";
23import { startPermissionUpdateNotices } from "./components/PermissionUpdateNotice";
24import { startAgentOpenedPreviews } from "./components/preview/agentOpenedPreviews";
25import { startMainAppLocationTracking } from "./components/sidebar/mainAppLocation";
26import { startDesktopUpdateLaunchNotice } from "./components/sidebar/updateEverythingRun";
27import { startSlowRpcRequestToasts } from "./components/SlowRpcRequestToastCoordinator";
28import { startThreadNotifications } from "./components/ThreadNotificationCoordinator";
29import { startGuideRunNotifications } from "./components/pullRequest/guide/PullRequestGuideRunCoordinator";
30import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "./branding";
31import { resolveServerBackedAppDisplayName, resolveWindowTitle } from "./branding.logic";
32import { isElectron } from "./env";
33import {
34 subscribeComposerDraftModelReconciliation,
35 subscribeDraftThreadPromotion,
36 useComposerDraftStore,
37} from "./composerDraftStore";
38import { startDefaultThemeAdoption } from "./hooks/useDefaultTheme";
39import { startEnvironmentThemeRepaint } from "./hooks/useEnvironmentTheme";
40import { readLocalStorageValue, subscribeLocalStorageKey } from "./hooks/useLocalStorage";
41import { startHostEnvironmentLookup } from "./state/primaryEnvironment";
42import {
43 getClientSettings,
44 startModelPreferencesMigration,
45 subscribeClientSettings,
46} from "./hooks/useSettings";
47import { syncBrowserChromeTheme } from "./hooks/useTheme";
48import { startThreadVisitedMigration } from "./hooks/useThreadVisitedMigration";
49import { installDesktopPasteAsText } from "./lib/desktopPasteAsText";
50import {
51 syncDocumentElectronPlatformClasses,
52 syncDocumentWindowControlsOverlayClass,
53} from "./lib/windowControlsOverlay";
54import { startProviderAuthDelivery } from "./providerAuthDelivery";
55import { getRouter, type AppRouter } from "./router";
56import { appAtomRegistry } from "./rpc/atomRegistry";
57import { environmentProjects } from "./state/projects";
58import { primaryServerConfigAtom } from "./state/server";
59import { environmentShell } from "./state/shell";
60import { installChatRouteCommands } from "./shortcuts/chatRouteCommands";
61import { startWorkspaceFocus } from "./lib/workspaceFocus";
62import { startI18n } from "./i18n/runtime";
63import { installCommandPaletteCommands } from "./shortcuts/commandPaletteCommands";
64import { registerOpenFavoriteEditorCommand } from "./components/chat/OpenInPickerShortcut";
65import { environmentThreadShells, threadEnvironment } from "./state/threads";
66import { resolveActiveThreadRouteRef, resolveThreadRouteTarget } from "./threadRoutes";
67​
68/** Writes appearance settings onto the document, touching only the groups that changed. */
69function startClientAppearance(): void {
70 const root = document.documentElement;
71 let applied: ClientSettings | null = null;
72 let appliedSeparateFonts: boolean | null = null;
73 const apply = () => {
74 const settings = getClientSettings();
75 const previous = applied;
76 applied = settings;
77 const separateFonts = readLocalStorageValue(
78 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
79 false,
80 Schema.Boolean,
81 );
82 const previousSeparateFonts = appliedSeparateFonts;
83 appliedSeparateFonts = separateFonts;
84 if (previous?.diffColorScheme !== settings.diffColorScheme) {
85 root.dataset.diffColorScheme = settings.diffColorScheme;
86 }
87 if (previous?.chatWidth !== settings.chatWidth) {
88 root.dataset.chatWidth = settings.chatWidth;
89 }
90 if (previous?.appearanceContrast !== settings.appearanceContrast) {
91 applyAppearanceContrast(root, settings.appearanceContrast);
92 }
93 if (previous?.glassOpacity !== settings.glassOpacity) {
94 root.style.setProperty("--glass-opacity", `${settings.glassOpacity}%`);
95 if (settings.glassOpacity === 100) {
96 root.style.setProperty("--glass-blur", "0px");
97 } else {
98 root.style.removeProperty("--glass-blur");
99 }
100 }
101 if (
102 previous?.fontFamilySans !== settings.fontFamilySans ||
103 previous.fontFamilyCode !== settings.fontFamilyCode ||
104 previous.fontFamilyComposer !== settings.fontFamilyComposer ||
105 previous.fontSizeInterface !== settings.fontSizeInterface ||
106 previous.fontSizePrompt !== settings.fontSizePrompt ||
107 previous.fontSizeCode !== settings.fontSizeCode ||
108 previous.fontSmoothing !== settings.fontSmoothing ||
109 previousSeparateFonts !== separateFonts
110 ) {
111 applyAppearanceFontVariables(root, {
112 sans: settings.fontFamilySans,
113 code: settings.fontFamilyCode,
114 composer: settings.fontFamilyComposer,
115 sizeInterface: settings.fontSizeInterface,
116 sizePrompt: settings.fontSizePrompt,
117 sizeCode: settings.fontSizeCode,
118 smoothing: settings.fontSmoothing,
119 separate: separateFonts,
120 });
121 }
122 };
123 subscribeClientSettings(apply);
124 subscribeLocalStorageKey(TYPOGRAPHY_ADVANCED_STORAGE_KEY, apply);
125 apply();
127​
128function readOpenThreadRef(router: AppRouter): ScopedThreadRef | null {
129 const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {});
130 const draft =
131 target?.kind === "draft"
132 ? useComposerDraftStore.getState().getDraftSession(target.draftId)
133 : null;
134 return resolveActiveThreadRouteRef(target, draft);
136​
137/**
138 * Names the open thread and its project in the window title, or the app when none is open.
139 * Screen readers read it on Alt+Tab, so it can lead with the thread and what it's doing.
140 */
141function startDocumentTitle(router: AppRouter): void {
142 const readOnGuestPage = () => router.state.matches.some((match) => match.routeId === "/guest");
143 let openThreadRef = readOpenThreadRef(router);
144 let onGuestPage = readOnGuestPage();
145 const write = () => {
146 // GuestPage titles the tab after the shared thread.
147 if (onGuestPage) return;
148 const thread =
149 openThreadRef === null
150 ? null
151 : appAtomRegistry.get(environmentThreadShells.quietThreadShellAtom(openThreadRef));
152 const project =
153 thread === null
154 ? null
155 : appAtomRegistry.get(
156 environmentProjects.projectAtom(
157 scopeProjectRef(thread.environmentId, thread.projectId),
158 ),
159 );
160 const title = resolveWindowTitle({
161 appDisplayName: resolveServerBackedAppDisplayName({
162 baseName: APP_BASE_NAME,
163 fallbackDisplayName: APP_DISPLAY_NAME,
164 fallbackStageLabel: APP_STAGE_LABEL,
165 primaryServerVersion:
166 appAtomRegistry.get(primaryServerConfigAtom)?.environment.serverVersion ?? null,
167 }),
168 projectTitle: project?.title ?? null,
169 threadTitle: thread?.title ?? null,
170 ...(getClientSettings().screenReaderWindowTitle && {
171 screenReader: {
172 threadStatus: thread === null ? null : resolveWindowTitleThreadStatus(thread),
173 },
174 }),
175 desktop: isElectron,
176 });
177 // Projects change whenever any project does; most changes leave the title alone.
178 if (document.title !== title) document.title = title;
179 };
180 // The open thread's own shell, since the shell lists skip threads hidden by a tag filter.
181 let unsubscribeOpenThread = () => {};
182 const watchOpenThread = () => {
183 unsubscribeOpenThread();
184 unsubscribeOpenThread =
185 openThreadRef === null
186 ? () => {}
187 : appAtomRegistry.subscribe(
188 environmentThreadShells.quietThreadShellAtom(openThreadRef),
189 write,
190 );
191 };
192 // The draft store changes on every keystroke; only a new open thread matters here.
193 const updateOpenThread = () => {
194 const next = readOpenThreadRef(router);
195 const nextOnGuestPage = readOnGuestPage();
196 if (
197 nextOnGuestPage === onGuestPage &&
198 (next && scopedThreadKey(next)) === (openThreadRef && scopedThreadKey(openThreadRef))
199 ) {
200 return;
201 }
202 openThreadRef = next;
203 onGuestPage = nextOnGuestPage;
204 watchOpenThread();
205 write();
206 };
207 watchOpenThread();
208 appAtomRegistry.subscribe(primaryServerConfigAtom, write, { immediate: true });
209 appAtomRegistry.subscribe(environmentProjects.projectsAtom, write);
210 subscribeClientSettings(write);
211 router.subscribe("onResolved", updateOpenThread);
212 useComposerDraftStore.subscribe(updateOpenThread);
214​
215/**
216 * Each route paints its own surface, which the browser chrome samples once it is on screen.
217 * onRendered only fires for a new href; onResolved also covers `router.invalidate()`, which
218 * can swap the root between the bare outlet and the shell at the same address.
219 */
220function startBrowserChromeThemeSync(router: AppRouter): void {
221 const resync = () => window.requestAnimationFrame(syncBrowserChromeTheme);
222 router.subscribe("onRendered", resync);
223 router.subscribe("onResolved", resync);
225​
226/** Chat overlays belong to the thread they were opened in, so leaving its route drops them. */
227function startThreadOverlayRetention(router: AppRouter): void {
228 router.subscribe("onResolved", () => {
229 const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {});
230 const draftSession =
231 target?.kind === "draft"
232 ? useComposerDraftStore.getState().getDraftSession(target.draftId)
233 : null;
234 const threadRef =
235 target?.kind === "server"
236 ? target.threadRef
237 : draftSession
238 ? scopeThreadRef(draftSession.environmentId, draftSession.threadId)
239 : null;
240 retainThreadOverlaysForThread(threadRef ? scopedThreadKey(threadRef) : null);
241 });
243​
244/**
245 * Starts every app-lifetime service once, before the first render, and returns the router.
246 * They run for the life of the page, so their disposers are dropped here.
247 */
248export function startAppServices(): AppRouter {
249 // Ahead of the history: the provider auth code must leave the address before anything reads it.
250 startProviderAuthDelivery();
251​
252 // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
253 const router = getRouter(isElectron ? createHashHistory() : createBrowserHistory());
254​
255 if (isElectron) {
256 syncDocumentElectronPlatformClasses(navigator.platform);
257 syncDocumentWindowControlsOverlayClass();
258 }
259​
260 installDesktopPasteAsText(window.desktopBridge, window);
261​
262 startClientAppearance();
263 startI18n();
264 startDocumentTitle(router);
265 startHostEnvironmentLookup();
266 startEnvironmentThemeRepaint();
267 startBrowserChromeThemeSync(router);
268 startDefaultThemeAdoption();
269 startModelPreferencesMigration();
270 startAgentOpenedPreviews();
271 if (isElectron) startElectronBrowserHost();
272 startPreviewTitleRecording();
273 startBrowserProfileReporting();
274 installChatRouteCommands(router);
275 installCommandPaletteCommands(router);
276 registerOpenFavoriteEditorCommand();
277 startThreadOverlayRetention(router);
278 // Drafts promote as their server threads arrive, wherever the user is.
279 subscribeDraftThreadPromotion({
280 registry: appAtomRegistry,
281 threadShellAtom: environmentThreadShells.quietThreadShellAtom,
282 threadRefsAtom: environmentThreadShells.threadRefsAtom,
283 });
284 subscribeComposerDraftModelReconciliation({
285 registry: appAtomRegistry,
286 threadShellAtom: environmentThreadShells.threadShellAtom,
287 threadRefsAtom: environmentThreadShells.threadRefsAtom,
288 shellStateAtom: environmentShell.stateAtom,
289 pendingThreadIdsAtom: threadEnvironment.pendingThreadIdsAtom,
290 });
291 startMainAppLocationTracking(router);
292 startWorkspaceFocus(router);
293 startBackgroundThreadAnnouncements(appAtomRegistry, router);
294 startThreadVisitedMigration(appAtomRegistry);
295 startSshPasswordPromptQueue();
296​
297 startKeybindingsUpdateToasts();
298 startSlowRpcRequestToasts();
299 startHostFreezeToasts();
300 startLegacyThreadMigrationToast();
301 startPermissionUpdateNotices(router);
302 startThreadNotifications(appAtomRegistry, router);
303 startGuideRunNotifications(appAtomRegistry, router);
304 if (isElectron) startDesktopUpdateLaunchNotice();
305​
306 return router;