desktop: new-session deeplink (#15322)

This commit is contained in:
Brendan Allan
2026-03-05 17:15:14 +08:00
committed by GitHub
parent 6531cfc521
commit 4e26b0aec7
4 changed files with 107 additions and 38 deletions

View File

@@ -44,6 +44,7 @@ import { playSound, soundSrc } from "@/utils/sound"
import { createAim } from "@/utils/aim" import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click" import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
@@ -67,7 +68,12 @@ import {
sortedRootSessions, sortedRootSessions,
workspaceKey, workspaceKey,
} from "./layout/helpers" } from "./layout/helpers"
import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links" import {
collectNewSessionDeepLinks,
collectOpenProjectDeepLinks,
deepLinkEvent,
drainPendingDeepLinks,
} from "./layout/deep-links"
import { createInlineEditorController } from "./layout/inline-editor" import { createInlineEditorController } from "./layout/inline-editor"
import { import {
LocalWorkspace, LocalWorkspace,
@@ -1177,9 +1183,20 @@ export default function Layout(props: ParentProps) {
const handleDeepLinks = (urls: string[]) => { const handleDeepLinks = (urls: string[]) => {
if (!server.isLocal()) return if (!server.isLocal()) return
for (const directory of collectOpenProjectDeepLinks(urls)) { for (const directory of collectOpenProjectDeepLinks(urls)) {
openProject(directory) openProject(directory)
} }
for (const link of collectNewSessionDeepLinks(urls)) {
openProject(link.directory, false)
const slug = base64Encode(link.directory)
if (link.prompt) {
setSessionHandoff(slug, { prompt: link.prompt })
}
const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session`
navigateWithSidebarReset(href)
}
} }
onMount(() => { onMount(() => {

View File

@@ -1,15 +1,17 @@
export const deepLinkEvent = "opencode:deep-link" export const deepLinkEvent = "opencode:deep-link"
export const parseDeepLink = (input: string) => { const parseUrl = (input: string) => {
if (!input.startsWith("opencode://")) return if (!input.startsWith("opencode://")) return
if (typeof URL.canParse === "function" && !URL.canParse(input)) return if (typeof URL.canParse === "function" && !URL.canParse(input)) return
const url = (() => { try {
try { return new URL(input)
return new URL(input) } catch {
} catch { return
return undefined }
} }
})()
export const parseDeepLink = (input: string) => {
const url = parseUrl(input)
if (!url) return if (!url) return
if (url.hostname !== "open-project") return if (url.hostname !== "open-project") return
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
@@ -17,9 +19,23 @@ export const parseDeepLink = (input: string) => {
return directory return directory
} }
export const parseNewSessionDeepLink = (input: string) => {
const url = parseUrl(input)
if (!url) return
if (url.hostname !== "new-session") return
const directory = url.searchParams.get("directory")
if (!directory) return
const prompt = url.searchParams.get("prompt") || undefined
if (!prompt) return { directory }
return { directory, prompt }
}
export const collectOpenProjectDeepLinks = (urls: string[]) => export const collectOpenProjectDeepLinks = (urls: string[]) =>
urls.map(parseDeepLink).filter((directory): directory is string => !!directory) 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)
type OpenCodeWindow = Window & { type OpenCodeWindow = Window & {
__OPENCODE__?: { __OPENCODE__?: {
deepLinks?: string[] deepLinks?: string[]

View File

@@ -1,15 +1,14 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links"
import { import {
displayName, collectNewSessionDeepLinks,
errorMessage, collectOpenProjectDeepLinks,
getDraggableId, drainPendingDeepLinks,
hasProjectPermissions, parseDeepLink,
latestRootSession, parseNewSessionDeepLink,
syncWorkspaceOrder, } from "./deep-links"
workspaceKey, import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
} from "./helpers" import { type Session } from "@opencode-ai/sdk/v2/client"
import { hasProjectPermissions, latestRootSession } from "./helpers"
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) => const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
({ ({
@@ -62,6 +61,28 @@ describe("layout deep links", () => {
expect(result).toEqual(["/a", "/c"]) expect(result).toEqual(["/a", "/c"])
}) })
test("parses new-session deep links with optional prompt", () => {
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo")).toEqual({ directory: "/tmp/demo" })
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo&prompt=hello%20world")).toEqual({
directory: "/tmp/demo",
prompt: "hello world",
})
})
test("ignores new-session deep links without directory", () => {
expect(parseNewSessionDeepLink("opencode://new-session")).toBeUndefined()
expect(parseNewSessionDeepLink("opencode://new-session?directory=")).toBeUndefined()
})
test("collects only valid new-session deep links", () => {
const result = collectNewSessionDeepLinks([
"opencode://new-session?directory=/a",
"opencode://open-project?directory=/b",
"opencode://new-session?directory=/c&prompt=ship%20it",
])
expect(result).toEqual([{ directory: "/a" }, { directory: "/c", prompt: "ship it" }])
})
test("drains global deep links once", () => { test("drains global deep links once", () => {
const target = { const target = {
__OPENCODE__: { __OPENCODE__: {

View File

@@ -1,3 +1,5 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { import {
onCleanup, onCleanup,
Show, Show,
@@ -9,7 +11,6 @@ import {
on, on,
onMount, onMount,
untrack, untrack,
createSignal,
} from "solid-js" } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
@@ -20,29 +21,26 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { createAutoScroll } from "@opencode-ai/ui/hooks" import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { base64Encode, checksum } from "@opencode-ai/util/encode"
import { useSync } from "@/context/sync" import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { useLayout } from "@/context/layout" import { NewSessionView, SessionHeader } from "@/components/session"
import { checksum, base64Encode } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { SessionHeader, NewSessionView } from "@/components/session" import { useLanguage } from "@/context/language"
import { same } from "@/utils/same" import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { createOpenReviewFile } from "@/pages/session/helpers" import { createOpenReviewFile } from "@/pages/session/helpers"
import { createScrollSpy } from "@/pages/session/scroll-spy"
import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { MessageTimeline } from "@/pages/session/message-timeline" import { MessageTimeline } from "@/pages/session/message-timeline"
import { useSessionCommands } from "@/pages/session/use-session-commands" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer" import { createScrollSpy } from "@/pages/session/scroll-spy"
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel" import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { same } from "@/utils/same"
const emptyUserMessages: UserMessage[] = [] const emptyUserMessages: UserMessage[] = []
@@ -265,6 +263,19 @@ export default function Page() {
const sdk = useSDK() const sdk = useSDK()
const prompt = usePrompt() const prompt = usePrompt()
const comments = useComments() const comments = useComments()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
createEffect(() => {
if (!untrack(() => prompt.ready())) return
prompt.ready()
untrack(() => {
if (params.id || !prompt.ready()) return
const text = searchParams.prompt
if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
setSearchParams({ ...searchParams, prompt: undefined })
})
})
const [ui, setUi] = createStore({ const [ui, setUi] = createStore({
pendingMessage: undefined as string | undefined, pendingMessage: undefined as string | undefined,
@@ -679,7 +690,11 @@ export default function Page() {
on( on(
sessionKey, sessionKey,
() => { () => {
setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined }) setTree({
reviewScroll: undefined,
pendingDiff: undefined,
activeDiff: undefined,
})
}, },
{ defer: true }, { defer: true },
), ),