| author | |
| committer | |
| log | 38571328a4bc3c30c56fc0cae299437fe9b53898 |
| tree | b43e427da4d79c22a62e9be28033e3bc2594b36c |
| parent | 140ee145ac33653ac2c36f7940e4f54f6e92497f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 35 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 36 | 36 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 37 | 37 | import * as DesktopClerk from "./DesktopClerk.ts"; |
| 38 | import * as DesktopWebLinks from "./DesktopWebLinks.ts"; | |
| 38 | 39 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 39 | 40 | import * as DesktopPreReadyFileSystem from "./DesktopPreReadyFileSystem.ts"; |
| 40 | 41 | |
| 42 | /** Clerk forwards web links; tests that are not about links ignore them. */ | |
| 43 | const ignoreWebLinks = DesktopWebLinks.DesktopWebLinks.of({ | |
| 44 | receive: () => Effect.void, | |
| 45 | setRendererReady: () => Effect.void, | |
| 46 | }); | |
| 47 | ||
| 41 | 48 | const layerDesktopClerk = ( |
| 42 | 49 | isDevelopment = true, |
| 43 | 50 | events: string[] = [], |
| ... | ... | @@ -220,11 +227,12 @@ describe("DesktopClerk", () => { |
| 220 | 227 | |
| 221 | 228 | assert.isTrue(Exit.isSuccess(exit)); |
| 222 | 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 | 231 | }).pipe( |
| 225 | 232 | Effect.provide(layerDesktopClerk()), |
| 226 | 233 | Effect.provideService(ElectronApp.ElectronApp, electronApp), |
| 227 | 234 | Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), |
| 235 | Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), | |
| 228 | 236 | ); |
| 229 | 237 | }); |
| 230 | 238 | |
| ... | ... | @@ -253,6 +261,7 @@ describe("DesktopClerk", () => { |
| 253 | 261 | Effect.provide(layerDesktopClerk()), |
| 254 | 262 | Effect.provideService(ElectronApp.ElectronApp, electronApp), |
| 255 | 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 | 308 | Effect.provide(layerDesktopClerk()), |
| 300 | 309 | Effect.provideService(ElectronApp.ElectronApp, electronApp), |
| 301 | 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 | 391 | ElectronWindow.ElectronWindow, |
| 382 | 392 | {} as ElectronWindow.ElectronWindow["Service"], |
| 383 | 393 | ), |
| 394 | Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), | |
| 384 | 395 | ); |
| 385 | 396 | }).pipe(Effect.scoped), |
| 386 | 397 | ); |
| 398 | ||
| 399 | it.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 | 1 | import { createClerkBridge } from "@clerk/electron"; |
| 2 | import * as NodeURL from "node:url"; | |
| 2 | 3 | import { storage } from "@clerk/electron/storage"; |
| 3 | 4 | import * as Context from "effect/Context"; |
| 4 | 5 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -18,6 +19,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; |
| 18 | 19 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 19 | 20 | import * as DesktopUserData from "./DesktopUserData.ts"; |
| 20 | 21 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 22 | import * as DesktopWebLinks from "./DesktopWebLinks.ts"; | |
| 21 | 23 | |
| 22 | 24 | declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; |
| 23 | 25 | |
| ... | ... | @@ -53,7 +55,10 @@ export class DesktopClerk extends Context.Service< |
| 53 | 55 | readonly configure: Effect.Effect< |
| 54 | 56 | void, |
| 55 | 57 | never, |
| 56 | ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope | |
| 58 | | ElectronApp.ElectronApp | |
| 59 | | ElectronWindow.ElectronWindow | |
| 60 | | DesktopWebLinks.DesktopWebLinks | |
| 61 | | Scope.Scope | |
| 57 | 62 | >; |
| 58 | 63 | } |
| 59 | 64 | >()("@t3tools/desktop/app/DesktopClerk") {} |
| ... | ... | @@ -125,6 +130,7 @@ export const make = Effect.gen(function* () { |
| 125 | 130 | configure: Effect.gen(function* () { |
| 126 | 131 | const electronApp = yield* ElectronApp.ElectronApp; |
| 127 | 132 | const electronWindow = yield* ElectronWindow.ElectronWindow; |
| 133 | const webLinks = yield* DesktopWebLinks.DesktopWebLinks; | |
| 128 | 134 | const context = yield* Effect.context<ElectronWindow.ElectronWindow>(); |
| 129 | 135 | const runPromise = Effect.runPromiseWith(context); |
| 130 | 136 | |
| ... | ... | @@ -184,8 +190,21 @@ export const make = Effect.gen(function* () { |
| 184 | 190 | }; |
| 185 | 191 | const args = yield* HostProcess.Arguments; |
| 186 | 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 | 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 | 209 | yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { |
| 191 | 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. | |
| 2 | import { assert, describe, it } from "@effect/vitest"; | |
| 3 | import * as NodeEvents from "node:events"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | ||
| 8 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | |
| 9 | import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; | |
| 10 | import * as DesktopWebLinks from "./DesktopWebLinks.ts"; | |
| 11 | ||
| 12 | const 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 | ||
| 39 | describe("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 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | ||
| 6 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | |
| 7 | import { 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. */ | |
| 10 | export 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. */ | |
| 17 | export 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 | */ | |
| 25 | export 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 | ||
| 35 | const 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 | ||
| 97 | export const layer = Layer.effect(DesktopWebLinks, make); |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2| ... | ... | @@ -70,6 +70,7 @@ import { |
| 70 | 70 | } from "./methods/snapShot.ts"; |
| 71 | 71 | import * as PreviewIpc from "./methods/preview.ts"; |
| 72 | 72 | import * as AppActivationIpc from "./methods/appActivation.ts"; |
| 73 | import * as WebLinksIpc from "./methods/webLinks.ts"; | |
| 73 | 74 | import { |
| 74 | 75 | completeLegacyLocalStorage, |
| 75 | 76 | takeLegacyLocalStorage, |
| ... | ... | @@ -88,6 +89,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 88 | 89 | |
| 89 | 90 | yield* ipc.handle(AppActivationIpc.setReady); |
| 90 | 91 | yield* ipc.handle(AppActivationIpc.complete); |
| 92 | yield* ipc.handle(WebLinksIpc.setReady); | |
| 91 | 93 | |
| 92 | 94 | yield* ipc.handleSync(takeLegacyLocalStorage); |
| 93 | 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 | 10 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 11 | 11 | export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; |
| 12 | 12 | export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; |
| 13 | export const WEB_LINK_OPEN_CHANNEL = "desktop:web-link-open"; | |
| 14 | export const WEB_LINK_READY_CHANNEL = "desktop:web-link-ready"; | |
| 13 | 15 | export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; |
| 14 | 16 | export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end"; |
| 15 | 17 | export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; |
apps/desktop/src/ipc/methods/webLinks.ts created+16| ... | ... | @@ -0,0 +1,16 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | ||
| 4 | import * as DesktopWebLinks from "../../app/DesktopWebLinks.ts"; | |
| 5 | import * as IpcChannels from "../channels.ts"; | |
| 6 | import * as DesktopIpc from "../DesktopIpc.ts"; | |
| 7 | ||
| 8 | export 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 | 35 | import * as ElectronWindow from "./electron/ElectronWindow.ts"; |
| 36 | 36 | import * as DesktopApp from "./app/DesktopApp.ts"; |
| 37 | 37 | import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; |
| 38 | import * as DesktopWebLinks from "./app/DesktopWebLinks.ts"; | |
| 38 | 39 | import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; |
| 39 | 40 | import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; |
| 40 | 41 | import * as DesktopClerk from "./app/DesktopClerk.ts"; |
| ... | ... | @@ -214,6 +215,7 @@ const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe( |
| 214 | 215 | const layerDesktopApplication = Layer.mergeAll( |
| 215 | 216 | DesktopLifecycle.layer, |
| 216 | 217 | layerDesktopAppActivation, |
| 218 | DesktopWebLinks.layer, | |
| 217 | 219 | DesktopApplicationMenu.layer, |
| 218 | 220 | DesktopLinuxUrlHandler.layer, |
| 219 | 221 | DesktopCliCommand.layer, |
apps/desktop/src/preload.ts+12| ... | ... | @@ -285,6 +285,18 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 285 | 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 | 300 | appActivation: { |
| 289 | 301 | setReady: (ready) => |
| 290 | 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 | 6056 | if (!activeThreadRef) return; |
| 6057 | 6057 | useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation); |
| 6058 | 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 | 6066 | const toggleRightPanelMaximized = useCallback(() => { |
| 6060 | 6067 | if (!canMaximizeRightPanel) return; |
| 6061 | 6068 | setMaximizedRightPanelThreadKey((threadKey) => |
apps/web/src/components/chat/DraftHeroHeadline.tsx+12-4| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import type { DraftId } from "~/composerDraftStore"; |
| 2 | import { useComposerDraftStore } from "~/composerDraftStore"; | |
| 2 | import { composerDraftHasUserContent, useComposerDraftStore } from "~/composerDraftStore"; | |
| 3 | 3 | import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; |
| 4 | 4 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 5 | 5 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; |
| ... | ... | @@ -11,6 +11,7 @@ import { openCommandPalette } from "~/commandPaletteBus"; |
| 11 | 11 | import { shortcutLabelForCommand } from "~/keybindings"; |
| 12 | 12 | import { projectIconColorClassName } from "~/projectIconColors"; |
| 13 | 13 | import { primaryServerKeybindingsAtom } from "~/state/server"; |
| 14 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; | |
| 14 | 15 | import { useScratchProject } from "~/hooks/useScratchProject"; |
| 15 | 16 | import { useClientSettings } from "~/hooks/useSettings"; |
| 16 | 17 | import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; |
| ... | ... | @@ -69,6 +70,7 @@ export function DraftHeroHeadline({ |
| 69 | 70 | const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); |
| 70 | 71 | const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); |
| 71 | 72 | const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); |
| 73 | const openProjectDraft = useNewThreadHandler(); | |
| 72 | 74 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 73 | 75 | |
| 74 | 76 | const environmentLabelById = useMemo( |
| ... | ... | @@ -172,9 +174,11 @@ export function DraftHeroHeadline({ |
| 172 | 174 | useEffect(() => { |
| 173 | 175 | latestTargetRef.current = { draftId, activeProjectKey, scratchTargetEnvironmentId }; |
| 174 | 176 | }, [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. | |
| 178 | 182 | const selectProject = (project: (typeof projects)[number], logicalProjectKey: string) => { |
| 179 | 183 | if (!draftId) { |
| 180 | 184 | return; |
| ... | ... | @@ -185,6 +189,10 @@ export function DraftHeroHeadline({ |
| 185 | 189 | scratchTargetEnvironmentId: project.environmentId, |
| 186 | 190 | }; |
| 187 | 191 | const currentDraft = getComposerDraft(draftId); |
| 192 | if (!composerDraftHasUserContent(currentDraft)) { | |
| 193 | void openProjectDraft(scopeProjectRef(project.environmentId, project.id)); | |
| 194 | return; | |
| 195 | } | |
| 188 | 196 | setLogicalProjectDraftThreadId( |
| 189 | 197 | logicalProjectKey, |
| 190 | 198 | scopeProjectRef(project.environmentId, project.id), |
apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx created+146| ... | ... | @@ -0,0 +1,146 @@ |
| 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 2 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | |
| 3 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; | |
| 4 | import { useRouter } from "@tanstack/react-router"; | |
| 5 | import { useEffect, useEffectEvent, useRef } from "react"; | |
| 6 | ||
| 7 | import { openFileInPreview, openUrlInPreview } from "../../browser/openFileInPreview"; | |
| 8 | import { isPreviewAvailableFor } from "../../browser/previewRuntime"; | |
| 9 | import { useComposerDraftStore } from "../../composerDraftStore"; | |
| 10 | import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; | |
| 11 | import { useScratchProject } from "../../hooks/useScratchProject"; | |
| 12 | import { selectActiveRightPanelSurface, useRightPanelStore } from "../../rightPanelStore"; | |
| 13 | import { resolveThreadRouteTarget } from "../../threadRoutes"; | |
| 14 | import { assetEnvironment } from "../../state/assets"; | |
| 15 | import { useEnvironmentHttpBaseUrl, usePrimaryEnvironment } from "../../state/environments"; | |
| 16 | import { previewEnvironment } from "../../state/preview"; | |
| 17 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 18 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; | |
| 19 | import { 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 | */ | |
| 26 | function 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 | */ | |
| 52 | export 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 | 89 | type TerminalContextDraft, |
| 90 | 90 | } from "./lib/terminalContext"; |
| 91 | 91 | import { createDeferredStorage } from "./lib/storage"; |
| 92 | import { applyPreviewServerSnapshot, resetPreviewStateForTests } from "./previewStateStore"; | |
| 92 | 93 | |
| 93 | 94 | function makeImage(input: { |
| 94 | 95 | id: string; |
| ... | ... | @@ -1467,6 +1468,30 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 1467 | 1468 | expect(draftByKey(draftId)).toBeUndefined(); |
| 1468 | 1469 | }); |
| 1469 | 1470 | |
| 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 | 1495 | it("keeps invested composer drafts alive unmapped when remapping a project to a new draft thread", () => { |
| 1471 | 1496 | const store = useComposerDraftStore.getState(); |
| 1472 | 1497 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); |
apps/web/src/composerDraftStore.ts+19-6| ... | ... | @@ -40,6 +40,7 @@ import { DeepMutable } from "effect/Types"; |
| 40 | 40 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 41 | 41 | import { useMemo } from "react"; |
| 42 | 42 | import { getLocalStorageItem } from "./hooks/useLocalStorage"; |
| 43 | import { readThreadPreviewState } from "./previewStateStore"; | |
| 43 | 44 | import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; |
| 44 | 45 | import { |
| 45 | 46 | DEFAULT_INTERACTION_MODE, |
| ... | ... | @@ -1556,6 +1557,16 @@ function getComposerDraftState( |
| 1556 | 1557 | return state.draftsByThreadKey[threadKey] ?? null; |
| 1557 | 1558 | } |
| 1558 | 1559 | |
| 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 | */ | |
| 1564 | function 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 | ||
| 1559 | 1570 | function isComposerThreadKeyInUse(mappings: Record<string, string>, threadKey: string): boolean { |
| 1560 | 1571 | return Object.values(mappings).includes(threadKey); |
| 1561 | 1572 | } |
| ... | ... | @@ -2174,10 +2185,10 @@ export function partializeComposerDraftStoreState( |
| 2174 | 2185 | state: ComposerDraftStoreState, |
| 2175 | 2186 | ): PersistedComposerDraftStoreState { |
| 2176 | 2187 | // 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. | |
| 2181 | 2192 | const mappedDraftKeys = new Set( |
| 2182 | 2193 | Object.values(state.logicalProjectDraftThreadKeyByLogicalProjectKey), |
| 2183 | 2194 | ); |
| ... | ... | @@ -2187,7 +2198,8 @@ export function partializeComposerDraftStoreState( |
| 2187 | 2198 | ([threadKey, draftThread]) => |
| 2188 | 2199 | mappedDraftKeys.has(threadKey) || |
| 2189 | 2200 | isDraftThreadPromoting(draftThread) || |
| 2190 | composerDraftHasUserContent(state.draftsByThreadKey[threadKey]), | |
| 2201 | composerDraftHasUserContent(state.draftsByThreadKey[threadKey]) || | |
| 2202 | draftThreadHasOpenPreview(draftThread), | |
| 2191 | 2203 | ) |
| 2192 | 2204 | .map(([threadKey]) => threadKey), |
| 2193 | 2205 | ); |
| ... | ... | @@ -2792,7 +2804,8 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2792 | 2804 | !isDraftThreadPromoting(previousDraftThread) && |
| 2793 | 2805 | !composerDraftHasUserContent( |
| 2794 | 2806 | state.draftsByThreadKey[previousThreadKeyForLogicalProject], |
| 2795 | ) | |
| 2807 | ) && | |
| 2808 | !draftThreadHasOpenPreview(previousDraftThread) | |
| 2796 | 2809 | ) { |
| 2797 | 2810 | delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; |
| 2798 | 2811 | if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { |
apps/web/src/rightPanelStore.ts+14| ... | ... | @@ -121,6 +121,13 @@ export interface ThreadPanelVisibility { |
| 121 | 121 | |
| 122 | 122 | interface RightPanelStoreState { |
| 123 | 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 | 131 | threadPanelVisibilityByThreadKey: Record<string, ThreadPanelVisibility>; |
| 125 | 132 | /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */ |
| 126 | 133 | userActionRevisionByThreadKey: Record<string, number>; |
| ... | ... | @@ -590,6 +597,13 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 590 | 597 | persist( |
| 591 | 598 | (set, get) => ({ |
| 592 | 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 | 607 | threadPanelVisibilityByThreadKey: {}, |
| 594 | 608 | userActionRevisionByThreadKey: {}, |
| 595 | 609 | closeRevisionByThreadKey: {}, |
apps/web/src/routes/__root.tsx+2| ... | ... | @@ -27,6 +27,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall |
| 27 | 27 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; |
| 28 | 28 | import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; |
| 29 | 29 | import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; |
| 30 | import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkCoordinator"; | |
| 30 | 31 | import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; |
| 31 | 32 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| 32 | 33 | import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; |
| ... | ... | @@ -228,6 +229,7 @@ function RootRouteView() { |
| 228 | 229 | > |
| 229 | 230 | {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} |
| 230 | 231 | {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null} |
| 232 | {primaryEnvironmentAuthenticated ? <DesktopWebLinkCoordinator /> : null} | |
| 231 | 233 | {isElectron ? <RunningThreadKeepAlive /> : null} |
| 232 | 234 | <RelayClientInstallDialog /> |
| 233 | 235 | <ConnectOnboardingDialog /> |
docs/README.md+1| ... | ... | @@ -14,6 +14,7 @@ |
| 14 | 14 | - [SnapShots](./user/snap-shot.md) |
| 15 | 15 | - [Visual replies](./user/html-renders.md) |
| 16 | 16 | - [Import browser sessions](./user/browser-import.md) |
| 17 | - [Use T3 Code as your default browser](./user/default-browser.md) | |
| 17 | 18 | - [Devices](./user/devices.md) |
| 18 | 19 | - [Usage and limits](./user/usage.md) |
| 19 | 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 | ||
| 3 | On macOS, the desktop app can be your default web browser. Links you open from other apps then | |
| 4 | open in T3 Code. When the thread on screen shows its browser panel, the link opens there as a new | |
| 5 | tab. Otherwise it starts a new thread without a project, with the browser panel maximized on the | |
| 6 | page, so you can ask an agent about it right away. | |
| 7 | ||
| 8 | Choose **T3 Code** under **System Settings → Desktop & Dock → Default web browser**. To go back, | |
| 9 | choose another browser there. | |
| 10 | ||
| 11 | HTML files you open from Finder open the same way. | |
| 12 | ||
| 13 | A 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 | 1246 | uninstall: () => Promise<DesktopCliCommandState>; |
| 1247 | 1247 | }; |
| 1248 | 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 | 1257 | appActivation?: { |
| 1250 | 1258 | setReady: (ready: boolean) => Promise<void>; |
| 1251 | 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 | 698 | assert.deepStrictEqual((linux.linux as Record<string, unknown>).protocols, [ |
| 699 | 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 | 716 | assert.deepStrictEqual(linux.toolsets, { appimage: "1.0.3" }); |
| 702 | 717 | assert.notProperty(mac, "toolsets"); |
| 703 | 718 | assert.notProperty(win, "toolsets"); |
| ... | ... | @@ -2082,6 +2097,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 2082 | 2097 | assert.match(String(mac.sign), /[\\/]scripts[\\/]sign-macos\.ts$/); |
| 2083 | 2098 | assert.deepStrictEqual(mac.protocols, [ |
| 2084 | 2099 | { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, |
| 2100 | { name: "Web site URL", schemes: ["http", "https"], role: "Viewer" }, | |
| 2085 | 2101 | ]); |
| 2086 | 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 | 2813 | extendInfo: { |
| 2814 | 2814 | NSScreenCaptureUsageDescription: |
| 2815 | 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 | 2827 | protocols: [ |
| 2818 | 2828 | { |
| 2819 | 2829 | name: "T3 Code", |
| 2820 | 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 | 2840 | ...(signed ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}), |
| 2824 | 2841 | ...(macPasskeySigning |