diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c5507c6fb0..015ce234eb 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -102,6 +102,7 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +const OPEN_THREAD_CHANNEL = "desktop:open-thread"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); @@ -216,6 +217,7 @@ let backendListeningDetector: ServerListeningDetector | null = null; let restartAttempt = 0; let restartTimer: ReturnType | null = null; let isQuitting = false; +let pendingDeepLinkThreadId: string | null = null; let desktopProtocolRegistered = false; let aboutCommitHashCache: string | null | undefined; let desktopLogSink: RotatingFileSink | null = null; @@ -1106,6 +1108,35 @@ function revealWindow(window: BrowserWindow): void { window.focus(); } +function handleDeepLink(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return; + } + + if (parsed.protocol !== `${DESKTOP_SCHEME}:` || parsed.hostname !== "thread") { + return; + } + + const threadId = parsed.pathname.slice(1); // strip leading "/" + if (!threadId) { + return; + } + + writeDesktopLogHeader(`deep link open-thread threadId=${threadId}`); + + const window = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; + if (window && !window.isDestroyed()) { + revealWindow(window); + window.webContents.send(OPEN_THREAD_CHANNEL, threadId); + } else { + // App is still launching; dispatch once the window finishes loading. + pendingDeepLinkThreadId = threadId; + } +} + function emitUpdateState(): void { for (const window of BrowserWindow.getAllWindows()) { if (window.isDestroyed()) continue; @@ -1997,6 +2028,10 @@ function createWindow(): BrowserWindow { window.webContents.on("did-finish-load", () => { window.setTitle(APP_DISPLAY_NAME); emitUpdateState(); + if (pendingDeepLinkThreadId !== null) { + window.webContents.send(OPEN_THREAD_CHANNEL, pendingDeepLinkThreadId); + pendingDeepLinkThreadId = null; + } }); // On Linux/Wayland with `show: false`, Electron's `ready-to-show` only @@ -2113,12 +2148,37 @@ app.on("before-quit", () => { restoreStdIoCapture?.(); }); +// macOS: fired when the OS routes a t3:// URL to this process (already running or cold-start). +// Must be registered before whenReady() to avoid missing early events. +app.on("open-url", (event, url) => { + event.preventDefault(); + handleDeepLink(url); +}); + +// Windows / Linux: a second instance is launched with the URL in argv. +// requestSingleInstanceLock redirects that second instance here instead. +if (!app.requestSingleInstanceLock()) { + app.quit(); +} else { + app.on("second-instance", (_event, argv) => { + const url = argv.find((arg) => arg.startsWith(`${DESKTOP_SCHEME}://`)); + if (url) { + handleDeepLink(url); + } + const window = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; + if (window) { + revealWindow(window); + } + }); +} + app .whenReady() .then(() => { writeDesktopLogHeader("app ready"); configureAppIdentity(); configureApplicationMenu(); + app.setAsDefaultProtocolClient(DESKTOP_SCHEME); registerDesktopProtocol(); configureAutoUpdater(); void bootstrap().catch((error) => { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index a675604872..8867a299b9 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -7,6 +7,7 @@ const SET_THEME_CHANNEL = "desktop:set-theme"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; +const OPEN_THREAD_CHANNEL = "desktop:open-thread"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; @@ -85,4 +86,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(UPDATE_STATE_CHANNEL, wrappedListener); }; }, + onOpenThread: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, threadId: unknown) => { + if (typeof threadId !== "string") return; + listener(threadId); + }; + + ipcRenderer.on(OPEN_THREAD_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(OPEN_THREAD_CHANNEL, wrappedListener); + }; + }, } satisfies DesktopBridge); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index b1ce57235a..7af06e5ac6 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -7,12 +7,14 @@ import { clearShortcutModifierState, syncShortcutModifierStateFromKeyboardEvent, } from "../shortcutModifierState"; +import { useStore } from "../store"; const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const activeEnvironmentId = useStore((state) => state.activeEnvironmentId); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { @@ -36,6 +38,27 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }; }, []); + useEffect(() => { + const onOpenThread = window.desktopBridge?.onOpenThread; + if (typeof onOpenThread !== "function") { + return; + } + + const unsubscribe = onOpenThread((threadId) => { + if (!activeEnvironmentId) { + return; + } + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: activeEnvironmentId, threadId }, + }); + }); + + return () => { + unsubscribe?.(); + }; + }, [navigate, activeEnvironmentId]); + useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; if (typeof onMenuAction !== "function") { diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index b508b29b77..fa29a12f7f 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -313,6 +313,7 @@ const createDesktopBridgeStub = (overrides?: { showContextMenu: vi.fn().mockResolvedValue(null), openExternal: vi.fn().mockResolvedValue(true), onMenuAction: () => () => {}, + onOpenThread: () => () => {}, getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), setUpdateChannel: overrides?.setUpdateChannel ?? diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index c361cbd787..e35318ff89 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -185,6 +185,7 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg showContextMenu: async () => null, openExternal: async () => true, onMenuAction: () => () => undefined, + onOpenThread: () => () => undefined, getUpdateState: async () => { throw new Error("getUpdateState not implemented in test"); }, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a1abc0fa4a..0cb4f29801 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -169,6 +169,7 @@ export interface DesktopBridge { ) => 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.ts b/scripts/build-desktop-artifact.ts index 74e8bed0cb..55b4e4ded5 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -592,6 +592,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( target: target === "dmg" ? [target, "zip"] : [target], icon: "icon.icns", category: "public.app-category.developer-tools", + protocols: [{ name: "T3 Code", schemes: ["t3"] }], }; }