feat(desktop): register t3:// OS protocol handler for thread deep links - #2424
feat(desktop): register t3:// OS protocol handler for thread deep links#2424davidmashburn wants to merge 3 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR introduces a new OS protocol handler feature with cross-cutting changes to desktop lifecycle, IPC, and frontend navigation. Multiple high-severity unresolved findings identify fundamental issues including cold-start timing races, missing Linux support, and potential passkey signing breakage that require human attention. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
|
Trying to integrate with the T3 code app and deep linking (the ability to focus specific chats programmatically specifically) would be a big help. |
c1e8393 to
75a2103
Compare
|
Branch update: rebased onto current No server/orchestration changes in this revision — those are split out to #3642. |
| void navigate({ | ||
| to: "/$environmentId/$threadId", | ||
| params: { environmentId, threadId }, | ||
| }); |
There was a problem hiding this comment.
Deep link ignores thread environment
Medium Severity
onOpenThread navigates using only the active or primary environment id, not the environment that owns the thread. The codebase already exposes findThreadRef(threadId) for cross-environment lookup, but the new handler never uses it, so links to threads in another environment can land on / after the thread route check fails.
Reviewed by Cursor Bugbot for commit 75a2103. Configure here.
There was a problem hiding this comment.
🟡 Medium
t3code/scripts/build-desktop-artifact.ts
Line 1314 in 75a2103
resolveDesktopBuildAppId now returns distinct appId values for the alpha and nightly channels, so both builds can be installed side-by-side. However, both channels still share the same Electron userData path (.../.t3/userdata/t3code), so they read and write the same profile, settings, and update-metadata files. Running both builds simultaneously causes them to overwrite each other's persisted state and auto-update data. Consider making the userDataDirName (or the stateDir) channel-specific — e.g. t3code-nightly for nightly builds — so each channel gets an isolated data directory.
🤖 Copy this AI Prompt to have your agent fix this:
In file @scripts/build-desktop-artifact.ts around line 1314:
`resolveDesktopBuildAppId` now returns distinct `appId` values for the `alpha` and `nightly` channels, so both builds can be installed side-by-side. However, both channels still share the same Electron `userData` path (`.../.t3/userdata/t3code`), so they read and write the same profile, settings, and update-metadata files. Running both builds simultaneously causes them to overwrite each other's persisted state and auto-update data. Consider making the `userDataDirName` (or the `stateDir`) channel-specific — e.g. `t3code-nightly` for nightly builds — so each channel gets an isolated data directory.
| void runPromise(handleDeepLinkUrl(url)); | ||
| }); | ||
|
|
||
| yield* electronApp.on( |
There was a problem hiding this comment.
🟡 Medium app/DesktopDeepLinks.ts:78
The second-instance handler is registered in registerEarly before the single-instance lock is acquired, so a t3:// link opened during startup on Windows/Linux launches a second app process instead of routing the URL to the starting instance. registerEarly attaches the second-instance listener early, but the lock (requestSingleInstanceLock) is only taken later during configure, leaving a window where the second-instance event is never emitted and the deep link is lost. Consider registering the second-instance listener only after the single-instance lock has been acquired, or document the startup ordering if this gap is intentional.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/app/DesktopDeepLinks.ts around line 78:
The `second-instance` handler is registered in `registerEarly` before the single-instance lock is acquired, so a `t3://` link opened during startup on Windows/Linux launches a second app process instead of routing the URL to the starting instance. `registerEarly` attaches the `second-instance` listener early, but the lock (`requestSingleInstanceLock`) is only taken later during `configure`, leaving a window where the second-instance event is never emitted and the deep link is lost. Consider registering the `second-instance` listener only after the single-instance lock has been acquired, or document the startup ordering if this gap is intentional.
|
|
||
| yield* appIdentity.configure; | ||
| yield* lifecycle.register; | ||
| yield* deepLinks.registerEarly; |
There was a problem hiding this comment.
🟡 Medium app/DesktopApp.ts:243
deepLinks.registerEarly is called after shellEnvironment.installIntoProcess, resolveUserDataPath, electronApp.setPath, desktopSettings.load, appIdentity.configure, and lifecycle.register. On a macOS cold start, Electron emits the open-url event very early — before this point — so a t3://... launch that triggers the initial open-url is dropped and the app opens without navigating to the deep link. Consider registering the open-url listener before these async initialization steps.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/app/DesktopApp.ts around line 243:
`deepLinks.registerEarly` is called after `shellEnvironment.installIntoProcess`, `resolveUserDataPath`, `electronApp.setPath`, `desktopSettings.load`, `appIdentity.configure`, and `lifecycle.register`. On a macOS cold start, Electron emits the `open-url` event very early — before this point — so a `t3://...` launch that triggers the initial `open-url` is dropped and the app opens without navigating to the deep link. Consider registering the `open-url` listener before these async initialization steps.
| }); | ||
| } | ||
|
|
||
| const launchThreadId = findThreadDeepLinkInArgv(process.argv); |
There was a problem hiding this comment.
🟡 Medium app/DesktopDeepLinks.ts:106
On startup, configure re-parses process.argv and treats any preserved t3://... argument as a fresh deep link. The app's own relaunch path restarts packaged builds with args: process.argv.slice(1), so if the app was originally launched from a t3:// deep link, subsequent relaunches (for example WSL/server-exposure changes) carry the stale URL forward and reopen that old thread unexpectedly. Consider checking only argv that was not carried over from a prior launch (for example the OS-provided launch args from the first instance), or stripping known deep-link arguments before relaunching.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/app/DesktopDeepLinks.ts around line 106:
On startup, `configure` re-parses `process.argv` and treats any preserved `t3://...` argument as a fresh deep link. The app's own relaunch path restarts packaged builds with `args: process.argv.slice(1)`, so if the app was originally launched from a `t3://` deep link, subsequent relaunches (for example WSL/server-exposure changes) carry the stale URL forward and reopen that old thread unexpectedly. Consider checking only argv that was not carried over from a prior launch (for example the OS-provided launch args from the first instance), or stripping known deep-link arguments before relaunching.
| ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); | ||
| }; | ||
| }, | ||
| onOpenThread: (listener) => { |
There was a problem hiding this comment.
🟠 High src/preload.ts:119
The onOpenThread listener is registered during useEffect in AppSidebarLayout, which runs after the renderer has loaded. The main process emits the thread-open event on did-finish-load, so a t3://thread/... deep link opened during cold start fires before this listener exists and is permanently dropped. Consider having the main process buffer early thread-open events (or replay the last one) so a late-subscribing renderer still receives them.
Also found in 1 other location(s)
apps/web/src/components/AppSidebarLayout.tsx:101
AppSidebarLayoutregisterswindow.desktopBridge.onOpenThreadinsideuseEffect, so the listener is attached only after the initial React commit. On a cold start from at3://thread/...link, the main process flushes the queued deep link ondid-finish-loadand the preload bridge forwards it immediately, but there is no replay/buffer inpreload.ts. That means the firstOPEN_THREAD_CHANNELevent can fire before this effect runs, and the deep link is silently dropped instead of navigating to the thread.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/preload.ts around line 119:
The `onOpenThread` listener is registered during `useEffect` in `AppSidebarLayout`, which runs after the renderer has loaded. The main process emits the thread-open event on `did-finish-load`, so a `t3://thread/...` deep link opened during cold start fires before this listener exists and is permanently dropped. Consider having the main process buffer early thread-open events (or replay the last one) so a late-subscribing renderer still receives them.
Also found in 1 other location(s):
- apps/web/src/components/AppSidebarLayout.tsx:101 -- `AppSidebarLayout` registers `window.desktopBridge.onOpenThread` inside `useEffect`, so the listener is attached only after the initial React commit. On a cold start from a `t3://thread/...` link, the main process flushes the queued deep link on `did-finish-load` and the preload bridge forwards it immediately, but there is no replay/buffer in `preload.ts`. That means the first `OPEN_THREAD_CHANNEL` event can fire before this effect runs, and the deep link is silently dropped instead of navigating to the thread.
75a2103 to
9cf9ae2
Compare
| const existingWindow = yield* currentMainWindow; | ||
| if (Option.isNone(existingWindow)) { | ||
| yield* Ref.set(pendingOpenThreadIdRef, Option.some(threadId)); | ||
| yield* createMainIfBackendReady; |
There was a problem hiding this comment.
🟡 Medium window/DesktopWindow.ts:950
Concurrent deep links can create duplicate main windows during cold startup: each openThread call observes no window and createMainIfBackendReady starts a separate createWindow() before either call registers its result. Serialize these requests or guard creation with a shared in-flight effect so only one main window is created and all pending thread IDs are routed through it.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/window/DesktopWindow.ts around line 950:
Concurrent deep links can create duplicate main windows during cold startup: each `openThread` call observes no window and `createMainIfBackendReady` starts a separate `createWindow()` before either call registers its result. Serialize these requests or guard creation with a shared in-flight effect so only one main window is created and all pending thread IDs are routed through it.
| }); | ||
| const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; | ||
| const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | ||
| const legacyUserDataDirName = branding.displayName; |
There was a problem hiding this comment.
🟠 High app/DesktopEnvironment.ts:182
Nightly upgrades now look for T3 Code (Nightly) instead of the existing T3 Code (Alpha) legacy directory, so users with that prior installation fall back to the fresh t3code directory and appear signed out with their desktop state/settings missing. Preserve the legacy Alpha name for non-development builds so the existing data can still be discovered.
| const legacyUserDataDirName = branding.displayName; | |
| const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; |
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/app/DesktopEnvironment.ts around line 182:
Nightly upgrades now look for `T3 Code (Nightly)` instead of the existing `T3 Code (Alpha)` legacy directory, so users with that prior installation fall back to the fresh `t3code` directory and appear signed out with their desktop state/settings missing. Preserve the legacy Alpha name for non-development builds so the existing data can still be discovered.
| } | ||
|
|
||
| send(); | ||
| }; |
There was a problem hiding this comment.
Cold-start deep links drop IPC
High Severity
Cold-start t3://thread/... delivery sends desktop:open-thread on did-finish-load, but the renderer only listens after AppSidebarLayout mounts and its useEffect runs. Router beforeLoad can still be in flight then, and that layout is not mounted at all until auth succeeds, so the IPC event is dropped. pendingThreadIdRef only helps after a listener already ran and the environment id is missing.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 9cf9ae2. Configure here.
| { | ||
| name: "T3 Code", | ||
| schemes: ["t3code", "t3code-dev"], | ||
| schemes: ["t3", "t3code", "t3code-dev"], |
There was a problem hiding this comment.
Linux never claims t3://
High Severity
Thread links use the t3 scheme, but Linux still only registers t3code in the hidden URL-handler .desktop file, xdg-mime, and electron-builder linux.protocols. Existing comments already note that setAsDefaultProtocolClient cannot claim schemes for AppImage builds, so t3://thread/... never reaches second-instance on packaged Linux.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 9cf9ae2. Configure here.
| return resolveWebAssetBrandForChannel(resolveDesktopUpdateChannel(version)); | ||
| } | ||
|
|
||
| // Keep in sync with apps/desktop/src/updates/updateChannels.ts. |
There was a problem hiding this comment.
Passkey app id left stale
High Severity
Packaged appId is now com.t3tools.t3code.alpha or .nightly, but resolveMacPasskeySigningConfiguration still writes entitlements as TEAMID.com.t3tools.t3code. Signed macOS builds then have a bundle id that does not match com.apple.application-identifier or the embedded provisioning profile, which breaks signing and passkeys.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 9cf9ae2. Configure here.
| displayName, | ||
| appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => | ||
| isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", | ||
| resolveDesktopAppBundleId({ isDevelopment, appVersion: input.appVersion }), |
There was a problem hiding this comment.
Channels still share userData lock
Medium Severity
Bundle ids were split so Alpha and Nightly can run together and each receive t3:// links, but userDataDirName is still t3code for every non-dev build. Electron’s single-instance lock follows that directory, so a fresh Alpha and Nightly still cannot both stay running, and the second launch is swallowed by whichever instance holds the lock.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 9cf9ae2. Configure here.
9cf9ae2 to
26cff8f
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
There are 8 total unresolved issues (including 5 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 26cff8f. Configure here.
| yield* logDeepLinkError("failed to register default protocol client", { | ||
| scheme: DESKTOP_THREAD_DEEP_LINK_SCHEME, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Linux never claims the t3 scheme
Medium Severity
t3 is added to macOS CFBundleURLTypes and registered via setAsDefaultProtocolClient, but Linux packaging still advertises only t3code / t3code-dev, and DesktopLinuxUrlHandler still xdg-mime-claims only getDesktopScheme(). On AppImage that Electron API does not bind the handler, so t3:// never launches or focuses the Linux app.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 26cff8f. Configure here.
| }); | ||
| const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; | ||
| const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | ||
| const legacyUserDataDirName = branding.displayName; |
There was a problem hiding this comment.
Nightly loses legacy userData path
Medium Severity
legacyUserDataDirName is now branding.displayName. Nightly therefore looks for T3 Code (Nightly) instead of the previous non-dev legacy folder T3 Code (Alpha). Nightly installs still using that Alpha-named directory are treated as fresh (t3code) and drop existing session/settings data.
Reviewed by Cursor Bugbot for commit 26cff8f. Configure here.
|
|
||
| // 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"; |
There was a problem hiding this comment.
Channels still share single-instance lock
Medium Severity
Distinct bundle ids are described as letting Alpha and Nightly run together without single-instance conflicts so each can own t3://. Electron’s lock is still scoped to userData, and both non-dev channels keep userDataDirName t3code. The second channel still quits as a duplicate instance and forwards into the first.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 26cff8f. Configure here.
Replay of d0a2c94 onto upstream (origin/main). Changes from original: - Merged deep-link wiring with upstream desktop: kept linuxUrlHandler, fullscreen IPC, and zoomMain alongside new OPEN_THREAD channel, DesktopDeepLinks, and openThread window routing. - AppSidebarLayout: kept upstream resizable sidebar/fullscreen chrome; added onOpenThread navigation only. - Reverted redundant server orchestration hunks already covered by 8209478 (assistant message ID scoping). - build-desktop-artifact: kept upstream nightly branding test plus fork publish auto-detection expectations. Co-authored-by: Cursor <cursoragent@cursor.com>
Replay of 7317944 onto upstream (105cd5e). - Kept upstream electron-launcher LAUNCHER_VERSION=15; applied fork alpha bundle id and t3+t3code schemes. - Merged DesktopEnvironment: upstream DesktopStatePaths + fork resolveDesktopAppBundleId; legacyUserDataDirName uses branding.displayName. - Merged AppSidebarLayout: kept upstream resizable sidebar, settings nav, fullscreen traffic-light inset; added fork deep-link navigation queue (pendingThreadIdRef, onOpenThread). - Kept upstream resolveDesktopWebAssetBrand and added fork resolveDesktopBuildAppId in build-desktop-artifact.ts. - DesktopDeepLinks, updateChannels, and tests applied cleanly from original. Co-authored-by: Cursor <cursoragent@cursor.com>
Upstream added DesktopLifecycle.test.ts after the fork point, and its DesktopWindow mock predates the openThread member the t3:// deep-link work adds to that service. The replay carried the interface change without the new mock, so apps/desktop failed to typecheck on both the integration branch and the deep-link PR branch. Co-authored-by: Cursor <cursoragent@cursor.com>
26cff8f to
edd18aa
Compare
|
Note 🤖 GPT-5.6 Sol responding on behalf of Theo We're closing this PR as we clean up the T3 Code backlog. Thank you for taking the time to put this together. This branch passes only a thread ID through its deep link. #8246 carries both the environment and thread IDs and waits for the renderer, which avoids opening the wrong environment. If you believe we closed this in error, please reopen the PR and leave a comment explaining what we missed. |


What Changed
Adds a
t3://thread/<threadId>OS-level protocol handler to the Electron desktop app.apps/desktop/src/main.ts: registersopen-url(macOS) andsecond-instance(Win/Linux) listeners beforewhenReady;handleDeepLink()parses the URL and sendsdesktop:open-threadto the renderer viawebContents.send;pendingDeepLinkThreadIdqueues deep links that arrive before the window finishes loading;setAsDefaultProtocolClient('t3')called inwhenReadyapps/desktop/src/preload.ts: exposesonOpenThreadover the context bridge (same pattern asonMenuAction)packages/contracts/src/ipc.ts: addsonOpenThreadto theDesktopBridgeinterfaceapps/web/src/components/AppSidebarLayout.tsx: subscribes toonOpenThreadand navigates to/$environmentId/$threadIdusing TanStack Routerscripts/build-desktop-artifact.ts: addsprotocols: [{name:"T3 Code", schemes:["t3"]}]to the mac electron-builder config soCFBundleURLTypesis written into the packagedInfo.plistlocalApi.test.ts,SettingsPanels.browser.tsx): stubonOpenThread: () => () => {}addedWhy
Clicking a thread link (e.g. from Slack or the terminal) currently opens a browser tab that hits the pairing screen. With this change,
t3://thread/<id>links route directly to the correct thread inside the already-running desktop app — or launch it if it's closed.Verified end-to-end in dev:
open "t3://thread/<id>"in the terminal fired theopen-urlevent,handleDeepLinkparsed it correctly, andwebContents.send("desktop:open-thread", threadId)was confirmed in the Electron main-process logs.UI Changes
N/A — no visual changes. Navigation behavior only.
Checklist
Note
High Risk
Packaged alpha builds change bundle id from com.t3tools.t3code to com.t3tools.t3code.alpha, which can break in-place auto-updates and alter which app macOS routes t3:// links to; protocol registration and second-instance argv handling are security-sensitive entry points.
Overview
Adds
t3://thread/<threadId>handling so OS links open the right thread in the desktop app instead of a browser.A new
DesktopDeepLinksservice registersopen-url/second-instanceearly, parses URLs viathreadDeepLink, registers thet3protocol (macOS pinssetAsDefaultProtocolClientto the running executable), and callsDesktopWindow.openThread, which IPCsdesktop:open-threadto the renderer (with pending IDs until the main window loads). The web shell listens throughdesktopBridge.onOpenThreadand navigates to/$environmentId/$threadId, queuing the thread id until an environment id is known.Alpha / nightly / dev builds now get distinct bundle ids via
resolveDesktopAppBundleId(alpha usescom.t3tools.t3code.alphainstead ofcom.t3tools.t3code), macOS/electron-builder protocol lists includet3, and local artifact builds setpublish: nullso electron-builder does not auto-detect GitHub publish fromGH_TOKEN.Reviewed by Cursor Bugbot for commit 26cff8f. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Register
t3://protocol handler for thread deep links in desktop appDesktopDeepLinksservice that wires Electronopen-url,second-instance, and argv events to parset3://thread/<id>URLs and open the requested thread viaDesktopWindow.openThreaddesktop:open-threadIPC channel so the main process can tell the renderer to navigate to a threadonOpenThreadand navigates to/$environmentId/$threadId, deferring until an environment id is availablenightlyvsalpha) and registers thet3scheme on macOS; local builds setpublish = nullto disable auto-detectioncom.t3tools.t3codetocom.t3tools.t3code.alphain electron-launcher.mjs; WindowsappUserModelIdnow varies by channel viaresolveDesktopAppBundleIdin updateChannels.tsMacroscope summarized edd18aa.