diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2fd2f2fe3..cc322d74f 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -44,6 +44,7 @@ import { playSound, soundSrc } from "@/utils/sound" import { createAim } from "@/utils/aim" import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" +import { setSessionHandoff } from "@/pages/session/handoff" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" @@ -67,7 +68,12 @@ import { sortedRootSessions, workspaceKey, } 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 { LocalWorkspace, @@ -1177,9 +1183,20 @@ export default function Layout(props: ParentProps) { const handleDeepLinks = (urls: string[]) => { if (!server.isLocal()) return + for (const directory of collectOpenProjectDeepLinks(urls)) { 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(() => { diff --git a/packages/app/src/pages/layout/deep-links.ts b/packages/app/src/pages/layout/deep-links.ts index 7bdb002a3..5dca421f7 100644 --- a/packages/app/src/pages/layout/deep-links.ts +++ b/packages/app/src/pages/layout/deep-links.ts @@ -1,15 +1,17 @@ export const deepLinkEvent = "opencode:deep-link" -export const parseDeepLink = (input: string) => { +const parseUrl = (input: string) => { if (!input.startsWith("opencode://")) return if (typeof URL.canParse === "function" && !URL.canParse(input)) return - const url = (() => { - try { - return new URL(input) - } catch { - return undefined - } - })() + try { + return new URL(input) + } catch { + return + } +} + +export const parseDeepLink = (input: string) => { + const url = parseUrl(input) if (!url) return if (url.hostname !== "open-project") return const directory = url.searchParams.get("directory") @@ -17,9 +19,23 @@ export const parseDeepLink = (input: string) => { 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[]) => 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 & { __OPENCODE__?: { deepLinks?: string[] diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 29517b624..d1569dbd9 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -1,15 +1,14 @@ import { describe, expect, test } from "bun:test" -import { type Session } from "@opencode-ai/sdk/v2/client" -import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links" import { - displayName, - errorMessage, - getDraggableId, - hasProjectPermissions, - latestRootSession, - syncWorkspaceOrder, - workspaceKey, -} from "./helpers" + collectNewSessionDeepLinks, + collectOpenProjectDeepLinks, + drainPendingDeepLinks, + parseDeepLink, + parseNewSessionDeepLink, +} from "./deep-links" +import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers" +import { type Session } from "@opencode-ai/sdk/v2/client" +import { hasProjectPermissions, latestRootSession } from "./helpers" const session = (input: Partial & Pick) => ({ @@ -62,6 +61,28 @@ describe("layout deep links", () => { 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", () => { const target = { __OPENCODE__: { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index cc81ae7b6..24a754428 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,3 +1,5 @@ +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { onCleanup, Show, @@ -9,7 +11,6 @@ import { on, onMount, untrack, - createSignal, } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" 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 { createAutoScroll } from "@opencode-ai/ui/hooks" import { Mark } from "@opencode-ai/ui/logo" - -import { useSync } from "@/context/sync" -import { useLayout } from "@/context/layout" -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 { base64Encode, checksum } from "@opencode-ai/util/encode" +import { useNavigate, useParams, useSearchParams } from "@solidjs/router" +import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" -import { SessionHeader, NewSessionView } from "@/components/session" -import { same } from "@/utils/same" +import { useLanguage } from "@/context/language" +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 { 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 { useSessionCommands } from "@/pages/session/use-session-commands" -import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer" +import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" +import { createScrollSpy } from "@/pages/session/scroll-spy" import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" 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 { same } from "@/utils/same" const emptyUserMessages: UserMessage[] = [] @@ -265,6 +263,19 @@ export default function Page() { const sdk = useSDK() const prompt = usePrompt() 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({ pendingMessage: undefined as string | undefined, @@ -679,7 +690,11 @@ export default function Page() { on( sessionKey, () => { - setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined }) + setTree({ + reviewScroll: undefined, + pendingDiff: undefined, + activeDiff: undefined, + }) }, { defer: true }, ),