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";...@@ -35,9 +35,16 @@ import * as ElectronApp from "../electron/ElectronApp.ts";
35import * as ElectronShell from "../electron/ElectronShell.ts";35import * as ElectronShell from "../electron/ElectronShell.ts";
36import * as ElectronWindow from "../electron/ElectronWindow.ts";36import * as ElectronWindow from "../electron/ElectronWindow.ts";
37import * as DesktopClerk from "./DesktopClerk.ts";37import * as DesktopClerk from "./DesktopClerk.ts";
38import * as DesktopWebLinks from "./DesktopWebLinks.ts";
38import * as DesktopEnvironment from "./DesktopEnvironment.ts";39import * as DesktopEnvironment from "./DesktopEnvironment.ts";
39import * as DesktopPreReadyFileSystem from "./DesktopPreReadyFileSystem.ts";40import * 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
41const layerDesktopClerk = (48const layerDesktopClerk = (
42 isDevelopment = true,49 isDevelopment = true,
43 events: string[] = [],50 events: string[] = [],
...@@ -220,11 +227,12 @@ describe("DesktopClerk", () => {...@@ -220,11 +227,12 @@ describe("DesktopClerk", () => {
220227
221 assert.isTrue(Exit.isSuccess(exit));228 assert.isTrue(Exit.isSuccess(exit));
222 assert.equal(quit.mock.calls.length, 0);229 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"]);
224 }).pipe(231 }).pipe(
225 Effect.provide(layerDesktopClerk()),232 Effect.provide(layerDesktopClerk()),
226 Effect.provideService(ElectronApp.ElectronApp, electronApp),233 Effect.provideService(ElectronApp.ElectronApp, electronApp),
227 Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),234 Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),
235 Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks),
228 );236 );
229 });237 });
230238
...@@ -253,6 +261,7 @@ describe("DesktopClerk", () => {...@@ -253,6 +261,7 @@ describe("DesktopClerk", () => {
253 Effect.provide(layerDesktopClerk()),261 Effect.provide(layerDesktopClerk()),
254 Effect.provideService(ElectronApp.ElectronApp, electronApp),262 Effect.provideService(ElectronApp.ElectronApp, electronApp),
255 Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),263 Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),
264 Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks),
256 );265 );
257 });266 });
258});267});
...@@ -299,6 +308,7 @@ it.effect(...@@ -299,6 +308,7 @@ it.effect(
299 Effect.provide(layerDesktopClerk()),308 Effect.provide(layerDesktopClerk()),
300 Effect.provideService(ElectronApp.ElectronApp, electronApp),309 Effect.provideService(ElectronApp.ElectronApp, electronApp),
301 Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),310 Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),
311 Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks),
302 );312 );
303 },313 },
304);314);
...@@ -381,6 +391,54 @@ it.effect.each(["startup", "open-url"] as const)(...@@ -381,6 +391,54 @@ it.effect.each(["startup", "open-url"] as const)(
381 ElectronWindow.ElectronWindow,391 ElectronWindow.ElectronWindow,
382 {} as ElectronWindow.ElectronWindow["Service"],392 {} as ElectronWindow.ElectronWindow["Service"],
383 ),393 ),
394 Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks),
384 );395 );
385 }).pipe(Effect.scoped),396 }).pipe(Effect.scoped),
386);397);
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 @@...@@ -1,4 +1,5 @@
1import { createClerkBridge } from "@clerk/electron";1import { createClerkBridge } from "@clerk/electron";
2import * as NodeURL from "node:url";
2import { storage } from "@clerk/electron/storage";3import { storage } from "@clerk/electron/storage";
3import * as Context from "effect/Context";4import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
...@@ -18,6 +19,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts";...@@ -18,6 +19,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
18import * as ElectronWindow from "../electron/ElectronWindow.ts";19import * as ElectronWindow from "../electron/ElectronWindow.ts";
19import * as DesktopUserData from "./DesktopUserData.ts";20import * as DesktopUserData from "./DesktopUserData.ts";
20import * as DesktopEnvironment from "./DesktopEnvironment.ts";21import * as DesktopEnvironment from "./DesktopEnvironment.ts";
22import * as DesktopWebLinks from "./DesktopWebLinks.ts";
2123
22declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;24declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
2325
...@@ -53,7 +55,10 @@ export class DesktopClerk extends Context.Service<...@@ -53,7 +55,10 @@ export class DesktopClerk extends Context.Service<
53 readonly configure: Effect.Effect<55 readonly configure: Effect.Effect<
54 void,56 void,
55 never,57 never,
56 ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope58 | ElectronApp.ElectronApp
59 | ElectronWindow.ElectronWindow
60 | DesktopWebLinks.DesktopWebLinks
61 | Scope.Scope
57 >;62 >;
58 }63 }
59>()("@t3tools/desktop/app/DesktopClerk") {}64>()("@t3tools/desktop/app/DesktopClerk") {}
...@@ -125,6 +130,7 @@ export const make = Effect.gen(function* () {...@@ -125,6 +130,7 @@ export const make = Effect.gen(function* () {
125 configure: Effect.gen(function* () {130 configure: Effect.gen(function* () {
126 const electronApp = yield* ElectronApp.ElectronApp;131 const electronApp = yield* ElectronApp.ElectronApp;
127 const electronWindow = yield* ElectronWindow.ElectronWindow;132 const electronWindow = yield* ElectronWindow.ElectronWindow;
133 const webLinks = yield* DesktopWebLinks.DesktopWebLinks;
128 const context = yield* Effect.context<ElectronWindow.ElectronWindow>();134 const context = yield* Effect.context<ElectronWindow.ElectronWindow>();
129 const runPromise = Effect.runPromiseWith(context);135 const runPromise = Effect.runPromiseWith(context);
130136
...@@ -184,8 +190,21 @@ export const make = Effect.gen(function* () {...@@ -184,8 +190,21 @@ export const make = Effect.gen(function* () {
184 };190 };
185 const args = yield* HostProcess.Arguments;191 const args = yield* HostProcess.Arguments;
186 args.some((value) => startProviderAuthHandoff(value));192 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 };
187 yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => {199 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));
189 });208 });
190 yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => {209 yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => {
191 if (argv?.some((value) => startProviderAuthHandoff(value) || resumeProviderAuth(value)))210 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 {...@@ -70,6 +70,7 @@ import {
70} from "./methods/snapShot.ts";70} from "./methods/snapShot.ts";
71import * as PreviewIpc from "./methods/preview.ts";71import * as PreviewIpc from "./methods/preview.ts";
72import * as AppActivationIpc from "./methods/appActivation.ts";72import * as AppActivationIpc from "./methods/appActivation.ts";
73import * as WebLinksIpc from "./methods/webLinks.ts";
73import {74import {
74 completeLegacyLocalStorage,75 completeLegacyLocalStorage,
75 takeLegacyLocalStorage,76 takeLegacyLocalStorage,
...@@ -88,6 +89,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"...@@ -88,6 +89,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
8889
89 yield* ipc.handle(AppActivationIpc.setReady);90 yield* ipc.handle(AppActivationIpc.setReady);
90 yield* ipc.handle(AppActivationIpc.complete);91 yield* ipc.handle(AppActivationIpc.complete);
92 yield* ipc.handle(WebLinksIpc.setReady);
9193
92 yield* ipc.handleSync(takeLegacyLocalStorage);94 yield* ipc.handleSync(takeLegacyLocalStorage);
93 yield* ipc.handle(completeLegacyLocalStorage);95 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";...@@ -10,6 +10,8 @@ export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
10export const MENU_ACTION_CHANNEL = "desktop:menu-action";10export const MENU_ACTION_CHANNEL = "desktop:menu-action";
11export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text";11export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text";
12export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event";12export 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";
13export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";15export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
14export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end";16export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end";
15export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";17export 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";...@@ -35,6 +35,7 @@ import * as ElectronUpdater from "./electron/ElectronUpdater.ts";
35import * as ElectronWindow from "./electron/ElectronWindow.ts";35import * as ElectronWindow from "./electron/ElectronWindow.ts";
36import * as DesktopApp from "./app/DesktopApp.ts";36import * as DesktopApp from "./app/DesktopApp.ts";
37import * as DesktopAppActivation from "./app/DesktopAppActivation.ts";37import * as DesktopAppActivation from "./app/DesktopAppActivation.ts";
38import * as DesktopWebLinks from "./app/DesktopWebLinks.ts";
38import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";39import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
39import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";40import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";
40import * as DesktopClerk from "./app/DesktopClerk.ts";41import * as DesktopClerk from "./app/DesktopClerk.ts";
...@@ -214,6 +215,7 @@ const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe(...@@ -214,6 +215,7 @@ const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe(
214const layerDesktopApplication = Layer.mergeAll(215const layerDesktopApplication = Layer.mergeAll(
215 DesktopLifecycle.layer,216 DesktopLifecycle.layer,
216 layerDesktopAppActivation,217 layerDesktopAppActivation,
218 DesktopWebLinks.layer,
217 DesktopApplicationMenu.layer,219 DesktopApplicationMenu.layer,
218 DesktopLinuxUrlHandler.layer,220 DesktopLinuxUrlHandler.layer,
219 DesktopCliCommand.layer,221 DesktopCliCommand.layer,
apps/desktop/src/preload.ts+12
...@@ -285,6 +285,18 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -285,6 +285,18 @@ contextBridge.exposeInMainWorld("desktopBridge", {
285 ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener);285 ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener);
286 };286 };
287 },287 },
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 },
288 appActivation: {300 appActivation: {
289 setReady: (ready) =>301 setReady: (ready) =>
290 ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, ready),302 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) {...@@ -6056,6 +6056,13 @@ export default function ChatView(props: ChatViewProps) {
6056 if (!activeThreadRef) return;6056 if (!activeThreadRef) return;
6057 useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation);6057 useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation);
6058 }, [activeThreadRef, threadPanelPresentation]);6058 }, [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]);
6059 const toggleRightPanelMaximized = useCallback(() => {6066 const toggleRightPanelMaximized = useCallback(() => {
6060 if (!canMaximizeRightPanel) return;6067 if (!canMaximizeRightPanel) return;
6061 setMaximizedRightPanelThreadKey((threadKey) =>6068 setMaximizedRightPanelThreadKey((threadKey) =>
apps/web/src/components/chat/DraftHeroHeadline.tsx+12-4
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { DraftId } from "~/composerDraftStore";1import type { DraftId } from "~/composerDraftStore";
2import { useComposerDraftStore } from "~/composerDraftStore";2import { composerDraftHasUserContent, useComposerDraftStore } from "~/composerDraftStore";
3import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";3import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";
4import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";4import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
5import { isScratchProject } from "@t3tools/client-runtime/state/projects";5import { isScratchProject } from "@t3tools/client-runtime/state/projects";
...@@ -11,6 +11,7 @@ import { openCommandPalette } from "~/commandPaletteBus";...@@ -11,6 +11,7 @@ import { openCommandPalette } from "~/commandPaletteBus";
11import { shortcutLabelForCommand } from "~/keybindings";11import { shortcutLabelForCommand } from "~/keybindings";
12import { projectIconColorClassName } from "~/projectIconColors";12import { projectIconColorClassName } from "~/projectIconColors";
13import { primaryServerKeybindingsAtom } from "~/state/server";13import { primaryServerKeybindingsAtom } from "~/state/server";
14import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
14import { useScratchProject } from "~/hooks/useScratchProject";15import { useScratchProject } from "~/hooks/useScratchProject";
15import { useClientSettings } from "~/hooks/useSettings";16import { useClientSettings } from "~/hooks/useSettings";
16import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions";17import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions";
...@@ -69,6 +70,7 @@ export function DraftHeroHeadline({...@@ -69,6 +70,7 @@ export function DraftHeroHeadline({
69 const setModelSelection = useComposerDraftStore((store) => store.setModelSelection);70 const setModelSelection = useComposerDraftStore((store) => store.setModelSelection);
70 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);71 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
71 const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject();72 const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject();
73 const openProjectDraft = useNewThreadHandler();
72 const keybindings = useAtomValue(primaryServerKeybindingsAtom);74 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
7375
74 const environmentLabelById = useMemo(76 const environmentLabelById = useMemo(
...@@ -172,9 +174,11 @@ export function DraftHeroHeadline({...@@ -172,9 +174,11 @@ export function DraftHeroHeadline({
172 useEffect(() => {174 useEffect(() => {
173 latestTargetRef.current = { draftId, activeProjectKey, scratchTargetEnvironmentId };175 latestTargetRef.current = { draftId, activeProjectKey, scratchTargetEnvironmentId };
174 }, [activeProjectKey, scratchTargetEnvironmentId, draftId]);176 }, [activeProjectKey, scratchTargetEnvironmentId, draftId]);
175 // Project selection changes the target of the open draft in place. The177 // With a prompt typed, project selection changes the target of the open
176 // prompt stays in the same composer session, so the sidebar only gets a178 // draft in place, so the prompt stays in the same composer session. An empty
177 // draft row if the user later navigates away.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.
178 const selectProject = (project: (typeof projects)[number], logicalProjectKey: string) => {182 const selectProject = (project: (typeof projects)[number], logicalProjectKey: string) => {
179 if (!draftId) {183 if (!draftId) {
180 return;184 return;
...@@ -185,6 +189,10 @@ export function DraftHeroHeadline({...@@ -185,6 +189,10 @@ export function DraftHeroHeadline({
185 scratchTargetEnvironmentId: project.environmentId,189 scratchTargetEnvironmentId: project.environmentId,
186 };190 };
187 const currentDraft = getComposerDraft(draftId);191 const currentDraft = getComposerDraft(draftId);
192 if (!composerDraftHasUserContent(currentDraft)) {
193 void openProjectDraft(scopeProjectRef(project.environmentId, project.id));
194 return;
195 }
188 setLogicalProjectDraftThreadId(196 setLogicalProjectDraftThreadId(
189 logicalProjectKey,197 logicalProjectKey,
190 scopeProjectRef(project.environmentId, project.id),198 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 {...@@ -89,6 +89,7 @@ import {
89 type TerminalContextDraft,89 type TerminalContextDraft,
90} from "./lib/terminalContext";90} from "./lib/terminalContext";
91import { createDeferredStorage } from "./lib/storage";91import { createDeferredStorage } from "./lib/storage";
92import { applyPreviewServerSnapshot, resetPreviewStateForTests } from "./previewStateStore";
9293
93function makeImage(input: {94function makeImage(input: {
94 id: string;95 id: string;
...@@ -1467,6 +1468,30 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -1467,6 +1468,30 @@ describe("composerDraftStore project draft thread mapping", () => {
1467 expect(draftByKey(draftId)).toBeUndefined();1468 expect(draftByKey(draftId)).toBeUndefined();
1468 });1469 });
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
1470 it("keeps invested composer drafts alive unmapped when remapping a project to a new draft thread", () => {1495 it("keeps invested composer drafts alive unmapped when remapping a project to a new draft thread", () => {
1471 const store = useComposerDraftStore.getState();1496 const store = useComposerDraftStore.getState();
1472 store.setProjectDraftThreadId(projectRef, draftId, { threadId });1497 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
apps/web/src/composerDraftStore.ts+19-6
...@@ -40,6 +40,7 @@ import { DeepMutable } from "effect/Types";...@@ -40,6 +40,7 @@ import { DeepMutable } from "effect/Types";
40import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";40import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
41import { useMemo } from "react";41import { useMemo } from "react";
42import { getLocalStorageItem } from "./hooks/useLocalStorage";42import { getLocalStorageItem } from "./hooks/useLocalStorage";
43import { readThreadPreviewState } from "./previewStateStore";
43import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection";44import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection";
44import {45import {
45 DEFAULT_INTERACTION_MODE,46 DEFAULT_INTERACTION_MODE,
...@@ -1556,6 +1557,16 @@ function getComposerDraftState(...@@ -1556,6 +1557,16 @@ function getComposerDraftState(
1556 return state.draftsByThreadKey[threadKey] ?? null;1557 return state.draftsByThreadKey[threadKey] ?? null;
1557}1558}
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
1559function isComposerThreadKeyInUse(mappings: Record<string, string>, threadKey: string): boolean {1570function isComposerThreadKeyInUse(mappings: Record<string, string>, threadKey: string): boolean {
1560 return Object.values(mappings).includes(threadKey);1571 return Object.values(mappings).includes(threadKey);
1561}1572}
...@@ -2174,10 +2185,10 @@ export function partializeComposerDraftStoreState(...@@ -2174,10 +2185,10 @@ export function partializeComposerDraftStoreState(
2174 state: ComposerDraftStoreState,2185 state: ComposerDraftStoreState,
2175): PersistedComposerDraftStoreState {2186): PersistedComposerDraftStoreState {
2176 // Draft sessions worth persisting: mapped (a new-thread flow targets2187 // Draft sessions worth persisting: mapped (a new-thread flow targets
2177 // them), promoting (mid-send), or holding real user content (they back a2188 // them), promoting (mid-send), holding real user content (they back a
2178 // sidebar row). Everything else is a zombie — and its composer blob must2189 // sidebar row), or holding an open page. Everything else is a zombie — and
2179 // be dropped WITH it, or model/mode-only entries would persist forever2190 // its composer blob must be dropped WITH it, or model/mode-only entries
2180 // keyed to a session that no longer exists.2191 // would persist forever keyed to a session that no longer exists.
2181 const mappedDraftKeys = new Set(2192 const mappedDraftKeys = new Set(
2182 Object.values(state.logicalProjectDraftThreadKeyByLogicalProjectKey),2193 Object.values(state.logicalProjectDraftThreadKeyByLogicalProjectKey),
2183 );2194 );
...@@ -2187,7 +2198,8 @@ export function partializeComposerDraftStoreState(...@@ -2187,7 +2198,8 @@ export function partializeComposerDraftStoreState(
2187 ([threadKey, draftThread]) =>2198 ([threadKey, draftThread]) =>
2188 mappedDraftKeys.has(threadKey) ||2199 mappedDraftKeys.has(threadKey) ||
2189 isDraftThreadPromoting(draftThread) ||2200 isDraftThreadPromoting(draftThread) ||
2190 composerDraftHasUserContent(state.draftsByThreadKey[threadKey]),2201 composerDraftHasUserContent(state.draftsByThreadKey[threadKey]) ||
2202 draftThreadHasOpenPreview(draftThread),
2191 )2203 )
2192 .map(([threadKey]) => threadKey),2204 .map(([threadKey]) => threadKey),
2193 );2205 );
...@@ -2792,7 +2804,8 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2792,7 +2804,8 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
2792 !isDraftThreadPromoting(previousDraftThread) &&2804 !isDraftThreadPromoting(previousDraftThread) &&
2793 !composerDraftHasUserContent(2805 !composerDraftHasUserContent(
2794 state.draftsByThreadKey[previousThreadKeyForLogicalProject],2806 state.draftsByThreadKey[previousThreadKeyForLogicalProject],
2795 )2807 ) &&
2808 !draftThreadHasOpenPreview(previousDraftThread)
2796 ) {2809 ) {
2797 delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject];2810 delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject];
2798 if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) {2811 if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) {
apps/web/src/rightPanelStore.ts+14
...@@ -121,6 +121,13 @@ export interface ThreadPanelVisibility {...@@ -121,6 +121,13 @@ export interface ThreadPanelVisibility {
121121
122interface RightPanelStoreState {122interface RightPanelStoreState {
123 byThreadKey: Record<string, ThreadRightPanelState>;123 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;
124 threadPanelVisibilityByThreadKey: Record<string, ThreadPanelVisibility>;131 threadPanelVisibilityByThreadKey: Record<string, ThreadPanelVisibility>;
125 /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */132 /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */
126 userActionRevisionByThreadKey: Record<string, number>;133 userActionRevisionByThreadKey: Record<string, number>;
...@@ -590,6 +597,13 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -590,6 +597,13 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
590 persist(597 persist(
591 (set, get) => ({598 (set, get) => ({
592 byThreadKey: {},599 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 },
593 threadPanelVisibilityByThreadKey: {},607 threadPanelVisibilityByThreadKey: {},
594 userActionRevisionByThreadKey: {},608 userActionRevisionByThreadKey: {},
595 closeRevisionByThreadKey: {},609 closeRevisionByThreadKey: {},
apps/web/src/routes/__root.tsx+2
...@@ -27,6 +27,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall...@@ -27,6 +27,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall
27import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";27import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
28import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator";28import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator";
29import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";29import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";
30import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkCoordinator";
30import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive";31import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive";
31import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";32import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
32import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta";33import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta";
...@@ -228,6 +229,7 @@ function RootRouteView() {...@@ -228,6 +229,7 @@ function RootRouteView() {
228 >229 >
229 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}230 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
230 {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null}231 {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null}
232 {primaryEnvironmentAuthenticated ? <DesktopWebLinkCoordinator /> : null}
231 {isElectron ? <RunningThreadKeepAlive /> : null}233 {isElectron ? <RunningThreadKeepAlive /> : null}
232 <RelayClientInstallDialog />234 <RelayClientInstallDialog />
233 <ConnectOnboardingDialog />235 <ConnectOnboardingDialog />
docs/README.md+1
...@@ -14,6 +14,7 @@...@@ -14,6 +14,7 @@
14- [SnapShots](./user/snap-shot.md)14- [SnapShots](./user/snap-shot.md)
15- [Visual replies](./user/html-renders.md)15- [Visual replies](./user/html-renders.md)
16- [Import browser sessions](./user/browser-import.md)16- [Import browser sessions](./user/browser-import.md)
17- [Use T3 Code as your default browser](./user/default-browser.md)
17- [Devices](./user/devices.md)18- [Devices](./user/devices.md)
18- [Usage and limits](./user/usage.md)19- [Usage and limits](./user/usage.md)
19- [Product usage data](./user/telemetry.md)20- [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 {...@@ -1246,6 +1246,14 @@ export interface DesktopBridge {
1246 uninstall: () => Promise<DesktopCliCommandState>;1246 uninstall: () => Promise<DesktopCliCommandState>;
1247 };1247 };
1248 /** Present when the desktop shell accepts `t3 app` activation requests. */1248 /** 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 };
1249 appActivation?: {1257 appActivation?: {
1250 setReady: (ready: boolean) => Promise<void>;1258 setReady: (ready: boolean) => Promise<void>;
1251 complete: (response: DesktopAppActivationResponse) => Promise<void>;1259 complete: (response: DesktopAppActivationResponse) => Promise<void>;
scripts/build-desktop-artifact.test.ts+16
...@@ -698,6 +698,21 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -698,6 +698,21 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
698 assert.deepStrictEqual((linux.linux as Record<string, unknown>).protocols, [698 assert.deepStrictEqual((linux.linux as Record<string, unknown>).protocols, [
699 { name: "T3 Code", schemes: ["t3code", "t3code-dev"] },699 { name: "T3 Code", schemes: ["t3code", "t3code-dev"] },
700 ]);700 ]);
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 ]);
701 assert.deepStrictEqual(linux.toolsets, { appimage: "1.0.3" });716 assert.deepStrictEqual(linux.toolsets, { appimage: "1.0.3" });
702 assert.notProperty(mac, "toolsets");717 assert.notProperty(mac, "toolsets");
703 assert.notProperty(win, "toolsets");718 assert.notProperty(win, "toolsets");
...@@ -2082,6 +2097,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -2082,6 +2097,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
2082 assert.match(String(mac.sign), /[\\/]scripts[\\/]sign-macos\.ts$/);2097 assert.match(String(mac.sign), /[\\/]scripts[\\/]sign-macos\.ts$/);
2083 assert.deepStrictEqual(mac.protocols, [2098 assert.deepStrictEqual(mac.protocols, [
2084 { name: "T3 Code", schemes: ["t3code", "t3code-dev"] },2099 { name: "T3 Code", schemes: ["t3code", "t3code-dev"] },
2100 { name: "Web site URL", schemes: ["http", "https"], role: "Viewer" },
2085 ]);2101 ]);
2086 }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),2102 }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
2087 );2103 );
scripts/build-desktop-artifact.ts+17
...@@ -2813,12 +2813,29 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -2813,12 +2813,29 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
2813 extendInfo: {2813 extendInfo: {
2814 NSScreenCaptureUsageDescription:2814 NSScreenCaptureUsageDescription:
2815 "T3 Code captures the active window when you use the window capture shortcut.",2815 "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 ],
2816 },2826 },
2817 protocols: [2827 protocols: [
2818 {2828 {
2819 name: "T3 Code",2829 name: "T3 Code",
2820 schemes: ["t3code", "t3code-dev"],2830 schemes: ["t3code", "t3code-dev"],
2821 },2831 },
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 },
2822 ],2839 ],
2823 ...(signed ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}),2840 ...(signed ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}),
2824 ...(macPasskeySigning2841 ...(macPasskeySigning