diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9f8286a47ff98..9864c19b0d0ab 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -54,6 +54,21 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens only the bare Grok Bot launch link", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.forEach( + ["grokbot://app", "grokbot://app/v1/prompt?id=bot&text=hi", "grokbot://"], + (url) => electronShell.openExternal(url), + ); + + assert.deepEqual(results, [true, false, false]); + assert.deepEqual(openExternalMock.mock.calls, [["grokbot://app"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("opens local editor file deep links", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 308f60b10c2cb..e342361750f41 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -21,6 +21,10 @@ const REMOTE_EDITOR_PROTOCOLS = new Set( }), ); +// "Use T3 Code in another app" opens these apps (apps/web/src/components/mcp/mcpApps.ts). List +// each exact link shape the wizard sends, never a whole scheme. +const APP_LAUNCH_URLS = new Set(["grokbot://app"]); + // Zed embeds [user@]host in the path; reject passwords and ports there. const ZED_SSH_PATHNAME = /^\/(?:[^/@:]+@)?[^/@:]+\/.*$/; @@ -46,6 +50,7 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { try { const url = new URL(rawUrl); return SAFE_WEB_PROTOCOLS.has(url.protocol) || + APP_LAUNCH_URLS.has(url.href) || isRemoteEditorUrl(url) || isLocalEditorFileUrl(url) ? Option.some(url.href) diff --git a/apps/web/src/components/mcp/UseInAnotherAppDialog.tsx b/apps/web/src/components/mcp/UseInAnotherAppDialog.tsx index a904d4d568dcd..647e63c341207 100644 --- a/apps/web/src/components/mcp/UseInAnotherAppDialog.tsx +++ b/apps/web/src/components/mcp/UseInAnotherAppDialog.tsx @@ -662,7 +662,16 @@ function PrimaryAction({ app, mcpUrl }: { readonly app: McpApp; readonly mcpUrl: ); } - const { copy, link } = action; + const { copy, link, appLink } = action; + // The desktop app reports a link nothing opens and falls back; a browser cannot tell, and + // opens an app link in place (a new tab for it would stay blank), so its toast keeps the web. + const openApp = () => { + if (appLink === null) return openLink(link); + if (!isElectron) return window.location.assign(appLink); + void readLocalApi() + ?.shell.openExternal(appLink) + .catch(() => openLink(link)); + }; const handOff = async () => { if (copy !== null) { // The app opens only once the copy lands, so it never gets an older clipboard. @@ -678,13 +687,19 @@ function PrimaryAction({ app, mcpUrl }: { readonly app: McpApp; readonly mcpUrl: toastManager.add({ type: "success", title: copy.title, - // A browser can block an open that follows the copy; this keeps a way through. - ...(isElectron + // A browser can block an open that follows the copy, and no platform reliably reports an + // app link nothing handles (Linux says it opened), so these keep a way through. + ...(isElectron && appLink === null ? {} - : { actionProps: { children: `Open ${app.name}`, onClick: () => openLink(link) } }), + : { + actionProps: { + children: appLink === null ? `Open ${app.name}` : `Open ${app.name} on the web`, + onClick: () => openLink(link), + }, + }), }); } - openLink(link); + openApp(); }; return (
diff --git a/apps/web/src/components/mcp/mcpApps.test.ts b/apps/web/src/components/mcp/mcpApps.test.ts index 0e9f85f84aa1d..2e9328cf78784 100644 --- a/apps/web/src/components/mcp/mcpApps.test.ts +++ b/apps/web/src/components/mcp/mcpApps.test.ts @@ -18,11 +18,12 @@ describe("MCP app links", () => { expect(claude.searchParams.get("connectorUrl")).toBe(MCP_URL); }); - // The desktop app opens only http and https links. + // The desktop app opens only https links and the bare app launch links it lists. it.each(MCP_APPS.map((app) => [app.name, app] as const))("%s opens only https", (_, app) => { - const link = mcpHandoffAction(app, MCP_URL)?.link; - for (const url of [link, app.docs].filter((value) => value != null)) { + const action = mcpHandoffAction(app, MCP_URL); + for (const url of [action?.link, app.docs].filter((value) => value != null)) { expect(new URL(url).protocol).toBe("https:"); } + expect(action?.appLink ?? null).toBe(app.id === "grok-bot" ? "grokbot://app" : null); }); }); diff --git a/apps/web/src/components/mcp/mcpApps.ts b/apps/web/src/components/mcp/mcpApps.ts index c2285157a8f02..e32dac3e361e6 100644 --- a/apps/web/src/components/mcp/mcpApps.ts +++ b/apps/web/src/components/mcp/mcpApps.ts @@ -8,7 +8,7 @@ import museLogo from "./logos/muse.png"; import openClawLogo from "./logos/openclaw.png"; import pokeLogo from "./logos/poke.png"; -/** How the Connect step hands T3 Code's MCP URL to an app. Every link is https. */ +/** How the Connect step hands T3 Code's MCP URL to an app. Every `link` is https. */ export type McpAppHandoff = /** Opens the app's add form with T3 Code filled in. */ | { readonly kind: "open"; readonly link: (mcpUrl: string) => string } @@ -18,6 +18,11 @@ export type McpAppHandoff = | { readonly kind: "copy-message"; readonly link: string; + /** + * Opens the installed app first, given the message; `link` is the fallback. The desktop + * app opens only the link shapes `APP_LAUNCH_URLS` in ElectronShell lists. + */ + readonly appLink?: (message: string) => string; readonly message: (mcpUrl: string) => string; } /** Commands to run where the app runs. */ @@ -83,10 +88,12 @@ export const MCP_APPS: readonly McpApp[] = [ id: "grok-bot", name: "Grok Bot", logo: grokBotLogo, - // The app has no web chat, and its prompt links need a Bot id. + // No web chat, and no link that prefills a message yet (Grok Bot plans one), so the app + // opens bare and the copied message is pasted in. handoff: { kind: "copy-message", link: "https://x.ai/bot", + appLink: () => "grokbot://app", message: (url) => `Add the MCP server ${url} and connect it.`, }, steps: ["Send this message to any of your Grok Bots."], @@ -182,19 +189,28 @@ export function mcpHandoffAction(app: McpApp, mcpUrl: string) { const { handoff } = app; switch (handoff.kind) { case "open": - return { label: `Open in ${app.name}`, link: handoff.link(mcpUrl), copy: null }; + return { + label: `Open in ${app.name}`, + link: handoff.link(mcpUrl), + appLink: null, + copy: null, + }; case "copy-url": return { label: `Copy URL and open ${app.name}`, link: handoff.link, + appLink: null, copy: { text: mcpUrl, title: "URL copied" }, }; - case "copy-message": + case "copy-message": { + const message = handoff.message(mcpUrl); return { label: `Copy message and open ${app.name}`, link: handoff.link, - copy: { text: handoff.message(mcpUrl), title: "Message copied" }, + appLink: handoff.appLink?.(message) ?? null, + copy: { text: message, title: "Message copied" }, }; + } case "commands": return null; }