authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 17:29:09-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 17:29:09-07:00
log38571328a4bc3c30c56fc0cae299437fe9b53898
treeb43e427da4d79c22a62e9be28033e3bc2594b36c
parent140ee145ac33653ac2c36f7940e4f54f6e92497f
signature Signed by PGP key B5690EEEBB952194

feat(desktop): T3 Code can be your default web browser on macOS (#17587)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

21 files changed, 584 insertions(+), 13 deletions(-)

apps/desktop/src/app/DesktopClerk.test.ts+59-1
......@@ -35,9 +35,16 @@ import * as ElectronApp from "../electron/ElectronApp.ts";
3535import * as ElectronShell from "../electron/ElectronShell.ts";
3636import * as ElectronWindow from "../electron/ElectronWindow.ts";
3737import * as DesktopClerk from "./DesktopClerk.ts";
38import * as DesktopWebLinks from "./DesktopWebLinks.ts";
3839import * as DesktopEnvironment from "./DesktopEnvironment.ts";
3940import * as DesktopPreReadyFileSystem from "./DesktopPreReadyFileSystem.ts";
4041
42/** Clerk forwards web links; tests that are not about links ignore them. */
43const ignoreWebLinks = DesktopWebLinks.DesktopWebLinks.of({
44 receive: () => Effect.void,
45 setRendererReady: () => Effect.void,
46});
47
4148const layerDesktopClerk = (
4249 isDevelopment = true,
4350 events: string[] = [],
......@@ -220,11 +227,12 @@ describe("DesktopClerk", () => {
220227
221228 assert.isTrue(Exit.isSuccess(exit));
222229 assert.equal(quit.mock.calls.length, 0);
223 assert.deepEqual(registeredEvents, ["open-url", "second-instance"]);
230 assert.deepEqual(registeredEvents, ["open-url", "open-file", "second-instance"]);
224231 }).pipe(
225232 Effect.provide(layerDesktopClerk()),
226233 Effect.provideService(ElectronApp.ElectronApp, electronApp),
227234 Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),
235 Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks),
228236 );
229237 });
230238
......@@ -253,6 +261,7 @@ describe("DesktopClerk", () => {
253261 Effect.provide(layerDesktopClerk()),
254262 Effect.provideService(ElectronApp.ElectronApp, electronApp),
255263 Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),
264 Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks),
256265 );
257266 });
258267});
......@@ -299,6 +308,7 @@ it.effect(
299308 Effect.provide(layerDesktopClerk()),
300309 Effect.provideService(ElectronApp.ElectronApp, electronApp),
301310 Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),
311 Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks),
302312 );
303313 },
304314);
......@@ -381,6 +391,54 @@ it.effect.each(["startup", "open-url"] as const)(
381391 ElectronWindow.ElectronWindow,
382392 {} as ElectronWindow.ElectronWindow["Service"],
383393 ),
394 Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks),
384395 );
385396 }).pipe(Effect.scoped),
386397);
398
399it.effect("hands a web link to the renderer and leaves other links alone", () =>
400 Effect.gen(function* () {
401 storageMock.mockReturnValue(storageAdapter);
402 createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true });
403 const listeners = new Map<string, (...args: unknown[]) => void>();
404 const electronApp = {
405 whenReady: Effect.void,
406 on: (name: string, listener: (...args: unknown[]) => void) =>
407 Effect.sync(() => {
408 listeners.set(name, listener);
409 }),
410 } as unknown as ElectronApp.ElectronApp["Service"];
411 const received: Array<string> = [];
412 yield* Effect.gen(function* () {
413 const clerk = yield* DesktopClerk.DesktopClerk;
414 yield* clerk.configure;
415 const open = (url: string) => {
416 const event = { preventDefault: vi.fn() };
417 listeners.get("open-url")!(event, url);
418 return event.preventDefault.mock.calls.length;
419 };
420 // macOS hands the default browser every web link.
421 assert.strictEqual(open("https://example.com/page"), 1);
422 assert.strictEqual(open("http://localhost:3000/"), 1);
423 // Anything else is not a web page; Electron keeps its own handling.
424 assert.strictEqual(open("mailto:hello@example.com"), 0);
425 yield* Effect.yieldNow;
426 assert.deepStrictEqual(received, ["https://example.com/page", "http://localhost:3000/"]);
427 }).pipe(
428 Effect.provide(layerDesktopClerk(true, [], "darwin")),
429 Effect.provideService(HostProcess.Arguments, ["t3"]),
430 Effect.provideService(ElectronApp.ElectronApp, electronApp),
431 Effect.provideService(
432 ElectronWindow.ElectronWindow,
433 {} as ElectronWindow.ElectronWindow["Service"],
434 ),
435 Effect.provideService(
436 DesktopWebLinks.DesktopWebLinks,
437 DesktopWebLinks.DesktopWebLinks.of({
438 receive: (url) => Effect.sync(() => void received.push(url)),
439 setRendererReady: () => Effect.void,
440 }),
441 ),
442 );
443 }).pipe(Effect.scoped),
444);
apps/desktop/src/app/DesktopClerk.ts+21-2
......@@ -1,4 +1,5 @@
11import { createClerkBridge } from "@clerk/electron";
2import * as NodeURL from "node:url";
23import { storage } from "@clerk/electron/storage";
34import * as Context from "effect/Context";
45import * as Effect from "effect/Effect";
......@@ -18,6 +19,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
1819import * as ElectronWindow from "../electron/ElectronWindow.ts";
1920import * as DesktopUserData from "./DesktopUserData.ts";
2021import * as DesktopEnvironment from "./DesktopEnvironment.ts";
22import * as DesktopWebLinks from "./DesktopWebLinks.ts";
2123
2224declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
2325
......@@ -53,7 +55,10 @@ export class DesktopClerk extends Context.Service<
5355 readonly configure: Effect.Effect<
5456 void,
5557 never,
56 ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope
58 | ElectronApp.ElectronApp
59 | ElectronWindow.ElectronWindow
60 | DesktopWebLinks.DesktopWebLinks
61 | Scope.Scope
5762 >;
5863 }
5964>()("@t3tools/desktop/app/DesktopClerk") {}
......@@ -125,6 +130,7 @@ export const make = Effect.gen(function* () {
125130 configure: Effect.gen(function* () {
126131 const electronApp = yield* ElectronApp.ElectronApp;
127132 const electronWindow = yield* ElectronWindow.ElectronWindow;
133 const webLinks = yield* DesktopWebLinks.DesktopWebLinks;
128134 const context = yield* Effect.context<ElectronWindow.ElectronWindow>();
129135 const runPromise = Effect.runPromiseWith(context);
130136
......@@ -184,8 +190,21 @@ export const make = Effect.gen(function* () {
184190 };
185191 const args = yield* HostProcess.Arguments;
186192 args.some((value) => startProviderAuthHandoff(value));
193 // As the default browser, macOS hands T3 Code every web link through the same event.
194 const openWebLink = (url: string) => {
195 if (!DesktopWebLinks.isWebLink(url)) return false;
196 void runPromise(webLinks.receive(url));
197 return true;
198 };
187199 yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => {
188 if (startProviderAuthHandoff(url) || resumeProviderAuth(url)) event.preventDefault();
200 if (startProviderAuthHandoff(url) || resumeProviderAuth(url) || openWebLink(url))
201 event.preventDefault();
202 });
203 // A browser opens HTML files too, which macOS hands over by path.
204 yield* electronApp.on("open-file", (event: { preventDefault: () => void }, path: string) => {
205 if (!DesktopWebLinks.isWebPageFile(path)) return;
206 event.preventDefault();
207 void runPromise(webLinks.receive(NodeURL.pathToFileURL(path).href));
189208 });
190209 yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => {
191210 if (argv?.some((value) => startProviderAuthHandoff(value) || resumeProviderAuth(value)))
apps/desktop/src/app/DesktopWebLinks.test.ts created+93
......@@ -0,0 +1,93 @@
1// @effect-diagnostics nodeBuiltinImport:off - Stands in for an Electron webContents.
2import { assert, describe, it } from "@effect/vitest";
3import * as NodeEvents from "node:events";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7
8import * as ElectronWindow from "../electron/ElectronWindow.ts";
9import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts";
10import * as DesktopWebLinks from "./DesktopWebLinks.ts";
11
12const makeWindow = () => {
13 const sent: Array<string> = [];
14 const revealed: Array<unknown> = [];
15 const webContents = Object.assign(new NodeEvents.EventEmitter(), {
16 isDestroyed: () => false,
17 send: (channel: string, url: string) => {
18 if (channel === WEB_LINK_OPEN_CHANNEL) sent.push(url);
19 },
20 });
21 const window = { webContents } as unknown as Electron.BrowserWindow;
22 /** The web app starts loading a new page, as a reload does. */
23 const reload = () =>
24 webContents.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false });
25 const layer = DesktopWebLinks.layer.pipe(
26 Layer.provide(
27 Layer.succeed(
28 ElectronWindow.ElectronWindow,
29 ElectronWindow.ElectronWindow.of({
30 currentMainOrFirst: Effect.succeed(Option.some(window)),
31 reveal: (target: Electron.BrowserWindow) => Effect.sync(() => void revealed.push(target)),
32 } as unknown as ElectronWindow.ElectronWindow["Service"]),
33 ),
34 ),
35 );
36 return { sent, revealed, layer, reload };
37};
38
39describe("DesktopWebLinks", () => {
40 it("treats only http and https as web links", () => {
41 assert.isTrue(DesktopWebLinks.isWebLink("https://example.com/"));
42 assert.isTrue(DesktopWebLinks.isWebLink("http://localhost:3000/a?b=1"));
43 assert.isFalse(DesktopWebLinks.isWebLink("t3code://app/welcome"));
44 assert.isFalse(DesktopWebLinks.isWebLink("mailto:hello@example.com"));
45 assert.isFalse(DesktopWebLinks.isWebLink("not a url"));
46 });
47
48 it("opens HTML files the way a browser does, and nothing else by path", () => {
49 assert.isTrue(DesktopWebLinks.isWebPageFile("/Users/me/report.html"));
50 assert.isTrue(DesktopWebLinks.isWebPageFile("/tmp/page.HTM"));
51 assert.isTrue(DesktopWebLinks.isWebPageFile("/tmp/page.xhtml"));
52 assert.isFalse(DesktopWebLinks.isWebPageFile("/tmp/notes.md"));
53 assert.isFalse(DesktopWebLinks.isWebPageFile("/tmp/html"));
54 });
55
56 it.effect("holds links until the renderer listens, then delivers them in order", () => {
57 const { sent, revealed, layer } = makeWindow();
58 return Effect.gen(function* () {
59 const webLinks = yield* DesktopWebLinks.DesktopWebLinks;
60 // The link that launched the app arrives before the web app has loaded.
61 yield* webLinks.receive("https://example.com/first");
62 yield* webLinks.receive("https://example.com/second");
63 assert.deepStrictEqual(sent, []);
64
65 yield* webLinks.setRendererReady(true);
66 assert.deepStrictEqual(sent, ["https://example.com/first", "https://example.com/second"]);
67 assert.strictEqual(revealed.length, 1);
68
69 yield* webLinks.receive("https://example.com/third");
70 assert.deepStrictEqual(sent.at(-1), "https://example.com/third");
71
72 // A reloading web app stops listening; links wait for it again.
73 yield* webLinks.setRendererReady(false);
74 yield* webLinks.receive("https://example.com/fourth");
75 assert.strictEqual(sent.length, 3);
76 }).pipe(Effect.provide(layer));
77 });
78
79 it.effect("holds links that arrive while the web app reloads until it listens again", () => {
80 const { sent, layer, reload } = makeWindow();
81 return Effect.gen(function* () {
82 const webLinks = yield* DesktopWebLinks.DesktopWebLinks;
83 yield* webLinks.setRendererReady(true);
84 // The old page never says it stopped; the reload itself ends its listening.
85 reload();
86 yield* webLinks.receive("https://example.com/during-reload");
87 assert.deepStrictEqual(sent, []);
88
89 yield* webLinks.setRendererReady(true);
90 assert.deepStrictEqual(sent, ["https://example.com/during-reload"]);
91 }).pipe(Effect.provide(layer));
92 });
93});
apps/desktop/src/app/DesktopWebLinks.ts created+97
......@@ -0,0 +1,97 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5
6import * as ElectronWindow from "../electron/ElectronWindow.ts";
7import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts";
8
9/** An http(s) link the operating system handed to T3 Code as the default browser. */
10export const isWebLink = (value: string) => {
11 if (!URL.canParse(value)) return false;
12 const { protocol } = new URL(value);
13 return protocol === "http:" || protocol === "https:";
14};
15
16/** An HTML file the operating system asked T3 Code to open, as browsers are asked to. */
17export const isWebPageFile = (path: string) => /\.x?html?$/i.test(path);
18
19/**
20 * Web links and HTML files macOS opens with T3 Code once it is the default
21 * browser. Files arrive as file:// URLs. They can
22 * arrive before the app is ready (the link that launched it) or while the web
23 * app reloads, so they wait until the renderer says it is listening.
24 */
25export class DesktopWebLinks extends Context.Service<
26 DesktopWebLinks,
27 {
28 /** Queues a link and delivers it as soon as the renderer is listening. */
29 readonly receive: (url: string) => Effect.Effect<void>;
30 /** The renderer started or stopped listening; listening flushes queued links. */
31 readonly setRendererReady: (ready: boolean) => Effect.Effect<void>;
32 }
33>()("@t3tools/desktop/app/DesktopWebLinks") {}
34
35const make = Effect.gen(function* () {
36 const electronWindow = yield* ElectronWindow.ElectronWindow;
37 const pending: Array<string> = [];
38 /** The page that said it is listening; a reload or crash replaces it, and it must say so again. */
39 let listening: Electron.WebContents | null = null;
40 let detachListening: (() => void) | null = null;
41
42 const stopListening = () => {
43 detachListening?.();
44 detachListening = null;
45 listening = null;
46 };
47
48 const flush = Effect.gen(function* () {
49 if (listening === null || pending.length === 0) return;
50 const window = yield* electronWindow.currentMainOrFirst;
51 if (Option.isNone(window) || window.value.webContents !== listening) return;
52 if (listening.isDestroyed()) {
53 stopListening();
54 return;
55 }
56 for (const url of pending.splice(0)) listening.send(WEB_LINK_OPEN_CHANNEL, url);
57 yield* electronWindow.reveal(window.value);
58 });
59
60 const startListening = Effect.gen(function* () {
61 const window = yield* electronWindow.currentMainOrFirst;
62 if (Option.isNone(window) || window.value.webContents.isDestroyed()) return;
63 const webContents = window.value.webContents;
64 if (listening === webContents) return;
65 stopListening();
66 listening = webContents;
67 // Links that arrive while a new page loads wait for it to listen again.
68 const onNavigation = (
69 event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
70 ) => {
71 if (event.isMainFrame && !event.isSameDocument) stopListening();
72 };
73 webContents.on("did-start-navigation", onNavigation);
74 webContents.on("render-process-gone", stopListening);
75 webContents.once("destroyed", stopListening);
76 detachListening = () => {
77 webContents.removeListener("did-start-navigation", onNavigation);
78 webContents.removeListener("render-process-gone", stopListening);
79 webContents.removeListener("destroyed", stopListening);
80 };
81 });
82
83 return DesktopWebLinks.of({
84 receive: (url) =>
85 Effect.suspend(() => {
86 pending.push(url);
87 return flush;
88 }).pipe(Effect.withSpan("DesktopWebLinks.receive")),
89 setRendererReady: (ready) =>
90 (ready ? startListening : Effect.sync(stopListening)).pipe(
91 Effect.andThen(flush),
92 Effect.withSpan("DesktopWebLinks.setRendererReady"),
93 ),
94 });
95});
96
97export const layer = Layer.effect(DesktopWebLinks, make);
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
......@@ -70,6 +70,7 @@ import {
7070} from "./methods/snapShot.ts";
7171import * as PreviewIpc from "./methods/preview.ts";
7272import * as AppActivationIpc from "./methods/appActivation.ts";
73import * as WebLinksIpc from "./methods/webLinks.ts";
7374import {
7475 completeLegacyLocalStorage,
7576 takeLegacyLocalStorage,
......@@ -88,6 +89,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
8889
8990 yield* ipc.handle(AppActivationIpc.setReady);
9091 yield* ipc.handle(AppActivationIpc.complete);
92 yield* ipc.handle(WebLinksIpc.setReady);
9193
9294 yield* ipc.handleSync(takeLegacyLocalStorage);
9395 yield* ipc.handle(completeLegacyLocalStorage);
apps/desktop/src/ipc/channels.ts+2
......@@ -10,6 +10,8 @@ export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
1010export const MENU_ACTION_CHANNEL = "desktop:menu-action";
1111export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text";
1212export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event";
13export const WEB_LINK_OPEN_CHANNEL = "desktop:web-link-open";
14export const WEB_LINK_READY_CHANNEL = "desktop:web-link-ready";
1315export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
1416export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end";
1517export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
apps/desktop/src/ipc/methods/webLinks.ts created+16
......@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3
4import * as DesktopWebLinks from "../../app/DesktopWebLinks.ts";
5import * as IpcChannels from "../channels.ts";
6import * as DesktopIpc from "../DesktopIpc.ts";
7
8export const setReady = DesktopIpc.makeIpcMethod({
9 channel: IpcChannels.WEB_LINK_READY_CHANNEL,
10 payload: Schema.Boolean,
11 result: Schema.Void,
12 handler: Effect.fn("desktop.ipc.webLinks.setReady")(function* (ready) {
13 const webLinks = yield* DesktopWebLinks.DesktopWebLinks;
14 yield* webLinks.setRendererReady(ready);
15 }),
16});
apps/desktop/src/main.ts+2
......@@ -35,6 +35,7 @@ import * as ElectronUpdater from "./electron/ElectronUpdater.ts";
3535import * as ElectronWindow from "./electron/ElectronWindow.ts";
3636import * as DesktopApp from "./app/DesktopApp.ts";
3737import * as DesktopAppActivation from "./app/DesktopAppActivation.ts";
38import * as DesktopWebLinks from "./app/DesktopWebLinks.ts";
3839import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
3940import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";
4041import * as DesktopClerk from "./app/DesktopClerk.ts";
......@@ -214,6 +215,7 @@ const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe(
214215const layerDesktopApplication = Layer.mergeAll(
215216 DesktopLifecycle.layer,
216217 layerDesktopAppActivation,
218 DesktopWebLinks.layer,
217219 DesktopApplicationMenu.layer,
218220 DesktopLinuxUrlHandler.layer,
219221 DesktopCliCommand.layer,
apps/desktop/src/preload.ts+12
......@@ -285,6 +285,18 @@ contextBridge.exposeInMainWorld("desktopBridge", {
285285 ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener);
286286 };
287287 },
288 webLinks: {
289 setReady: (ready) => ipcRenderer.invoke(IpcChannels.WEB_LINK_READY_CHANNEL, ready),
290 onOpen: (listener) => {
291 const wrappedListener = (_event: Electron.IpcRendererEvent, url: unknown) => {
292 if (typeof url === "string") listener(url);
293 };
294 ipcRenderer.on(IpcChannels.WEB_LINK_OPEN_CHANNEL, wrappedListener);
295 return () => {
296 ipcRenderer.removeListener(IpcChannels.WEB_LINK_OPEN_CHANNEL, wrappedListener);
297 };
298 },
299 },
288300 appActivation: {
289301 setReady: (ready) =>
290302 ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, ready),
apps/web/src/components/ChatView.tsx+7
......@@ -6056,6 +6056,13 @@ export default function ChatView(props: ChatViewProps) {
60566056 if (!activeThreadRef) return;
60576057 useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation);
60586058 }, [activeThreadRef, threadPanelPresentation]);
6059 // A thread started for a link the OS opened shows its browser maximized, like a browser window.
6060 useEffect(() => {
6061 if (!canMaximizeRightPanel) return;
6062 if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) {
6063 setMaximizedRightPanelThreadKey(routeThreadKey);
6064 }
6065 }, [canMaximizeRightPanel, routeThreadKey, routeThreadRef]);
60596066 const toggleRightPanelMaximized = useCallback(() => {
60606067 if (!canMaximizeRightPanel) return;
60616068 setMaximizedRightPanelThreadKey((threadKey) =>
apps/web/src/components/chat/DraftHeroHeadline.tsx+12-4
......@@ -1,5 +1,5 @@
11import type { DraftId } from "~/composerDraftStore";
2import { useComposerDraftStore } from "~/composerDraftStore";
2import { composerDraftHasUserContent, useComposerDraftStore } from "~/composerDraftStore";
33import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";
44import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
55import { isScratchProject } from "@t3tools/client-runtime/state/projects";
......@@ -11,6 +11,7 @@ import { openCommandPalette } from "~/commandPaletteBus";
1111import { shortcutLabelForCommand } from "~/keybindings";
1212import { projectIconColorClassName } from "~/projectIconColors";
1313import { primaryServerKeybindingsAtom } from "~/state/server";
14import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
1415import { useScratchProject } from "~/hooks/useScratchProject";
1516import { useClientSettings } from "~/hooks/useSettings";
1617import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions";
......@@ -69,6 +70,7 @@ export function DraftHeroHeadline({
6970 const setModelSelection = useComposerDraftStore((store) => store.setModelSelection);
7071 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
7172 const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject();
73 const openProjectDraft = useNewThreadHandler();
7274 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
7375
7476 const environmentLabelById = useMemo(
......@@ -172,9 +174,11 @@ export function DraftHeroHeadline({
172174 useEffect(() => {
173175 latestTargetRef.current = { draftId, activeProjectKey, scratchTargetEnvironmentId };
174176 }, [activeProjectKey, scratchTargetEnvironmentId, draftId]);
175 // Project selection changes the target of the open draft in place. The
176 // prompt stays in the same composer session, so the sidebar only gets a
177 // draft row if the user later navigates away.
177 // With a prompt typed, project selection changes the target of the open
178 // draft in place, so the prompt stays in the same composer session. An empty
179 // draft instead opens the chosen project's own draft, like starting a new
180 // thread there: moving it would replace that draft and strand whatever it
181 // holds, such as the browser tabs of the no-project draft.
178182 const selectProject = (project: (typeof projects)[number], logicalProjectKey: string) => {
179183 if (!draftId) {
180184 return;
......@@ -185,6 +189,10 @@ export function DraftHeroHeadline({
185189 scratchTargetEnvironmentId: project.environmentId,
186190 };
187191 const currentDraft = getComposerDraft(draftId);
192 if (!composerDraftHasUserContent(currentDraft)) {
193 void openProjectDraft(scopeProjectRef(project.environmentId, project.id));
194 return;
195 }
188196 setLogicalProjectDraftThreadId(
189197 logicalProjectKey,
190198 scopeProjectRef(project.environmentId, project.id),
apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx created+146
......@@ -0,0 +1,146 @@
1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
3import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
4import { useRouter } from "@tanstack/react-router";
5import { useEffect, useEffectEvent, useRef } from "react";
6
7import { openFileInPreview, openUrlInPreview } from "../../browser/openFileInPreview";
8import { isPreviewAvailableFor } from "../../browser/previewRuntime";
9import { useComposerDraftStore } from "../../composerDraftStore";
10import { useNewThreadHandler } from "../../hooks/useHandleNewThread";
11import { useScratchProject } from "../../hooks/useScratchProject";
12import { selectActiveRightPanelSurface, useRightPanelStore } from "../../rightPanelStore";
13import { resolveThreadRouteTarget } from "../../threadRoutes";
14import { assetEnvironment } from "../../state/assets";
15import { useEnvironmentHttpBaseUrl, usePrimaryEnvironment } from "../../state/environments";
16import { previewEnvironment } from "../../state/preview";
17import { useAtomCommand } from "../../state/use-atom-command";
18import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
19import { stackedThreadToast, toastManager } from "../ui/toast";
20
21/**
22 * The thread on screen when its browser panel is showing, so a link opens as
23 * another tab there, the way a browser window takes new links. HTML files are
24 * served by this machine's environment, so only its threads can take them.
25 */
26function threadShowingBrowser(
27 routeParams: Record<string, string | undefined>,
28 fileEnvironmentId: EnvironmentId | null,
29): ScopedThreadRef | null {
30 const target = resolveThreadRouteTarget(routeParams);
31 if (target === null) return null;
32 let threadRef = target.kind === "server" ? target.threadRef : null;
33 if (target.kind === "draft") {
34 const session = useComposerDraftStore.getState().getDraftSession(target.draftId);
35 if (!session || session.promotedTo) return null;
36 threadRef = scopeThreadRef(session.environmentId, session.threadId);
37 }
38 if (threadRef === null || !isPreviewAvailableFor(threadRef.environmentId)) return null;
39 if (fileEnvironmentId !== null && threadRef.environmentId !== fileEnvironmentId) return null;
40 const surface = selectActiveRightPanelSurface(
41 useRightPanelStore.getState().byThreadKey,
42 threadRef,
43 );
44 return surface?.kind === "preview" ? threadRef : null;
45}
46
47/**
48 * Opens each web link or HTML file the OS hands T3 Code as the default browser
49 * (macOS). With a browser panel on screen the page opens as a new tab there;
50 * otherwise it starts a thread without a project, its browser panel maximized.
51 */
52export function DesktopWebLinkCoordinator() {
53 const router = useRouter();
54 const primaryEnvironment = usePrimaryEnvironment();
55 const { scratchEnvironmentId, openScratchProject } = useScratchProject();
56 const openThread = useNewThreadHandler();
57 const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false });
58 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
59 reportFailure: false,
60 refresh: true,
61 });
62 const httpBaseUrl = useEnvironmentHttpBaseUrl(primaryEnvironment?.environmentId ?? null);
63 const queueRef = useRef(Promise.resolve());
64 const webLinks = window.desktopBridge?.webLinks;
65 const ready =
66 webLinks !== undefined &&
67 primaryEnvironment?.connection.phase === "connected" &&
68 primaryEnvironment.serverConfig !== null;
69
70 const reportFailure = (url: string, description = url) =>
71 toastManager.add(
72 stackedThreadToast({ type: "error", title: "Could not open the link", description }),
73 );
74
75 // The OS hands each link over once, so a thread that could not start says so.
76 const startThread = async (url: string): Promise<ScopedThreadRef | null> => {
77 const environmentId = scratchEnvironmentId(primaryEnvironment?.environmentId ?? null);
78 if (environmentId === null) {
79 reportFailure(url);
80 return null;
81 }
82 // Reports its own failure.
83 const project = await openScratchProject(environmentId, "Could not open the link");
84 if (!project) return null;
85 const opened = await openThread(scopeProjectRef(project.environmentId, project.id));
86 if (!opened) {
87 reportFailure(url);
88 return null;
89 }
90 const threadRef = scopeThreadRef(project.environmentId, opened.threadId);
91 useRightPanelStore.getState().requestMaximize(threadRef);
92 return threadRef;
93 };
94
95 const openLink = useEffectEvent(async (url: string) => {
96 const isFile = url.startsWith("file:");
97 const routeParams = router.state.matches.at(-1)?.params ?? {};
98 const threadRef =
99 threadShowingBrowser(
100 routeParams,
101 isFile ? (primaryEnvironment?.environmentId ?? null) : null,
102 ) ?? (await startThread(url));
103 if (threadRef === null) return;
104 // An HTML file is served from the environment, which runs on this machine.
105 const result = isFile
106 ? httpBaseUrl === null
107 ? null
108 : await openFileInPreview({
109 threadRef,
110 filePath: decodeURIComponent(new URL(url).pathname),
111 workspaceRoot: undefined,
112 httpBaseUrl,
113 createAssetUrl,
114 openPreview,
115 })
116 : await openUrlInPreview({ threadRef, url, openPreview });
117 if (result === null) {
118 reportFailure(url);
119 return;
120 }
121 if (result._tag === "Failure") {
122 const error = squashAtomCommandFailure(result);
123 reportFailure(url, error instanceof Error ? error.message : url);
124 }
125 });
126
127 useEffect(() => {
128 if (!ready || webLinks === undefined) return;
129 let subscribed = true;
130 // Links open one at a time, in the order they came.
131 const unsubscribe = webLinks.onOpen((url) => {
132 queueRef.current = queueRef.current.then(() => openLink(url)).catch(() => undefined);
133 });
134 // Skip readiness if React runs cleanup before this subscription can receive links.
135 queueMicrotask(() => {
136 if (subscribed) void webLinks.setReady(true).catch(() => undefined);
137 });
138 return () => {
139 subscribed = false;
140 void webLinks.setReady(false).catch(() => undefined);
141 unsubscribe();
142 };
143 }, [ready, webLinks]);
144
145 return null;
146}
apps/web/src/composerDraftStore.test.ts+25
......@@ -89,6 +89,7 @@ import {
8989 type TerminalContextDraft,
9090} from "./lib/terminalContext";
9191import { createDeferredStorage } from "./lib/storage";
92import { applyPreviewServerSnapshot, resetPreviewStateForTests } from "./previewStateStore";
9293
9394function makeImage(input: {
9495 id: string;
......@@ -1467,6 +1468,30 @@ describe("composerDraftStore project draft thread mapping", () => {
14671468 expect(draftByKey(draftId)).toBeUndefined();
14681469 });
14691470
1471 it("keeps a draft with an open page when remapping a project to a new draft thread", () => {
1472 const store = useComposerDraftStore.getState();
1473 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1474 // A link another app opened: the composer is empty, but the page is not.
1475 applyPreviewServerSnapshot(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId), {
1476 threadId,
1477 tabId: "tab-link",
1478 navStatus: { _tag: "Success", url: "https://example.com/", title: "Example" },
1479 canGoBack: false,
1480 canGoForward: false,
1481 updatedAt: "2026-10-09T00:00:00.000Z",
1482 });
1483 try {
1484 store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId });
1485
1486 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.threadId).toBe(threadId);
1487 // It survives a reload too, so the page is still there when the app comes back.
1488 const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState());
1489 expect(persisted.draftThreadsByThreadKey[draftId]?.threadId).toBe(threadId);
1490 } finally {
1491 resetPreviewStateForTests();
1492 }
1493 });
1494
14701495 it("keeps invested composer drafts alive unmapped when remapping a project to a new draft thread", () => {
14711496 const store = useComposerDraftStore.getState();
14721497 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
apps/web/src/composerDraftStore.ts+19-6
......@@ -40,6 +40,7 @@ import { DeepMutable } from "effect/Types";
4040import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
4141import { useMemo } from "react";
4242import { getLocalStorageItem } from "./hooks/useLocalStorage";
43import { readThreadPreviewState } from "./previewStateStore";
4344import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection";
4445import {
4546 DEFAULT_INTERACTION_MODE,
......@@ -1556,6 +1557,16 @@ function getComposerDraftState(
15561557 return state.draftsByThreadKey[threadKey] ?? null;
15571558}
15581559
1560/**
1561 * A draft holding an open page, such as one started for a link another app
1562 * opened, is work in progress even with an empty composer.
1563 */
1564function draftThreadHasOpenPreview(draftThread: DraftThreadState | undefined): boolean {
1565 if (draftThread === undefined) return false;
1566 const ref = scopeThreadRef(draftThread.environmentId, draftThread.threadId);
1567 return Object.keys(readThreadPreviewState(ref).sessions).length > 0;
1568}
1569
15591570function isComposerThreadKeyInUse(mappings: Record<string, string>, threadKey: string): boolean {
15601571 return Object.values(mappings).includes(threadKey);
15611572}
......@@ -2174,10 +2185,10 @@ export function partializeComposerDraftStoreState(
21742185 state: ComposerDraftStoreState,
21752186): PersistedComposerDraftStoreState {
21762187 // Draft sessions worth persisting: mapped (a new-thread flow targets
2177 // them), promoting (mid-send), or holding real user content (they back a
2178 // sidebar row). Everything else is a zombie — and its composer blob must
2179 // be dropped WITH it, or model/mode-only entries would persist forever
2180 // keyed to a session that no longer exists.
2188 // them), promoting (mid-send), holding real user content (they back a
2189 // sidebar row), or holding an open page. Everything else is a zombie — and
2190 // its composer blob must be dropped WITH it, or model/mode-only entries
2191 // would persist forever keyed to a session that no longer exists.
21812192 const mappedDraftKeys = new Set(
21822193 Object.values(state.logicalProjectDraftThreadKeyByLogicalProjectKey),
21832194 );
......@@ -2187,7 +2198,8 @@ export function partializeComposerDraftStoreState(
21872198 ([threadKey, draftThread]) =>
21882199 mappedDraftKeys.has(threadKey) ||
21892200 isDraftThreadPromoting(draftThread) ||
2190 composerDraftHasUserContent(state.draftsByThreadKey[threadKey]),
2201 composerDraftHasUserContent(state.draftsByThreadKey[threadKey]) ||
2202 draftThreadHasOpenPreview(draftThread),
21912203 )
21922204 .map(([threadKey]) => threadKey),
21932205 );
......@@ -2792,7 +2804,8 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
27922804 !isDraftThreadPromoting(previousDraftThread) &&
27932805 !composerDraftHasUserContent(
27942806 state.draftsByThreadKey[previousThreadKeyForLogicalProject],
2795 )
2807 ) &&
2808 !draftThreadHasOpenPreview(previousDraftThread)
27962809 ) {
27972810 delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject];
27982811 if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) {
apps/web/src/rightPanelStore.ts+14
......@@ -121,6 +121,13 @@ export interface ThreadPanelVisibility {
121121
122122interface RightPanelStoreState {
123123 byThreadKey: Record<string, ThreadRightPanelState>;
124 /**
125 * A thread whose right panel should open maximized the next time it shows,
126 * such as one started for a link the OS opened. Its view consumes it once.
127 */
128 pendingMaximizeThreadKey: string | null;
129 requestMaximize: (ref: ScopedThreadRef) => void;
130 consumeMaximizeRequest: (ref: ScopedThreadRef) => boolean;
124131 threadPanelVisibilityByThreadKey: Record<string, ThreadPanelVisibility>;
125132 /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */
126133 userActionRevisionByThreadKey: Record<string, number>;
......@@ -590,6 +597,13 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
590597 persist(
591598 (set, get) => ({
592599 byThreadKey: {},
600 pendingMaximizeThreadKey: null,
601 requestMaximize: (ref) => set({ pendingMaximizeThreadKey: scopedThreadKey(ref) }),
602 consumeMaximizeRequest: (ref) => {
603 if (get().pendingMaximizeThreadKey !== scopedThreadKey(ref)) return false;
604 set({ pendingMaximizeThreadKey: null });
605 return true;
606 },
593607 threadPanelVisibilityByThreadKey: {},
594608 userActionRevisionByThreadKey: {},
595609 closeRevisionByThreadKey: {},
apps/web/src/routes/__root.tsx+2
......@@ -27,6 +27,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall
2727import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
2828import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator";
2929import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";
30import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkCoordinator";
3031import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive";
3132import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
3233import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta";
......@@ -228,6 +229,7 @@ function RootRouteView() {
228229 >
229230 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
230231 {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null}
232 {primaryEnvironmentAuthenticated ? <DesktopWebLinkCoordinator /> : null}
231233 {isElectron ? <RunningThreadKeepAlive /> : null}
232234 <RelayClientInstallDialog />
233235 <ConnectOnboardingDialog />
docs/README.md+1
......@@ -14,6 +14,7 @@
1414- [SnapShots](./user/snap-shot.md)
1515- [Visual replies](./user/html-renders.md)
1616- [Import browser sessions](./user/browser-import.md)
17- [Use T3 Code as your default browser](./user/default-browser.md)
1718- [Devices](./user/devices.md)
1819- [Usage and limits](./user/usage.md)
1920- [Product usage data](./user/telemetry.md)
docs/user/default-browser.md created+13
......@@ -0,0 +1,13 @@
1# Use T3 Code as your default browser
2
3On macOS, the desktop app can be your default web browser. Links you open from other apps then
4open in T3 Code. When the thread on screen shows its browser panel, the link opens there as a new
5tab. Otherwise it starts a new thread without a project, with the browser panel maximized on the
6page, so you can ask an agent about it right away.
7
8Choose **T3 Code** under **System Settings → Desktop & Dock → Default web browser**. To go back,
9choose another browser there.
10
11HTML files you open from Finder open the same way.
12
13A link that launches T3 Code waits until the app has connected to its environment, then opens.
packages/contracts/src/ipc.ts+8
......@@ -1246,6 +1246,14 @@ export interface DesktopBridge {
12461246 uninstall: () => Promise<DesktopCliCommandState>;
12471247 };
12481248 /** Present when the desktop shell accepts `t3 app` activation requests. */
1249 /**
1250 * Web links the OS opens with T3 Code as the default browser (macOS). The
1251 * renderer reports when it listens; links queue in the main process until then.
1252 */
1253 webLinks?: {
1254 setReady: (ready: boolean) => Promise<void>;
1255 onOpen: (listener: (url: string) => void) => () => void;
1256 };
12491257 appActivation?: {
12501258 setReady: (ready: boolean) => Promise<void>;
12511259 complete: (response: DesktopAppActivationResponse) => Promise<void>;
scripts/build-desktop-artifact.test.ts+16
......@@ -698,6 +698,21 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
698698 assert.deepStrictEqual((linux.linux as Record<string, unknown>).protocols, [
699699 { name: "T3 Code", schemes: ["t3code", "t3code-dev"] },
700700 ]);
701 // macOS also offers itself as a web browser, so it can be chosen as the default.
702 assert.deepStrictEqual((mac.mac as Record<string, unknown>).protocols, [
703 { name: "T3 Code", schemes: ["t3code", "t3code-dev"] },
704 { name: "Web site URL", schemes: ["http", "https"], role: "Viewer" },
705 ]);
706 // macOS lists a default browser only when it also opens web pages as documents.
707 const macInfo = (mac.mac as { extendInfo: Record<string, unknown> }).extendInfo;
708 assert.deepStrictEqual(macInfo.CFBundleDocumentTypes, [
709 {
710 CFBundleTypeName: "Web page",
711 CFBundleTypeRole: "Viewer",
712 LSHandlerRank: "Alternate",
713 LSItemContentTypes: ["public.html", "public.xhtml"],
714 },
715 ]);
701716 assert.deepStrictEqual(linux.toolsets, { appimage: "1.0.3" });
702717 assert.notProperty(mac, "toolsets");
703718 assert.notProperty(win, "toolsets");
......@@ -2082,6 +2097,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
20822097 assert.match(String(mac.sign), /[\\/]scripts[\\/]sign-macos\.ts$/);
20832098 assert.deepStrictEqual(mac.protocols, [
20842099 { name: "T3 Code", schemes: ["t3code", "t3code-dev"] },
2100 { name: "Web site URL", schemes: ["http", "https"], role: "Viewer" },
20852101 ]);
20862102 }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
20872103 );
scripts/build-desktop-artifact.ts+17
......@@ -2813,12 +2813,29 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
28132813 extendInfo: {
28142814 NSScreenCaptureUsageDescription:
28152815 "T3 Code captures the active window when you use the window capture shortcut.",
2816 // macOS lists an app under Default web browser only when it opens web
2817 // pages as documents as well as http and https links (see protocols).
2818 CFBundleDocumentTypes: [
2819 {
2820 CFBundleTypeName: "Web page",
2821 CFBundleTypeRole: "Viewer",
2822 LSHandlerRank: "Alternate",
2823 LSItemContentTypes: ["public.html", "public.xhtml"],
2824 },
2825 ],
28162826 },
28172827 protocols: [
28182828 {
28192829 name: "T3 Code",
28202830 schemes: ["t3code", "t3code-dev"],
28212831 },
2832 // Lets people choose T3 Code as their default web browser, which opens
2833 // each link in a new thread's browser panel.
2834 {
2835 name: "Web site URL",
2836 schemes: ["http", "https"],
2837 role: "Viewer",
2838 },
28222839 ],
28232840 ...(signed ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}),
28242841 ...(macPasskeySigning