mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-23 00:54:43 +00:00
desktop: new-session deeplink (#15322)
This commit is contained in:
@@ -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(() => {
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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__: {
|
||||||
|
|||||||
@@ -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 },
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user