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
53 changes: 53 additions & 0 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
type JSX,
lazy,
onCleanup,
onMount,
type ParentProps,
Show,
} from "solid-js"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -590,6 +642,7 @@ export function AppInterface(props: {
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<ExistingSessionDeepLinkHandler />
<PermissionProvider>
<NotificationProvider>
<ServerShell>
Expand Down
11 changes: 10 additions & 1 deletion packages/app/src/components/titlebar-tab-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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}
Expand Down
20 changes: 14 additions & 6 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ import {
collectOpenProjectDeepLinks,
deepLinkEvent,
drainPendingDeepLinks,
enqueueDeepLink,
lastDeepLink,
parseDeepLink,
parseNewSessionDeepLink,
takePendingDeepLink,
} from "./layout/deep-links"
import { createInlineEditorController } from "./layout/inline-editor"
import {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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)
})

Expand Down
53 changes: 51 additions & 2 deletions packages/app/src/pages/layout/deep-links.ts
Original file line number Diff line number Diff line change
@@ -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
}
Expand All @@ -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<void>) => {
const next = deepLinkQueue.then(task, task)
deepLinkQueue = next.then(
() => {},
() => {},
)
return next
}

type OpenCodeWindow = Window & {
__OPENCODE__?: {
deepLinks?: string[]
Expand All @@ -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
}
85 changes: 85 additions & 0 deletions packages/app/src/pages/layout/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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__: {
Expand All @@ -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<void>((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", () => {
Expand Down
20 changes: 20 additions & 0 deletions packages/desktop/src/main/deep-links.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
9 changes: 9 additions & 0 deletions packages/desktop/src/main/deep-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function collectDeepLinkArgs(args: string[]) {
return args.filter((arg) => {
try {
return new URL(arg).protocol === "opencode:"
} catch {
return false
}
})
}
Loading
Loading