Skip to content

feat(desktop): register t3:// OS protocol handler for thread deep links#2424

Open
davidmashburn wants to merge 1 commit into
pingdotgg:mainfrom
davidmashburn:feat/t3-deep-link-protocol
Open

feat(desktop): register t3:// OS protocol handler for thread deep links#2424
davidmashburn wants to merge 1 commit into
pingdotgg:mainfrom
davidmashburn:feat/t3-deep-link-protocol

Conversation

@davidmashburn

@davidmashburn davidmashburn commented Apr 30, 2026

Copy link
Copy Markdown

What Changed

Adds a t3://thread/<threadId> OS-level protocol handler to the Electron desktop app.

  • apps/desktop/src/main.ts: registers open-url (macOS) and second-instance (Win/Linux) listeners before whenReady; handleDeepLink() parses the URL and sends desktop:open-thread to the renderer via webContents.send; pendingDeepLinkThreadId queues deep links that arrive before the window finishes loading; setAsDefaultProtocolClient('t3') called in whenReady
  • apps/desktop/src/preload.ts: exposes onOpenThread over the context bridge (same pattern as onMenuAction)
  • packages/contracts/src/ipc.ts: adds onOpenThread to the DesktopBridge interface
  • apps/web/src/components/AppSidebarLayout.tsx: subscribes to onOpenThread and navigates to /$environmentId/$threadId using TanStack Router
  • scripts/build-desktop-artifact.ts: adds protocols: [{name:"T3 Code", schemes:["t3"]}] to the mac electron-builder config so CFBundleURLTypes is written into the packaged Info.plist
  • test mocks (localApi.test.ts, SettingsPanels.browser.tsx): stub onOpenThread: () => () => {} added

Why

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 the open-url event, handleDeepLink parsed it correctly, and webContents.send("desktop:open-thread", threadId) was confirmed in the Electron main-process logs.

UI Changes

N/A — no visual changes. Navigation behavior only.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes

Note

Medium Risk
Changes OS protocol registration, startup ordering, and bundle IDs (install identity / multi-channel installs); deep-link parsing is narrow but affects how external URLs drive navigation.

Overview
Adds t3://thread/<threadId> as the desktop thread deep link. A new DesktopDeepLinks service registers open-url / second-instance early, registers the t3 protocol after ready (macOS pins setAsDefaultProtocolClient to process.execPath), parses URLs via threadDeepLink, and opens threads through DesktopWindow.openThread, which IPCs desktop:open-thread when the main window is ready (with pending-thread queuing).

The web shell listens via onOpenThread on desktopBridge and navigates to /$environmentId/$threadId, deferring until an environment id is known.

Alpha and Nightly packaged builds now use com.t3tools.t3code.alpha / .nightly bundle IDs (dev unchanged); launcher and electron-builder register t3 plus legacy t3code schemes. Local artifact builds set publish: null so GH_TOKEN does not auto-enable GitHub publish.

Reviewed by Cursor Bugbot for commit 75a2103. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Register t3:// OS protocol handler for thread deep links in the desktop app

  • Adds t3://thread/<threadId> deep link handling end-to-end: the OS registers the app as the default t3 protocol client, parses incoming URLs, and routes them to DesktopWindow.openThread.
  • DesktopDeepLinks handles both open-url (macOS) and second-instance (Windows) Electron events, plus initial argv scanning for cold-start deep links.
  • DesktopWindow buffers thread open requests until the renderer is loaded, creating the window if needed, then sends via the new desktop:open-thread IPC channel.
  • AppSidebarLayout subscribes to onOpenThread and navigates to the thread route, deferring until the active environment is resolved.
  • Build config and bundle IDs are now channel-aware (com.t3tools.t3code.alpha for production, .nightly for nightly builds), and the t3 scheme is added to macOS protocols in the electron-builder config.
📊 Macroscope summarized 75a2103. 13 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 1d548879-0b52-40d2-ad75-7a789df6a838

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added size:M 30-99 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Apr 30, 2026
Comment thread apps/desktop/src/main.ts Outdated
@macroscopeapp

macroscopeapp Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

5 blocking correctness issues found. This PR introduces a new OS protocol handler feature with multiple unresolved review comments identifying bugs in the deep link handling logic, including race conditions where links are dropped during cold start and issues with duplicate/stale thread opens.

You can customize Macroscope's approvability policy. Learn more.

@wturon

wturon commented Jun 10, 2026

Copy link
Copy Markdown

Trying to integrate with the T3 code app and deep linking (the ability to focus specific chats programmatically specifically) would be a big help.

Port thread deep-link handling into the Effect-based desktop stack so
t3://thread/<id> 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 <cursoragent@cursor.com>
@davidmashburn
davidmashburn force-pushed the feat/t3-deep-link-protocol branch from c1e8393 to 75a2103 Compare July 7, 2026 22:18
@davidmashburn

Copy link
Copy Markdown
Author

Branch update: rebased onto current main and squashed into one commit (75a2103). The April implementation is replaced with the Effect-based DesktopDeepLinks path plus renderer-side pending-thread queueing (see reply on the Bugbot cold-start thread).

No server/orchestration changes in this revision — those are split out to #3642.

@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Jul 7, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 5 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 75a2103. Configure here.

return () => {
unsubscribe?.();
};
}, [navigate, activeEnvironmentId, serverConfig]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep link IPC before listener

High Severity

The onOpenThread listener in AppSidebarLayout registers after authentication, which means it can miss desktop:open-thread events sent early by the main process on did-finish-load. As IPC messages aren't replayed, deep links, especially during cold-starts or pre-auth, may be dropped.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 75a2103. Configure here.

void navigate({
to: "/$environmentId/$threadId",
params: { environmentId, threadId },
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 75a2103. Configure here.

}

sendOpenThreadToWindow(existingWindow.value, threadId);
}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale pending thread overwrites newer

Medium Severity

When the first deep link sets pendingOpenThreadIdRef before a main window exists, a later openThread for a different id on an existing (still loading) window does not clear that pending value. The next did-finish-load flush still emits the older thread id, so navigation can jump to the wrong chat after a newer link.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 75a2103. Configure here.


if (targetWindow.webContents.isLoadingMainFrame()) {
targetWindow.webContents.once("did-finish-load", send);
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate IPC while page loading

Medium Severity

Each openThread call while the main frame is loading adds another webContents.once("did-finish-load") sender without canceling prior ones. Repeated deep links (or duplicate macOS delivery) before load finishes can fire multiple desktop:open-thread messages for the same or different threads.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 75a2103. Configure here.

const launchThreadId = findThreadDeepLinkInArgv(process.argv);
if (Option.isSome(launchThreadId)) {
yield* openThreadDeepLink(launchThreadId.value);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

macOS cold start duplicate opens

Medium Severity

On macOS, launching via t3://thread/… can invoke openThreadDeepLink twice: once from findThreadDeepLinkInArgv(process.argv) in configure and again from the open-url handler. That can emit duplicate desktop:open-thread IPC and navigation for a single link.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 75a2103. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

export function resolveDesktopBuildAppId(version: string): string {

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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

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.

🤖 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants