diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 0c950871a335..ed1f7a6a1bbf 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -33,6 +33,7 @@ import { type JSX, lazy, onCleanup, + onMount, type ParentProps, Show, } from "solid-js" @@ -64,6 +65,14 @@ import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" import { createSessionLineage } from "@/pages/session/session-lineage" +import { + deepLinkEvent, + drainPendingDeepLinks, + enqueueDeepLink, + lastDeepLink, + parseExistingSessionDeepLink, + takePendingDeepLink, +} from "@/pages/layout/deep-links" import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" import { NewHome } from "@/pages/home" @@ -378,6 +387,49 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { ) } +function ExistingSessionDeepLinkHandler() { + const global = useGlobal() + const server = useServer() + const navigate = useNavigate() + + const open = async (url: string) => { + const link = parseExistingSessionDeepLink(url) + if (!link) return + const key = ServerConnection.Key.make(link.server) + const conn = global.servers.list().find((item) => ServerConnection.key(item) === key) + if (!conn) return + const lineage = await global + .ensureServerCtx(conn) + .sync.session.lineage.resolve(link.session) + .catch(() => undefined) + if (!lineage) return + server.setActive(key) + navigate(sessionHref(key, lineage.session.id)) + } + + onMount(() => { + const enqueue = (url: string | undefined) => { + if (!url) return + void enqueueDeepLink(() => open(url)).catch(() => {}) + } + enqueue(takePendingDeepLink(window, (url) => !!parseExistingSessionDeepLink(url))) + queueMicrotask(() => drainPendingDeepLinks(window)) + makeEventListener(window, deepLinkEvent, (event) => { + const detail = (event as CustomEvent<{ urls: string[] }>).detail + const selected = lastDeepLink(detail?.urls ?? []) + if (!selected) drainPendingDeepLinks(window) + if (!parseExistingSessionDeepLink(selected ?? "")) { + queueMicrotask(() => drainPendingDeepLinks(window)) + return + } + drainPendingDeepLinks(window) + enqueue(selected) + }) + }) + + return null +} + // The draft page only renders the prompt composer, so it drops TerminalProvider. // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context. function DraftProviders(props: ParentProps) { @@ -590,6 +642,7 @@ export function AppInterface(props: { component={props.router ?? Router} root={(routerProps) => ( + diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 0a4b4da12dfe..784ed8fb5b1c 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -17,9 +17,12 @@ import { useTabs } from "@/context/tabs" import { createTabPromptState } from "@/context/prompt" import { base64Encode } from "@opencode-ai/core/util/encode" import { showToast } from "@/utils/toast" +import { usePlatform } from "@/context/platform" +import { existingSessionDeepLink } from "@/pages/layout/deep-links" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order" import type { Session } from "@opencode-ai/sdk/v2" +import { useParams } from "@solidjs/router" function SessionTabSlot(props: { tab: SessionTab @@ -33,6 +36,8 @@ function SessionTabSlot(props: { onNavigate: (element: HTMLDivElement) => void onClose: () => void }) { + const platform = usePlatform() + const params = useParams<{ id?: string }>() const sortable = useSortable({ get id() { return props.id @@ -55,7 +60,11 @@ function SessionTabSlot(props: { ref={(el) => { ref = el }} - href={tabHref(props.tab)} + href={ + platform.platform === "desktop" + ? existingSessionDeepLink(props.tab.server, props.active() && params.id ? params.id : props.tab.sessionId) + : tabHref(props.tab) + } server={props.tab.server} session={props.session} fallbackTitle={props.fallbackTitle} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 98548ab2333b..b38a9ed0bd90 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -72,6 +72,11 @@ import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks, + enqueueDeepLink, + lastDeepLink, + parseDeepLink, + parseNewSessionDeepLink, + takePendingDeepLink, } from "./layout/deep-links" import { createInlineEditorController } from "./layout/inline-editor" import { @@ -1249,11 +1254,11 @@ export default function LegacyLayout(props: ParentProps) { if (navigate) return navigateToProject(directory) } - const handleDeepLinks = (urls: string[]) => { + const handleDeepLinks = async (urls: string[]) => { if (!server.isLocal()) return for (const directory of collectOpenProjectDeepLinks(urls)) { - void openProject(directory) + await openProject(directory) } for (const link of collectNewSessionDeepLinks(urls)) { @@ -1270,14 +1275,17 @@ export default function LegacyLayout(props: ParentProps) { } onMount(() => { + const take = (url: string) => !!parseDeepLink(url) || !!parseNewSessionDeepLink(url) const handler = (event: Event) => { const detail = (event as CustomEvent<{ urls: string[] }>).detail - const urls = detail?.urls ?? [] - if (urls.length === 0) return - handleDeepLinks(urls) + const selected = lastDeepLink(detail?.urls ?? []) + if (!selected || !take(selected)) return + drainPendingDeepLinks(window) + void enqueueDeepLink(() => handleDeepLinks([selected])).catch(() => {}) } - handleDeepLinks(drainPendingDeepLinks(window)) + const pending = takePendingDeepLink(window, take) + if (pending) void enqueueDeepLink(() => handleDeepLinks([pending])).catch(() => {}) makeEventListener(window, deepLinkEvent, handler as EventListener) }) diff --git a/packages/app/src/pages/layout/deep-links.ts b/packages/app/src/pages/layout/deep-links.ts index 5dca421f7498..b240ea425dce 100644 --- a/packages/app/src/pages/layout/deep-links.ts +++ b/packages/app/src/pages/layout/deep-links.ts @@ -1,10 +1,11 @@ export const deepLinkEvent = "opencode:deep-link" const parseUrl = (input: string) => { - if (!input.startsWith("opencode://")) return if (typeof URL.canParse === "function" && !URL.canParse(input)) return try { - return new URL(input) + const url = new URL(input) + if (url.protocol !== "opencode:") return + return url } catch { return } @@ -30,12 +31,48 @@ export const parseNewSessionDeepLink = (input: string) => { return { directory, prompt } } +export const existingSessionDeepLink = (server: string, session: string) => { + const url = new URL("opencode://open-session") + url.searchParams.set("server", server) + url.searchParams.set("session", session) + return url.toString() +} + +export const parseExistingSessionDeepLink = (input: string) => { + const url = parseUrl(input) + if (!url) return + if (url.hostname !== "open-session" || (url.pathname !== "" && url.pathname !== "/")) return + const server = url.searchParams.get("server") + const session = url.searchParams.get("session") + if (!server || !session) return + return { server, session } +} + export const collectOpenProjectDeepLinks = (urls: string[]) => urls.map(parseDeepLink).filter((directory): directory is string => !!directory) export const collectNewSessionDeepLinks = (urls: string[]) => urls.map(parseNewSessionDeepLink).filter((link): link is { directory: string; prompt?: string } => !!link) +export const collectExistingSessionDeepLinks = (urls: string[]) => + urls.map(parseExistingSessionDeepLink).filter((link): link is { server: string; session: string } => !!link) + +export const lastDeepLink = (urls: string[]) => + urls + .filter((url) => !!parseDeepLink(url) || !!parseNewSessionDeepLink(url) || !!parseExistingSessionDeepLink(url)) + .at(-1) + +let deepLinkQueue = Promise.resolve() + +export const enqueueDeepLink = (task: () => void | Promise) => { + const next = deepLinkQueue.then(task, task) + deepLinkQueue = next.then( + () => {}, + () => {}, + ) + return next +} + type OpenCodeWindow = Window & { __OPENCODE__?: { deepLinks?: string[] @@ -48,3 +85,15 @@ export const drainPendingDeepLinks = (target: OpenCodeWindow) => { if (target.__OPENCODE__) target.__OPENCODE__.deepLinks = [] return pending } + +export const takePendingDeepLink = (target: OpenCodeWindow, take: (url: string) => boolean) => { + const pending = target.__OPENCODE__?.deepLinks ?? [] + const selected = lastDeepLink(pending) + if (!selected) { + if (target.__OPENCODE__) target.__OPENCODE__.deepLinks = [] + return + } + if (!take(selected)) return + if (target.__OPENCODE__) target.__OPENCODE__.deepLinks = [] + return selected +} diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index cb58b6f8b6d6..bc39a9f458e0 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -1,10 +1,16 @@ import { describe, expect, test } from "bun:test" import { + collectExistingSessionDeepLinks, collectNewSessionDeepLinks, collectOpenProjectDeepLinks, drainPendingDeepLinks, + enqueueDeepLink, + existingSessionDeepLink, + lastDeepLink, parseDeepLink, + parseExistingSessionDeepLink, parseNewSessionDeepLink, + takePendingDeepLink, } from "./deep-links" import { type Session } from "@opencode-ai/sdk/v2/client" import { @@ -100,6 +106,36 @@ describe("layout deep links", () => { expect(result).toEqual([{ directory: "/a" }, { directory: "/c", prompt: "ship it" }]) }) + test("generates and parses existing-session deep links", () => { + const link = existingSessionDeepLink(serverKey("https://example.com:4096"), "ses_1") + expect(link).toBe("opencode://open-session?server=https%3A%2F%2Fexample.com%3A4096&session=ses_1") + expect(parseExistingSessionDeepLink(link)).toEqual({ server: "https://example.com:4096", session: "ses_1" }) + expect(parseExistingSessionDeepLink(existingSessionDeepLink(serverKey("sidecar"), "ses_2"))).toEqual({ + server: "sidecar", + session: "ses_2", + }) + }) + + test("ignores invalid existing-session deep links", () => { + expect(parseExistingSessionDeepLink("opencode://open-session?server=sidecar")).toBeUndefined() + expect(parseExistingSessionDeepLink("opencode://open-session?session=ses_1")).toBeUndefined() + expect(parseExistingSessionDeepLink("opencode://open-session/path?server=sidecar&session=ses_1")).toBeUndefined() + expect(parseExistingSessionDeepLink("https://open-session?server=sidecar&session=ses_1")).toBeUndefined() + }) + + test("collects only valid existing-session links", () => { + expect( + collectExistingSessionDeepLinks([ + "opencode://open-session?server=sidecar&session=ses_1", + "opencode://open-project?directory=/tmp/demo", + "opencode://open-session?server=wsl%3AUbuntu&session=ses_2", + ]), + ).toEqual([ + { server: "sidecar", session: "ses_1" }, + { server: "wsl:Ubuntu", session: "ses_2" }, + ]) + }) + test("drains global deep links once", () => { const target = { __OPENCODE__: { @@ -110,6 +146,55 @@ describe("layout deep links", () => { expect(drainPendingDeepLinks(target)).toEqual(["opencode://open-project?directory=/a"]) expect(drainPendingDeepLinks(target)).toEqual([]) }) + + test("selects only the last supported pending deep link", () => { + const target = { + __OPENCODE__: { + deepLinks: [ + "opencode://open-project?directory=/a", + "opencode://open-session?server=sidecar&session=ses_1", + "opencode://unsupported", + ], + }, + } as unknown as Window & { __OPENCODE__?: { deepLinks?: string[] } } + + expect(takePendingDeepLink(target, (url) => !!parseDeepLink(url))).toBeUndefined() + expect(takePendingDeepLink(target, (url) => !!parseExistingSessionDeepLink(url))).toBe( + "opencode://open-session?server=sidecar&session=ses_1", + ) + expect(drainPendingDeepLinks(target)).toEqual([]) + }) + + test("uses the last supported deep link in a batch", () => { + expect( + lastDeepLink([ + "opencode://open-session?server=sidecar&session=ses_1", + "opencode://unsupported", + "opencode://open-project?directory=/a", + ]), + ).toBe("opencode://open-project?directory=/a") + }) + + test("processes deep links in delivery order", async () => { + const result: string[] = [] + let release = () => {} + const first = enqueueDeepLink(() => + new Promise((resolve) => { + release = resolve + }).then(() => { + result.push("first") + }), + ) + const second = enqueueDeepLink(() => { + result.push("second") + }) + + await Promise.resolve() + expect(result).toEqual([]) + release() + await Promise.all([first, second]) + expect(result).toEqual(["first", "second"]) + }) }) describe("layout workspace helpers", () => { diff --git a/packages/desktop/src/main/deep-links.test.ts b/packages/desktop/src/main/deep-links.test.ts new file mode 100644 index 000000000000..367d6de13d6e --- /dev/null +++ b/packages/desktop/src/main/deep-links.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import { collectDeepLinkArgs } from "./deep-links" + +describe("desktop deep links", () => { + test("collects protocol URLs from process arguments", () => { + expect( + collectDeepLinkArgs([ + "OpenCode.exe", + "--flag", + "opencode://open-session?server=sidecar&session=ses_1", + "https://example.com", + "OPENCODE://open-project?directory=C%3A%5Cdemo", + ]), + ).toEqual(["opencode://open-session?server=sidecar&session=ses_1", "OPENCODE://open-project?directory=C%3A%5Cdemo"]) + }) + + test("ignores malformed and unrelated arguments", () => { + expect(collectDeepLinkArgs(["OpenCode.exe", "opencode://[", "not a URL", "file:///tmp/demo"])).toEqual([]) + }) +}) diff --git a/packages/desktop/src/main/deep-links.ts b/packages/desktop/src/main/deep-links.ts new file mode 100644 index 000000000000..29a30b16e2b2 --- /dev/null +++ b/packages/desktop/src/main/deep-links.ts @@ -0,0 +1,9 @@ +export function collectDeepLinkArgs(args: string[]) { + return args.filter((arg) => { + try { + return new URL(arg).protocol === "opencode:" + } catch { + return false + } + }) +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 183fc634db01..fba669b10ea5 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -49,6 +49,7 @@ import { migrate } from "./migrate" import { cleanupStoreFiles } from "./store-cleanup" import { startBackgroundCli } from "./background-cli" import { setNativeTranslations } from "./native-translations" +import { collectDeepLinkArgs } from "./deep-links" const APP_NAMES: Record = { dev: "OpenCode Dev", @@ -200,10 +201,16 @@ const main = Effect.gen(function* () { return } + const initialDeepLinks = collectDeepLinkArgs(process.argv) + if (initialDeepLinks.length) { + logger.log("deep link received via initial arguments", { urls: initialDeepLinks }) + emitDeepLinks(initialDeepLinks) + } + const shellEnv = preferAppEnv(app.getPath("userData")) app.on("second-instance", (_event: Event, argv: string[]) => { - const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) + const urls = collectDeepLinkArgs(argv) if (urls.length) { logger.log("deep link received via second-instance", { urls }) emitDeepLinks(urls)