From 75a2103accd4a95329e5cb502fb68f03606b5ab0 Mon Sep 17 00:00:00 2001 From: David Mashburn Date: Tue, 7 Jul 2026 18:18:08 -0400 Subject: [PATCH] feat(desktop): add t3:// thread deep links with cold-start queueing Port thread deep-link handling into the Effect-based desktop stack so t3://thread/ opens the right conversation. Queue renderer navigation until the active environment is ready, route Alpha vs Nightly via distinct bundle IDs, and disable electron-builder publish auto-detection when GH_TOKEN is set so local artifact builds complete reliably. Co-authored-by: Cursor --- apps/desktop/scripts/electron-launcher.mjs | 4 +- apps/desktop/src/app/DesktopApp.ts | 4 + apps/desktop/src/app/DesktopDeepLinks.ts | 114 ++++++++++++++++++ apps/desktop/src/app/DesktopEnvironment.ts | 6 +- .../src/backend/DesktopBackendPool.test.ts | 1 + .../src/deeplink/threadDeepLink.test.ts | 29 +++++ apps/desktop/src/deeplink/threadDeepLink.ts | 30 +++++ apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 11 ++ .../src/updates/updateChannels.test.ts | 28 +++++ apps/desktop/src/updates/updateChannels.ts | 24 ++++ .../src/window/DesktopApplicationMenu.test.ts | 1 + apps/desktop/src/window/DesktopWindow.ts | 51 +++++++- apps/web/src/components/AppSidebarLayout.tsx | 50 +++++++- packages/contracts/src/ipc.ts | 1 + scripts/build-desktop-artifact.test.ts | 20 ++- scripts/build-desktop-artifact.ts | 15 ++- 18 files changed, 380 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/app/DesktopDeepLinks.ts create mode 100644 apps/desktop/src/deeplink/threadDeepLink.test.ts create mode 100644 apps/desktop/src/deeplink/threadDeepLink.ts create mode 100644 apps/desktop/src/updates/updateChannels.test.ts diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 081ef903a06..cbe08ae7113 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -18,8 +18,8 @@ const devBundleIdSuffix = NodePath.basename(repoRoot) export const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; export const APP_BUNDLE_ID = isDevelopment ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` - : "com.t3tools.t3code"; -const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; + : "com.t3tools.t3code.alpha"; +const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3", "t3code"]; const LAUNCHER_VERSION = 12; const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns"); const developmentMacIconPngPath = NodePath.join( diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index fd86c5f05d2..1e0b459f634 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -12,6 +12,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopDeepLinks from "./DesktopDeepLinks.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; @@ -220,6 +221,7 @@ const startup = Effect.gen(function* () { const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; const electronApp = yield* ElectronApp.ElectronApp; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; const clerk = yield* DesktopClerk.DesktopClerk; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; @@ -238,6 +240,7 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* lifecycle.register; + yield* deepLinks.registerEarly; yield* clerk.configure; yield* electronApp.whenReady.pipe( @@ -247,6 +250,7 @@ const startup = Effect.gen(function* () { yield* logStartupInfo("app ready"); yield* appIdentity.configure; yield* applicationMenu.configure; + yield* deepLinks.configure; yield* updates.configure; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); diff --git a/apps/desktop/src/app/DesktopDeepLinks.ts b/apps/desktop/src/app/DesktopDeepLinks.ts new file mode 100644 index 00000000000..ab610b93de4 --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLinks.ts @@ -0,0 +1,114 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; + +import type * as Electron from "electron"; + +import { makeComponentLogger } from "./DesktopObservability.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { + DESKTOP_THREAD_DEEP_LINK_SCHEME, + findThreadDeepLinkInArgv, + parseThreadDeepLinkUrl, +} from "../deeplink/threadDeepLink.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import type { DesktopWindowError } from "../window/DesktopWindow.ts"; + +type DesktopDeepLinkRuntimeServices = + | DesktopEnvironment.DesktopEnvironment + | DesktopWindow.DesktopWindow + | ElectronApp.ElectronApp; + +/** + * @effect-expect-leaking DesktopEnvironment | DesktopWindow | ElectronApp + */ +export class DesktopDeepLinks extends Context.Service< + DesktopDeepLinks, + { + readonly registerEarly: Effect.Effect< + void, + never, + Scope.Scope | DesktopDeepLinkRuntimeServices + >; + readonly configure: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopDeepLinks") {} + +const { logInfo: logDeepLinkInfo, logError: logDeepLinkError } = + makeComponentLogger("desktop-deeplink"); + +export const make = Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronApp = yield* ElectronApp.ElectronApp; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + const openThreadDeepLink = (threadId: string) => + Effect.gen(function* () { + yield* logDeepLinkInfo("open thread deep link", { threadId }); + yield* desktopWindow + .openThread(threadId) + .pipe( + Effect.catch((error: DesktopWindowError) => + logDeepLinkError("failed to open thread deep link", { threadId, error }), + ), + ); + }).pipe(Effect.withSpan("desktop.deeplink.openThread")); + + const handleDeepLinkUrl = (rawUrl: string) => + Effect.gen(function* () { + const threadId = parseThreadDeepLinkUrl(rawUrl); + if (Option.isNone(threadId)) { + return; + } + yield* openThreadDeepLink(threadId.value); + }).pipe(Effect.withSpan("desktop.deeplink.handleUrl")); + + return DesktopDeepLinks.of({ + registerEarly: Effect.gen(function* () { + yield* electronApp.on("open-url", (event: Electron.Event, url: string) => { + event.preventDefault(); + void runPromise(handleDeepLinkUrl(url)); + }); + + yield* electronApp.on( + "second-instance", + (_event: Electron.Event, argv: readonly string[]) => { + const launchUrl = findThreadDeepLinkInArgv(argv); + if (Option.isSome(launchUrl)) { + void runPromise(openThreadDeepLink(launchUrl.value)); + } + }, + ); + }).pipe(Effect.withSpan("desktop.deeplink.registerEarly")), + + configure: Effect.gen(function* () { + // Pin registration to this executable on macOS so Launch Services routes + // `t3://` links to the running channel (Alpha/Nightly) instead of whichever + // app happens to share the production bundle id. + const registered = yield* environment.platform === "darwin" + ? electronApp.setAsDefaultProtocolClient( + DESKTOP_THREAD_DEEP_LINK_SCHEME, + process.execPath, + [], + ) + : electronApp.setAsDefaultProtocolClient(DESKTOP_THREAD_DEEP_LINK_SCHEME); + if (!registered) { + yield* logDeepLinkError("failed to register default protocol client", { + scheme: DESKTOP_THREAD_DEEP_LINK_SCHEME, + }); + } + + const launchThreadId = findThreadDeepLinkInArgv(process.argv); + if (Option.isSome(launchThreadId)) { + yield* openThreadDeepLink(launchThreadId.value); + } + }).pipe(Effect.withSpan("desktop.deeplink.configure")), + }); +}); + +export const layer = Layer.effect(DesktopDeepLinks, make); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 061a9368c53..24cea070adb 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -13,7 +13,7 @@ import * as Path from "effect/Path"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; -import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; +import { isNightlyDesktopVersion, resolveDesktopAppBundleId } from "../updates/updateChannels.ts"; export interface MakeDesktopEnvironmentInput { readonly dirname: string; @@ -157,7 +157,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( const displayName = branding.displayName; const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata"); const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; - const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; + const legacyUserDataDirName = branding.displayName; const resourcesPath = input.resourcesPath; return DesktopEnvironment.of({ @@ -197,7 +197,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( branding, displayName, appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => - isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", + resolveDesktopAppBundleId({ isDevelopment, appVersion: input.appVersion }), ), linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 5e6a3f5164d..f4b36ea2701 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -77,6 +77,7 @@ function makePoolLayer( handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), + openThread: () => Effect.die("unexpected open thread"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), ), diff --git a/apps/desktop/src/deeplink/threadDeepLink.test.ts b/apps/desktop/src/deeplink/threadDeepLink.test.ts new file mode 100644 index 00000000000..eca42b4b0c9 --- /dev/null +++ b/apps/desktop/src/deeplink/threadDeepLink.test.ts @@ -0,0 +1,29 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Option from "effect/Option"; + +import { findThreadDeepLinkInArgv, parseThreadDeepLinkUrl } from "./threadDeepLink.ts"; + +describe("threadDeepLink", () => { + it("parses t3://thread/ URLs", () => { + assert.deepStrictEqual( + parseThreadDeepLinkUrl("t3://thread/thread-123"), + Option.some("thread-123"), + ); + }); + + it("rejects unrelated protocols and hosts", () => { + assert.isTrue(Option.isNone(parseThreadDeepLinkUrl("t3code://app/"))); + assert.isTrue(Option.isNone(parseThreadDeepLinkUrl("t3://settings/general"))); + assert.isTrue(Option.isNone(parseThreadDeepLinkUrl("https://example.com/thread/abc"))); + }); + + it("finds deep links in process argv", () => { + assert.deepStrictEqual( + findThreadDeepLinkInArgv([ + "/Applications/T3 Code.app/Contents/MacOS/T3 Code", + "t3://thread/abc", + ]), + Option.some("abc"), + ); + }); +}); diff --git a/apps/desktop/src/deeplink/threadDeepLink.ts b/apps/desktop/src/deeplink/threadDeepLink.ts new file mode 100644 index 00000000000..f1e65580805 --- /dev/null +++ b/apps/desktop/src/deeplink/threadDeepLink.ts @@ -0,0 +1,30 @@ +import * as Option from "effect/Option"; + +/** OS-registered scheme for thread deep links (`t3://thread/`). */ +export const DESKTOP_THREAD_DEEP_LINK_SCHEME = "t3"; + +export function parseThreadDeepLinkUrl(rawUrl: string): Option.Option { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return Option.none(); + } + + if (parsed.protocol !== `${DESKTOP_THREAD_DEEP_LINK_SCHEME}:` || parsed.hostname !== "thread") { + return Option.none(); + } + + const threadId = parsed.pathname.replace(/^\/+/, ""); + return threadId.length > 0 ? Option.some(threadId) : Option.none(); +} + +export function findThreadDeepLinkInArgv(argv: readonly string[]): Option.Option { + for (const arg of argv) { + const threadId = parseThreadDeepLinkUrl(arg); + if (Option.isSome(threadId)) { + return threadId; + } + } + return Option.none(); +} diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index d28c507fa7f..144f3144ae4 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -4,6 +4,7 @@ export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const OPEN_THREAD_CHANNEL = "desktop:open-thread"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7a51700e0fd..e96d6d3720e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -34,6 +34,7 @@ import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfigurat import * as DesktopBackendPool from "./backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; +import * as DesktopDeepLinks from "./app/DesktopDeepLinks.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; import * as DesktopShutdown from "./app/DesktopShutdown.ts"; @@ -172,6 +173,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, DesktopShellEnvironment.layer, + DesktopDeepLinks.layer, desktopSshLayer, ).pipe( Layer.provideMerge(DesktopUpdates.layer), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4b5c0d2f656..09a0c5e9938 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -116,6 +116,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + onOpenThread: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, threadId: unknown) => { + if (typeof threadId !== "string") return; + listener(threadId); + }; + + ipcRenderer.on(IpcChannels.OPEN_THREAD_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.OPEN_THREAD_CHANNEL, wrappedListener); + }; + }, getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), setUpdateChannel: (channel) => ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), diff --git a/apps/desktop/src/updates/updateChannels.test.ts b/apps/desktop/src/updates/updateChannels.test.ts new file mode 100644 index 00000000000..cbda0dd382d --- /dev/null +++ b/apps/desktop/src/updates/updateChannels.test.ts @@ -0,0 +1,28 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { resolveDesktopAppBundleId, resolveDesktopBuildAppId } from "./updateChannels.ts"; + +describe("updateChannels bundle ids", () => { + it("uses dev, nightly, and alpha bundle ids per channel", () => { + assert.equal( + resolveDesktopAppBundleId({ isDevelopment: true, appVersion: "0.0.28" }), + "com.t3tools.t3code.dev", + ); + assert.equal( + resolveDesktopAppBundleId({ + isDevelopment: false, + appVersion: "0.0.28-nightly.20260702.1", + }), + "com.t3tools.t3code.nightly", + ); + assert.equal( + resolveDesktopAppBundleId({ isDevelopment: false, appVersion: "0.0.28" }), + "com.t3tools.t3code.alpha", + ); + assert.equal( + resolveDesktopBuildAppId("0.0.28-nightly.20260702.1"), + "com.t3tools.t3code.nightly", + ); + assert.equal(resolveDesktopBuildAppId("0.0.28"), "com.t3tools.t3code.alpha"); + }); +}); diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index 731910e441f..cc56c0891b3 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,6 +1,7 @@ import type { DesktopUpdateChannel } from "@t3tools/contracts"; const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; +const PRODUCTION_BUNDLE_ID = "com.t3tools.t3code"; export function isNightlyDesktopVersion(version: string): boolean { return NIGHTLY_VERSION_PATTERN.test(version); @@ -9,3 +10,26 @@ export function isNightlyDesktopVersion(version: string): boolean { export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; } + +export function resolveDesktopAppBundleId(input: { + readonly isDevelopment: boolean; + readonly appVersion: string; +}): string { + if (input.isDevelopment) { + return "com.t3tools.t3code.dev"; + } + + if (isNightlyDesktopVersion(input.appVersion)) { + return "com.t3tools.t3code.nightly"; + } + + // Alpha-channel builds use a distinct bundle id so they can run alongside + // Nightly and receive `t3://` deep links without single-instance conflicts. + return "com.t3tools.t3code.alpha"; +} + +export function resolveDesktopBuildAppId(version: string): string { + return resolveDesktopAppBundleId({ isDevelopment: false, appVersion: version }); +} + +export { PRODUCTION_BUNDLE_ID }; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index ba77292fdc8..6a3acafd0b4 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -77,6 +77,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), + openThread: () => Effect.void, syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b1dfabe7ab4..a9c9f4cd4f9 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -15,7 +15,7 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL, OPEN_THREAD_CHANNEL } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; const TITLEBAR_HEIGHT = 40; @@ -76,6 +76,7 @@ export class DesktopWindow extends Context.Service< // produce a stranded window pointing at nothing. readonly handleBackendNotReady: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; + readonly openThread: (threadId: string) => Effect.Effect; readonly syncAppearance: Effect.Effect; } >()("@t3tools/desktop/window/DesktopWindow") {} @@ -208,6 +209,7 @@ export const make = Effect.gen(function* () { // createMainIfBackendReady, which gates the post-readiness window // open in development and the macOS "activate without windows" path. const backendReadyRef = yield* Ref.make(false); + const pendingOpenThreadIdRef = yield* Ref.make>(Option.none()); // The transient "Connecting to WSL" splash window, tracked separately so it // is never mistaken for the real main window. const splashWindowRef = yield* Ref.make>(Option.none()); @@ -241,6 +243,36 @@ export const make = Effect.gen(function* () { const currentMainWindow = electronWindow.currentMainOrFirst.pipe(Effect.flatMap(withoutSplash)); const focusedMainWindow = electronWindow.focusedMainOrFirst.pipe(Effect.flatMap(withoutSplash)); + const sendOpenThreadToWindow = (targetWindow: Electron.BrowserWindow, threadId: string) => { + const send = () => { + if (targetWindow.isDestroyed()) return; + targetWindow.webContents.send(OPEN_THREAD_CHANNEL, threadId); + void runPromise(electronWindow.reveal(targetWindow)); + }; + + if (targetWindow.webContents.isLoadingMainFrame()) { + targetWindow.webContents.once("did-finish-load", send); + return; + } + + send(); + }; + + const flushPendingOpenThread = Effect.gen(function* () { + const pendingThreadId = yield* Ref.getAndSet(pendingOpenThreadIdRef, Option.none()); + if (Option.isNone(pendingThreadId)) { + return; + } + + const existingWindow = yield* currentMainWindow; + if (Option.isNone(existingWindow)) { + yield* Ref.set(pendingOpenThreadIdRef, pendingThreadId); + return; + } + + sendOpenThreadToWindow(existingWindow.value, pendingThreadId.value); + }); + const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< Electron.BrowserWindow, DesktopWindowError @@ -420,6 +452,7 @@ export const make = Effect.gen(function* () { clearDevelopmentLoadRetry(); developmentLoadRetryIndex = 0; window.setTitle(environment.displayName); + void runPromise(flushPendingOpenThread); }); window.webContents.on( "did-fail-load", @@ -610,6 +643,22 @@ export const make = Effect.gen(function* () { send(); }), + openThread: Effect.fn("desktop.window.openThread")(function* (threadId) { + yield* Effect.annotateCurrentSpan({ threadId }); + const existingWindow = yield* currentMainWindow; + if (Option.isNone(existingWindow)) { + yield* Ref.set(pendingOpenThreadIdRef, Option.some(threadId)); + yield* createMainIfBackendReady; + const windowAfterBootstrap = yield* currentMainWindow; + if (Option.isSome(windowAfterBootstrap)) { + sendOpenThreadToWindow(windowAfterBootstrap.value, threadId); + yield* Ref.set(pendingOpenThreadIdRef, Option.none()); + } + return; + } + + sendOpenThreadToWindow(existingWindow.value, threadId); + }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 0f1a8f9d429..d5e52d81800 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,11 +1,12 @@ import { useAtomValue } from "@effect/atom-react"; -import { useEffect, type CSSProperties, type ReactNode } from "react"; +import { useEffect, useRef, type CSSProperties, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { isMacPlatform } from "../lib/utils"; -import { primaryServerKeybindingsAtom } from "../state/server"; +import { useActiveEnvironmentId } from "../state/entities"; +import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; import ThreadSidebar from "./Sidebar"; import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -55,11 +56,31 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const activeEnvironmentId = useActiveEnvironmentId(); + const serverConfig = useAtomValue(primaryServerConfigAtom); + const pendingThreadIdRef = useRef(null); const macosWindowControlsStyle = isElectron && isMacPlatform(navigator.platform) ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) : undefined; + const resolveEnvironmentId = () => + activeEnvironmentId ?? serverConfig?.environment.environmentId ?? null; + + useEffect(() => { + const environmentId = resolveEnvironmentId(); + const pendingThreadId = pendingThreadIdRef.current; + if (!environmentId || !pendingThreadId) { + return; + } + + pendingThreadIdRef.current = null; + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId: pendingThreadId }, + }); + }, [activeEnvironmentId, navigate, serverConfig]); + useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; if (typeof onMenuAction !== "function") { @@ -77,6 +98,31 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }; }, [navigate]); + useEffect(() => { + const onOpenThread = window.desktopBridge?.onOpenThread; + if (typeof onOpenThread !== "function") { + return; + } + + const unsubscribe = onOpenThread((threadId) => { + const environmentId = resolveEnvironmentId(); + if (!environmentId) { + pendingThreadIdRef.current = threadId; + return; + } + + pendingThreadIdRef.current = null; + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + }); + }); + + return () => { + unsubscribe?.(); + }; + }, [navigate, activeEnvironmentId, serverConfig]); + return ( Promise; openExternal: (url: string) => Promise; onMenuAction: (listener: (action: string) => void) => () => void; + onOpenThread: (listener: (threadId: string) => void) => () => void; getUpdateState: () => Promise; setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; checkForUpdate: () => Promise; diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index c7e33f6fc6f..45f17c26fee 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -101,6 +101,22 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); }); + it.effect("disables publish auto-detection for local artifact builds", () => + Effect.gen(function* () { + const config = yield* createBuildConfig( + "mac", + "dmg", + "1.2.3", + false, + false, + undefined, + undefined, + ).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))); + + assert.strictEqual(config.publish, null); + }), + ); + it.effect("resolves GitHub desktop publish config from Effect config", () => Effect.gen(function* () { const latestConfig = yield* resolveGitHubPublishConfig("latest").pipe( @@ -401,11 +417,11 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); const mac = config.mac as Record; - assert.equal(config.appId, "com.t3tools.t3code"); + assert.equal(config.appId, "com.t3tools.t3code.alpha"); assert.equal(mac.entitlements, "/tmp/entitlements.mac.plist"); assert.equal(mac.provisioningProfile, "/tmp/t3code.provisionprofile"); assert.deepStrictEqual(mac.protocols, [ - { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, + { name: "T3 Code", schemes: ["t3", "t3code", "t3code-dev"] }, ]); }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 58153efe8a5..c9bcc69ed8b 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1310,6 +1310,13 @@ export function resolveDesktopUpdateChannel(version: string): "latest" | "nightl return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest"; } +// Keep in sync with apps/desktop/src/updates/updateChannels.ts. +export function resolveDesktopBuildAppId(version: string): string { + return resolveDesktopUpdateChannel(version) === "nightly" + ? "com.t3tools.t3code.nightly" + : "com.t3tools.t3code.alpha"; +} + export function resolveDesktopBuildIconAssets(version: string): DesktopBuildIconAssets { if (resolveDesktopUpdateChannel(version) === "nightly") { return { @@ -1351,7 +1358,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( | undefined, ) { const buildConfig: Record = { - appId: DESKTOP_APP_ID, + appId: resolveDesktopBuildAppId(version), productName: resolveDesktopProductName(version), artifactName: "T3-Code-${version}-${arch}.${ext}", directories: { @@ -1383,6 +1390,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( url: resolveMockUpdateServerUrl(mockUpdateServerPort), }, ]; + } else { + // Prevent electron-builder from inferring GitHub publish from GH_TOKEN when + // building local artifacts with `--publish never`. + buildConfig.publish = null; } if (platform === "mac") { @@ -1393,7 +1404,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( protocols: [ { name: "T3 Code", - schemes: ["t3code", "t3code-dev"], + schemes: ["t3", "t3code", "t3code-dev"], }, ], ...(macPasskeySigning