Jelajahi Sumber

desktop: new-session deeplink (#15322)

Brendan Allan 1 bulan lalu
induk
melakukan
4e26b0aec7

+ 18 - 1
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(() => {

+ 24 - 8
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[]

+ 31 - 10
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<Session> & Pick<Session, "id" | "directory">) =>
   ({
@@ -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__: {

+ 33 - 18
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 { 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 { useLanguage } from "@/context/language"
-import { useNavigate, useParams } from "@solidjs/router"
-import { UserMessage } from "@opencode-ai/sdk/v2"
-import { useSDK } from "@/context/sdk"
+import { useLayout } from "@/context/layout"
 import { usePrompt } from "@/context/prompt"
-import { useComments } from "@/context/comments"
-import { SessionHeader, NewSessionView } from "@/components/session"
-import { same } from "@/utils/same"
+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 },
     ),