Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -216,6 +217,7 @@ let backendListeningDetector: ServerListeningDetector | null = null;
let restartAttempt = 0;
let restartTimer: ReturnType<typeof setTimeout> | null = null;
let isQuitting = false;
let pendingDeepLinkThreadId: string | null = null;
let desktopProtocolRegistered = false;
let aboutCommitHashCache: string | null | undefined;
let desktopLogSink: RotatingFileSink | null = null;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
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.

Cold-start deep link permanently lost due to timing

High Severity

The cold-start deep link path is broken due to two compounding timing issues. First, a comment in the same file notes that did-finish-load "typically fires before the first paint," but React's useEffect (which registers the onOpenThread IPC listener) runs after paint — so the IPC message is sent before any listener exists. Second, even if that race were avoided, activeEnvironmentId is initialized to null and only set once serverConfig loads from the backend; during cold start the callback will hit the if (!activeEnvironmentId) return guard and silently discard the thread ID. Since pendingDeepLinkThreadId is cleared after sending and never retried, the deep link is permanently lost.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c1e8393. Configure here.

});

// On Linux/Wayland with `show: false`, Electron's `ready-to-show` only
Expand Down Expand Up @@ -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) => {
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
23 changes: 23 additions & 0 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ??
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/localApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg
showContextMenu: async () => null,
openExternal: async () => true,
onMenuAction: () => () => undefined,
onOpenThread: () => () => undefined,
getUpdateState: async () => {
throw new Error("getUpdateState not implemented in test");
},
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export interface DesktopBridge {
) => Promise<T | null>;
openExternal: (url: string) => Promise<boolean>;
onMenuAction: (listener: (action: string) => void) => () => void;
onOpenThread: (listener: (threadId: string) => void) => () => void;
getUpdateState: () => Promise<DesktopUpdateState>;
setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>;
checkForUpdate: () => Promise<DesktopUpdateCheckResult>;
Expand Down
1 change: 1 addition & 0 deletions scripts/build-desktop-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }],
};
}

Expand Down
Loading