From 14f1f244ec484ffe0266ee3406776e3dece9a302 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Fri, 3 Jul 2026 19:46:05 +0100 Subject: [PATCH 1/6] fix(app): open project deep links in new layout --- packages/app/src/pages/layout-new.tsx | 18 ++++++++++++++++++ packages/app/src/pages/layout.tsx | 15 ++------------- .../src/pages/layout/use-deep-link-listener.ts | 17 +++++++++++++++++ 3 files changed, 37 insertions(+), 13 deletions(-) create mode 100644 packages/app/src/pages/layout/use-deep-link-listener.ts diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx index f73e7b17eb31..078b69b7d36b 100644 --- a/packages/app/src/pages/layout-new.tsx +++ b/packages/app/src/pages/layout-new.tsx @@ -3,19 +3,37 @@ import { useNavigate } from "@solidjs/router" import { DebugBar } from "@/components/debug-bar" import { HelpButton } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" +import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" import { setNavigate } from "@/utils/notification-click" import { setV2Toast, ToastRegion } from "@/utils/toast" import { useSettingsCommand } from "@/components/settings-dialog" +import { collectOpenProjectDeepLinks } from "./layout/deep-links" +import { useDeepLinkListener } from "./layout/use-deep-link-listener" export default function NewLayout(props: ParentProps) { + const layout = useLayout() const platform = usePlatform() + const server = useServer() const navigate = useNavigate() setNavigate(navigate) useSettingsCommand() createEffect(() => setV2Toast(true)) + const handleDeepLinks = (urls: string[]) => { + if (!server.isLocal()) return + + for (const directory of collectOpenProjectDeepLinks(urls)) { + layout.projects.open(directory) + layout.home.setSelection({ server: server.key, directory }) + navigate("/") + } + } + + useDeepLinkListener(handleDeepLinks) + const update: TitlebarUpdate = { version: () => { const state = platform.updater?.state() diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index fd9d16b90ad6..5215be81413d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -70,9 +70,8 @@ import { import { collectNewSessionDeepLinks, collectOpenProjectDeepLinks, - deepLinkEvent, - drainPendingDeepLinks, } from "./layout/deep-links" +import { useDeepLinkListener } from "./layout/use-deep-link-listener" import { createInlineEditorController } from "./layout/inline-editor" import { LocalWorkspace, @@ -1275,17 +1274,7 @@ export default function LegacyLayout(props: ParentProps) { } } - onMount(() => { - const handler = (event: Event) => { - const detail = (event as CustomEvent<{ urls: string[] }>).detail - const urls = detail?.urls ?? [] - if (urls.length === 0) return - handleDeepLinks(urls) - } - - handleDeepLinks(drainPendingDeepLinks(window)) - makeEventListener(window, deepLinkEvent, handler as EventListener) - }) + useDeepLinkListener(handleDeepLinks) async function renameProject(project: LocalProject, next: string) { const current = displayName(project) diff --git a/packages/app/src/pages/layout/use-deep-link-listener.ts b/packages/app/src/pages/layout/use-deep-link-listener.ts new file mode 100644 index 000000000000..1ded7f1d4b25 --- /dev/null +++ b/packages/app/src/pages/layout/use-deep-link-listener.ts @@ -0,0 +1,17 @@ +import { makeEventListener } from "@solid-primitives/event-listener" +import { onMount } from "solid-js" +import { deepLinkEvent, drainPendingDeepLinks } from "./deep-links" + +export function useDeepLinkListener(handleDeepLinks: (urls: string[]) => void) { + onMount(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ urls: string[] }>).detail + const urls = detail?.urls ?? [] + if (urls.length === 0) return + handleDeepLinks(urls) + } + + handleDeepLinks(drainPendingDeepLinks(window)) + makeEventListener(window, deepLinkEvent, handler as EventListener) + }) +} From 562a07cd9ef5876f056418c71b65587dcd8f7d15 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Fri, 3 Jul 2026 21:03:59 +0100 Subject: [PATCH 2/6] fix(app): handle desktop new session deep links --- packages/app/src/pages/layout-new.tsx | 26 ++++++++++++++++--- packages/app/src/pages/layout.tsx | 15 +++++++---- packages/app/src/pages/layout/deep-links.ts | 11 ++++++-- packages/app/src/pages/layout/helpers.test.ts | 13 +++++++--- 4 files changed, 51 insertions(+), 14 deletions(-) diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx index 078b69b7d36b..c5a49fb4bee1 100644 --- a/packages/app/src/pages/layout-new.tsx +++ b/packages/app/src/pages/layout-new.tsx @@ -1,21 +1,24 @@ import { createEffect, Suspense, type ParentProps } from "solid-js" import { useNavigate } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/core/util/encode" import { DebugBar } from "@/components/debug-bar" import { HelpButton } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useServer } from "@/context/server" +import { useTabs } from "@/context/tabs" import { setNavigate } from "@/utils/notification-click" import { setV2Toast, ToastRegion } from "@/utils/toast" import { useSettingsCommand } from "@/components/settings-dialog" -import { collectOpenProjectDeepLinks } from "./layout/deep-links" +import { collectNewSessionDeepLinks, collectOpenProjectDeepLinks } from "./layout/deep-links" import { useDeepLinkListener } from "./layout/use-deep-link-listener" export default function NewLayout(props: ParentProps) { const layout = useLayout() const platform = usePlatform() const server = useServer() + const tabs = useTabs() const navigate = useNavigate() setNavigate(navigate) useSettingsCommand() @@ -25,15 +28,30 @@ export default function NewLayout(props: ParentProps) { const handleDeepLinks = (urls: string[]) => { if (!server.isLocal()) return - for (const directory of collectOpenProjectDeepLinks(urls)) { - layout.projects.open(directory) - layout.home.setSelection({ server: server.key, directory }) + for (const link of collectOpenProjectDeepLinks(urls)) { + layout.projects.open(link.directory) + layout.home.setSelection({ server: server.key, directory: link.directory }) + if (link.sessionId) { + navigate(`/${base64Encode(link.directory)}/session/${link.sessionId}`) + continue + } navigate("/") } + + for (const link of collectNewSessionDeepLinks(urls)) { + layout.projects.open(link.directory) + layout.home.setSelection({ server: server.key, directory: link.directory }) + void openNewSession(link.directory, link.prompt) + } } useDeepLinkListener(handleDeepLinks) + async function openNewSession(directory: string, prompt?: string) { + if (!tabs.ready()) await tabs.ready.promise + tabs.newDraft({ server: server.key, directory }, prompt) + } + const update: TitlebarUpdate = { version: () => { const state = platform.updater?.state() diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5215be81413d..291cb52882d4 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1169,7 +1169,7 @@ export default function LegacyLayout(props: ParentProps) { return root } - async function navigateToProject(directory: string | undefined) { + async function navigateToProject(directory: string | undefined, sessionId?: string) { if (!directory) return const root = projectRoot(directory) server.projects.touch(root) @@ -1209,6 +1209,11 @@ export default function LegacyLayout(props: ParentProps) { return true } + if (sessionId) { + await refreshDirs(directory) + if (await openSession({ directory, id: sessionId })) return + } + const projectSession = store.lastProjectSession[root] if (projectSession?.id) { await refreshDirs(projectSession.directory) @@ -1249,16 +1254,16 @@ export default function LegacyLayout(props: ParentProps) { navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`) } - function openProject(directory: string, navigate = true) { + function openProject(directory: string, navigate = true, sessionId?: string) { layout.projects.open(directory) - if (navigate) return navigateToProject(directory) + if (navigate) return navigateToProject(directory, sessionId) } const handleDeepLinks = (urls: string[]) => { if (!server.isLocal()) return - for (const directory of collectOpenProjectDeepLinks(urls)) { - void openProject(directory) + for (const link of collectOpenProjectDeepLinks(urls)) { + void openProject(link.directory, true, link.sessionId) } for (const link of collectNewSessionDeepLinks(urls)) { diff --git a/packages/app/src/pages/layout/deep-links.ts b/packages/app/src/pages/layout/deep-links.ts index 5dca421f7498..1ddc23604b38 100644 --- a/packages/app/src/pages/layout/deep-links.ts +++ b/packages/app/src/pages/layout/deep-links.ts @@ -1,5 +1,10 @@ export const deepLinkEvent = "opencode:deep-link" +export type OpenProjectDeepLink = { + directory: string + sessionId?: string +} + const parseUrl = (input: string) => { if (!input.startsWith("opencode://")) return if (typeof URL.canParse === "function" && !URL.canParse(input)) return @@ -16,7 +21,9 @@ export const parseDeepLink = (input: string) => { if (url.hostname !== "open-project") return const directory = url.searchParams.get("directory") if (!directory) return - return directory + const sessionId = url.searchParams.get("session") || undefined + if (!sessionId) return { directory } + return { directory, sessionId } } export const parseNewSessionDeepLink = (input: string) => { @@ -31,7 +38,7 @@ export const parseNewSessionDeepLink = (input: string) => { } export const collectOpenProjectDeepLinks = (urls: string[]) => - urls.map(parseDeepLink).filter((directory): directory is string => !!directory) + urls.map(parseDeepLink).filter((link): link is OpenProjectDeepLink => !!link) export const collectNewSessionDeepLinks = (urls: string[]) => urls.map(parseNewSessionDeepLink).filter((link): link is { directory: string; prompt?: string } => !!link) diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index df87ddfecd2a..e44bbf02d048 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -38,7 +38,14 @@ const session = (input: Partial & Pick) => describe("layout deep links", () => { test("parses open-project deep links", () => { - expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo") + expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toEqual({ directory: "/tmp/demo" }) + }) + + test("parses open-project deep links with a session", () => { + expect(parseDeepLink("opencode://open-project?directory=/tmp/demo&session=ses_123")).toEqual({ + directory: "/tmp/demo", + sessionId: "ses_123", + }) }) test("ignores non-project deep links", () => { @@ -55,7 +62,7 @@ describe("layout deep links", () => { const original = Object.getOwnPropertyDescriptor(URL, "canParse") Object.defineProperty(URL, "canParse", { configurable: true, value: undefined }) try { - expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo") + expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toEqual({ directory: "/tmp/demo" }) } finally { if (original) Object.defineProperty(URL, "canParse", original) if (!original) Reflect.deleteProperty(URL, "canParse") @@ -73,7 +80,7 @@ describe("layout deep links", () => { "opencode://other?directory=/b", "opencode://open-project?directory=/c", ]) - expect(result).toEqual(["/a", "/c"]) + expect(result).toEqual([{ directory: "/a" }, { directory: "/c" }]) }) test("parses new-session deep links with optional prompt", () => { From 5ca9dcc26a17e144459763b8d335088fd1c7f346 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Thu, 9 Jul 2026 11:55:44 +0100 Subject: [PATCH 3/6] refactor(app): extract desktop deep links --- packages/app/src/pages/layout-new.tsx | 38 +--------------- .../pages/layout/use-desktop-deep-links.ts | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+), 36 deletions(-) create mode 100644 packages/app/src/pages/layout/use-desktop-deep-links.ts diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx index c5a49fb4bee1..885c7e043b36 100644 --- a/packages/app/src/pages/layout-new.tsx +++ b/packages/app/src/pages/layout-new.tsx @@ -1,57 +1,23 @@ import { createEffect, Suspense, type ParentProps } from "solid-js" import { useNavigate } from "@solidjs/router" -import { base64Encode } from "@opencode-ai/core/util/encode" import { DebugBar } from "@/components/debug-bar" import { HelpButton } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" -import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" -import { useServer } from "@/context/server" -import { useTabs } from "@/context/tabs" import { setNavigate } from "@/utils/notification-click" import { setV2Toast, ToastRegion } from "@/utils/toast" import { useSettingsCommand } from "@/components/settings-dialog" -import { collectNewSessionDeepLinks, collectOpenProjectDeepLinks } from "./layout/deep-links" -import { useDeepLinkListener } from "./layout/use-deep-link-listener" +import { useDesktopDeepLinks } from "./layout/use-desktop-deep-links" export default function NewLayout(props: ParentProps) { - const layout = useLayout() const platform = usePlatform() - const server = useServer() - const tabs = useTabs() const navigate = useNavigate() setNavigate(navigate) useSettingsCommand() + useDesktopDeepLinks() createEffect(() => setV2Toast(true)) - const handleDeepLinks = (urls: string[]) => { - if (!server.isLocal()) return - - for (const link of collectOpenProjectDeepLinks(urls)) { - layout.projects.open(link.directory) - layout.home.setSelection({ server: server.key, directory: link.directory }) - if (link.sessionId) { - navigate(`/${base64Encode(link.directory)}/session/${link.sessionId}`) - continue - } - navigate("/") - } - - for (const link of collectNewSessionDeepLinks(urls)) { - layout.projects.open(link.directory) - layout.home.setSelection({ server: server.key, directory: link.directory }) - void openNewSession(link.directory, link.prompt) - } - } - - useDeepLinkListener(handleDeepLinks) - - async function openNewSession(directory: string, prompt?: string) { - if (!tabs.ready()) await tabs.ready.promise - tabs.newDraft({ server: server.key, directory }, prompt) - } - const update: TitlebarUpdate = { version: () => { const state = platform.updater?.state() diff --git a/packages/app/src/pages/layout/use-desktop-deep-links.ts b/packages/app/src/pages/layout/use-desktop-deep-links.ts new file mode 100644 index 000000000000..da49189aad2a --- /dev/null +++ b/packages/app/src/pages/layout/use-desktop-deep-links.ts @@ -0,0 +1,44 @@ +import { useNavigate } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { useLayout } from "@/context/layout" +import { ServerConnection, useServer } from "@/context/server" +import { useTabs } from "@/context/tabs" +import { collectNewSessionDeepLinks, collectOpenProjectDeepLinks } from "./deep-links" +import { useDeepLinkListener } from "./use-deep-link-listener" + +export function useDesktopDeepLinks() { + const layout = useLayout() + const navigate = useNavigate() + const server = useServer() + const tabs = useTabs() + + useDeepLinkListener((urls) => { + if (!server.isLocal()) return + + for (const link of collectOpenProjectDeepLinks(urls)) { + layout.projects.open(link.directory) + layout.home.setSelection({ server: server.key, directory: link.directory }) + if (link.sessionId) { + navigate(`/${base64Encode(link.directory)}/session/${link.sessionId}`) + continue + } + navigate("/") + } + + for (const link of collectNewSessionDeepLinks(urls)) { + layout.projects.open(link.directory) + layout.home.setSelection({ server: server.key, directory: link.directory }) + void openNewSession(tabs, server.key, link.directory, link.prompt) + } + }) +} + +async function openNewSession( + tabs: ReturnType, + server: ServerConnection.Key, + directory: string, + prompt?: string, +) { + if (!tabs.ready()) await tabs.ready.promise + tabs.newDraft({ server, directory }, prompt) +} From dd14bac47c2c0693c8d1b4654182bfcf1db70dc0 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Thu, 9 Jul 2026 12:11:42 +0100 Subject: [PATCH 4/6] refactor(app): move auto-accept out of prompt input --- packages/app/src/components/prompt-input.tsx | 8 -------- .../app/src/components/prompt-input/submit.test.ts | 11 ++++++----- packages/app/src/components/prompt-input/submit.ts | 3 +-- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 324a789825ae..ce98e0d0d922 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -47,7 +47,6 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" -import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { createSessionTabs } from "@/pages/session/helpers" @@ -211,7 +210,6 @@ export const PromptInput: Component = (props) => { const comments = useComments() const dialog = useDialog() const command = useCommand() - const permission = usePermission() const language = useLanguage() const platform = usePlatform() const tabs = () => props.controls.session.tabs @@ -1299,11 +1297,6 @@ export const PromptInput: Component = (props) => { const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) // Check provider variants directly: `variants` also includes the UI-only default option. const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0) - const accepting = createMemo(() => { - const id = props.controls.session.id - if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) - return permission.isAutoAccepting(id, sdk().directory) - }) const { abort, handleSubmit } = props.submission ?? @@ -1312,7 +1305,6 @@ export const PromptInput: Component = (props) => { info, imageAttachments, commentCount, - autoAccept: () => accepting(), mode: () => store.mode, working, editor: () => editorRef, diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 9a65fa101dd1..3da981b3e338 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -26,6 +26,7 @@ let params: { id?: string } = {} let search: { draftId?: string } = {} let selected = "/repo/worktree-a" let variant: string | undefined +let autoAcceptDirectory = false const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const prompt = { @@ -119,6 +120,8 @@ beforeAll(async () => { mock.module("@/context/permission", () => ({ usePermission: () => ({ + isAutoAccepting: () => false, + isAutoAcceptingDirectory: () => autoAcceptDirectory, enableAutoAccept(sessionID: string, directory: string) { enabledAutoAccept.push({ sessionID, directory }) }, @@ -246,6 +249,7 @@ beforeEach(() => { syncedDirectories.length = 0 selected = "/repo/worktree-a" variant = undefined + autoAcceptDirectory = false for (const key of Object.keys(storedSessions)) delete storedSessions[key] }) @@ -256,7 +260,6 @@ describe("prompt submit worktree selection", () => { info: () => undefined, imageAttachments: () => [], commentCount: () => 0, - autoAccept: () => false, mode: () => "shell", working: () => false, editor: () => undefined, @@ -289,12 +292,13 @@ describe("prompt submit worktree selection", () => { }) test("applies auto-accept to newly created sessions", async () => { + autoAcceptDirectory = true + const submit = createPromptSubmit({ prompt, info: () => undefined, imageAttachments: () => [], commentCount: () => 0, - autoAccept: () => true, mode: () => "shell", working: () => false, editor: () => undefined, @@ -323,7 +327,6 @@ describe("prompt submit worktree selection", () => { info: () => undefined, imageAttachments: () => [], commentCount: () => 0, - autoAccept: () => false, mode: () => "normal", working: () => false, editor: () => undefined, @@ -352,7 +355,6 @@ describe("prompt submit worktree selection", () => { info: () => ({ id: "session-1" }), imageAttachments: () => [], commentCount: () => 0, - autoAccept: () => false, mode: () => "normal", working: () => false, editor: () => undefined, @@ -384,7 +386,6 @@ describe("prompt submit worktree selection", () => { info: () => undefined, imageAttachments: () => [], commentCount: () => 0, - autoAccept: () => false, mode: () => "normal", working: () => false, editor: () => undefined, diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 05d978a947ce..a77055fe5eca 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -175,7 +175,6 @@ type PromptSubmitInput = { info: Accessor<{ id: string } | undefined> imageAttachments: Accessor commentCount: Accessor - autoAccept: Accessor mode: Accessor<"normal" | "shell"> working: Accessor editor: () => HTMLDivElement | undefined @@ -314,7 +313,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const projectDirectory = sdk().directory const isNewSession = !params.id - const shouldAutoAccept = isNewSession && input.autoAccept() + const shouldAutoAccept = isNewSession && permission.isAutoAcceptingDirectory(projectDirectory) const worktreeSelection = input.newSessionWorktree?.() || "main" let sessionDirectory = projectDirectory From 1266fdb41cc08217c91c8ba8c26fcbef2613822d Mon Sep 17 00:00:00 2001 From: anduimagui Date: Thu, 9 Jul 2026 12:35:30 +0100 Subject: [PATCH 5/6] fix(app): restore auto-accept composer control --- packages/app/src/components/prompt-input.tsx | 92 ++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index ce98e0d0d922..b677a05722f6 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -46,6 +46,7 @@ import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialo import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { useCommand } from "@/context/command" +import { usePermission } from "@/context/permission" import { Persist, persisted } from "@/utils/persist" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -210,6 +211,7 @@ export const PromptInput: Component = (props) => { const comments = useComments() const dialog = useDialog() const command = useCommand() + const permission = usePermission() const language = useLanguage() const platform = usePlatform() const tabs = () => props.controls.session.tabs @@ -1297,6 +1299,29 @@ export const PromptInput: Component = (props) => { const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) // Check provider variants directly: `variants` also includes the UI-only default option. const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0) + const autoAcceptActive = createMemo(() => { + const sessionID = props.controls.session.id + if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory) + return permission.isAutoAcceptingDirectory(sdk().directory) + }) + + const toggleAutoAccept = () => { + const sessionID = props.controls.session.id + if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory) + else permission.toggleAutoAcceptDirectory(sdk().directory) + + const active = sessionID + ? permission.isAutoAccepting(sessionID, sdk().directory) + : permission.isAutoAcceptingDirectory(sdk().directory) + showToast({ + title: active + ? language.t("toast.permissions.autoaccept.on.title") + : language.t("toast.permissions.autoaccept.off.title"), + description: active + ? language.t("toast.permissions.autoaccept.on.description") + : language.t("toast.permissions.autoaccept.off.description"), + }) + } const { abort, handleSubmit } = props.submission ?? @@ -1796,6 +1821,39 @@ export const PromptInput: Component = (props) => { + + {language.t( + autoAcceptActive() + ? "command.permissions.autoaccept.disable" + : "command.permissions.autoaccept.enable", + )} + + + } + > + + = (props) => { +
+ + + +
From 99980955d1d27ae5ea7a818e1315a712fee08159 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Thu, 9 Jul 2026 22:51:18 +0100 Subject: [PATCH 6/6] refactor: isolate desktop bootstrap seams --- packages/app/src/context/settings.tsx | 16 +- packages/desktop/src/main/index.ts | 18 +- packages/desktop/src/main/startup.ts | 25 ++ packages/desktop/src/renderer/index.tsx | 256 +----------------- packages/desktop/src/renderer/platform.ts | 213 +++++++++++++++ .../desktop/src/renderer/window-router.tsx | 31 +++ packages/desktop/src/renderer/window-state.ts | 14 + 7 files changed, 304 insertions(+), 269 deletions(-) create mode 100644 packages/desktop/src/main/startup.ts create mode 100644 packages/desktop/src/renderer/platform.ts create mode 100644 packages/desktop/src/renderer/window-router.tsx create mode 100644 packages/desktop/src/renderer/window-state.ts diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 8d4f61079c93..3388e3fd04e0 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -175,12 +175,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setStore("general", "followup", "steer") }) - return { - ready, - get current() { - return store - }, - general: { + function createGeneralSettings() { + return { autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave), setAutoSave(value: boolean) { setStore("general", "autoSave", value) @@ -252,7 +248,15 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setNewLayoutDesigns(value: boolean) { setStore("general", "newLayoutDesigns", value) }, + } + } + + return { + ready, + get current() { + return store }, + general: createGeneralSettings(), visibility: { fileTree: visible(showFileTree), search: visible(showSearch), diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index acceabfef0a6..4b28f18546d6 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -40,8 +40,7 @@ import { import { createWslServersController } from "./wsl/servers" import { registerWslIpcHandlers } from "./wsl/ipc" import { spawnWslSidecar } from "./wsl/sidecar" -import { migrate } from "./migrate" -import { cleanupStoreFiles } from "./store-cleanup" +import { runStartupTasks } from "./startup" const APP_NAMES: Record = { dev: "OpenCode Dev", @@ -245,20 +244,7 @@ const main = Effect.gen(function* () { yield* Effect.promise(() => app.whenReady()) - if (!TEST_ONBOARDING) migrate() - yield* Effect.promise(() => cleanupStoreFiles(app.getPath("userData"))).pipe( - Effect.tap((result) => - Effect.sync(() => { - if (result.deleted.length === 0) return - logger.log("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned }) - }), - ), - Effect.catch((error) => - Effect.sync(() => { - logger.warn("failed to clean scoped store files", error) - }), - ), - ) + yield* runStartupTasks(logger, app.getPath("userData"), TEST_ONBOARDING) app.setAsDefaultProtocolClient("opencode") registerRendererProtocol() setDockIcon() diff --git a/packages/desktop/src/main/startup.ts b/packages/desktop/src/main/startup.ts new file mode 100644 index 000000000000..3700d98e9620 --- /dev/null +++ b/packages/desktop/src/main/startup.ts @@ -0,0 +1,25 @@ +import { Effect } from "effect" +import { initLogging } from "./logging" +import { migrate } from "./migrate" +import { cleanupStoreFiles } from "./store-cleanup" + +type StartupLogger = Pick, "log" | "warn"> + +export function runStartupTasks(logger: StartupLogger, userDataPath: string, testOnboarding: boolean) { + return Effect.gen(function* () { + if (!testOnboarding) migrate() + yield* Effect.promise(() => cleanupStoreFiles(userDataPath)).pipe( + Effect.tap((result) => + Effect.sync(() => { + if (result.deleted.length === 0) return + logger.log("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned }) + }), + ), + Effect.catch((error) => + Effect.sync(() => { + logger.warn("failed to clean scoped store files", error) + }), + ), + ) + }) +} diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 966fb0c0bf3a..87e56ac45800 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -16,19 +16,20 @@ import { } from "@opencode-ai/app" import type { UpdaterState } from "@opencode-ai/app/updater" import * as Sentry from "@sentry/solid" -import type { AsyncStorage } from "@solid-primitives/storage" -import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidjs/router" +import type { BaseRouterProps } from "@solidjs/router" import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js" import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" import { initializationData, initializationReady } from "./initialization" import { DesktopFirstLaunchOnboarding } from "./onboarding" -import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" +import { createDesktopPlatform } from "./platform" import { availableStartupServer, readyWslConnections } from "./wsl/connections" -import "./styles.css" import { Splash } from "@opencode-ai/ui/logo" import { useTheme } from "@opencode-ai/ui/theme/context" +import "./styles.css" +import { DesktopMemoryRouter, getLastActiveUrl } from "./window-router" +import { readDesktopWindowState, type DesktopWindowState } from "./window-state" const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { @@ -65,10 +66,6 @@ void window.api.updater.subscribe(setUpdaterState) const deepLinkEvent = "opencode:deep-link" -type DesktopWindowState = { - id?: string -} - const emitDeepLinks = (urls: string[]) => { if (urls.length === 0) return window.__OPENCODE__ ??= {} @@ -82,234 +79,6 @@ const listenForDeepLinks = () => { return window.api.onDeepLink((urls) => emitDeepLinks(urls)) } -function windowLastActiveUrlKey(windowID: string) { - return `opencode.desktop.window.${windowID}.last-active-url` -} - -function getLastActiveUrl(windowID: string) { - if (typeof localStorage !== "object") return "/" - try { - const value = localStorage.getItem(windowLastActiveUrlKey(windowID)) - if (value?.startsWith("/") && !value.startsWith("//")) return value - } catch {} - return "/" -} - -function setLastActiveUrl(windowID: string, value: string) { - if (typeof localStorage !== "object") return - try { - localStorage.setItem(windowLastActiveUrlKey(windowID), value) - } catch {} -} - -function DesktopMemoryRouter(props: BaseRouterProps & { windowID: string }) { - const history = createMemoryHistory() - const initialUrl = getLastActiveUrl(props.windowID) - if (initialUrl !== "/") history.set({ value: initialUrl, replace: true, scroll: false }) - onCleanup(history.listen((value) => setLastActiveUrl(props.windowID, value))) - return -} - -const createPlatform = (windowState: DesktopWindowState): Platform => { - const attachmentPaths = new WeakMap() - const os = (() => { - const ua = navigator.userAgent - if (ua.includes("Mac")) return "macos" - if (ua.includes("Windows")) return "windows" - if (ua.includes("Linux")) return "linux" - return undefined - })() - - const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => { - switch (action) { - case "view.resetZoom": - resetZoom() - return - case "view.zoomIn": - zoomIn() - return - case "view.zoomOut": - zoomOut() - return - } - - return window.api.runDesktopMenuAction(action) - } - - const storage = (() => { - const cache = new Map() - - const createStorage = (name: string) => { - const api: AsyncStorage = { - getItem: (key: string) => window.api.storeGet(name, key), - setItem: (key: string, value: string) => window.api.storeSet(name, key, value), - removeItem: (key: string) => window.api.storeDelete(name, key), - clear: () => window.api.storeClear(name), - key: async (index: number) => (await window.api.storeKeys(name))[index], - getLength: () => window.api.storeLength(name), - get length() { - return api.getLength() - }, - } - return api - } - - return (name = "default.dat") => { - const cached = cache.get(name) - if (cached) return cached - const api = createStorage(name) - cache.set(name, api) - return api - } - })() - - const wslServersApi = os === "windows" ? window.api.wslServers : undefined - - return { - platform: "desktop", - os, - version: pkg.version, - windowID: windowState.id, - - async openDirectoryPickerDialog(opts) { - return window.api.openDirectoryPicker({ - multiple: opts?.multiple ?? false, - title: opts?.title ?? t("desktop.dialog.chooseFolder"), - }) - }, - - async openAttachmentPickerDialog(opts, onFile) { - const result = await window.api.openFilePicker({ - multiple: opts?.multiple ?? false, - title: opts?.title ?? t("desktop.dialog.chooseFile"), - defaultPath: opts?.defaultPath, - extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS, - }) - if (!result) return - try { - for (const file of result.files) { - const selected = new File([await window.api.readPickedFile(result.token, file.path)], file.name) - attachmentPaths.set(selected, file.path) - await onFile(selected) - } - } finally { - await window.api.releasePickedFiles(result.token) - } - }, - - getPathForFile(file) { - return attachmentPaths.get(file) ?? window.api.getPathForFile(file) - }, - - async saveFilePickerDialog(opts) { - return window.api.saveFilePicker({ - title: opts?.title ?? t("desktop.dialog.saveFile"), - defaultPath: opts?.defaultPath, - }) - }, - - openLink(url: string) { - window.api.openLink(url) - }, - async openPath(path: string, app?: string) { - if (os === "windows") { - const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null - return window.api.openPath(path, resolvedApp ?? undefined) - } - return window.api.openPath(path, app) - }, - - back() { - window.history.back() - }, - - forward() { - window.history.forward() - }, - - storage, - - updater: { - state: updaterState, - check: () => window.api.updater.check(), - install: () => window.api.updater.install(), - }, - - exportDebugLogs: () => window.api.exportDebugLogs(), - - recordFatalRendererError: (error) => window.api.recordFatalRendererError(error), - - restart: async () => { - await window.api.killSidecar().catch(() => undefined) - window.api.relaunch() - }, - - notify: async (title, description, href) => { - const focused = await window.api.getWindowFocused().catch(() => document.hasFocus()) - if (focused) return - - const notification = new Notification(title, { - body: description ?? "", - icon: "https://opencode.ai/favicon-96x96-v3.png", - }) - notification.onclick = () => { - void window.api.showWindow() - void window.api.setWindowFocus() - handleNotificationClick(href) - notification.close() - } - }, - - fetch: (input, init) => { - if (input instanceof Request) return fetch(input) - return fetch(input, init) - }, - - getDefaultServer: async () => { - const url = await window.api.getDefaultServerUrl().catch(() => null) - if (!url) return null - return ServerConnection.Key.make(url) - }, - - setDefaultServer: async (url: string | null) => { - await window.api.setDefaultServerUrl(url) - }, - - wslServers: wslServersApi, - - getDisplayBackend: async () => { - return window.api.getDisplayBackend().catch(() => null) - }, - - setDisplayBackend: async (backend) => { - await window.api.setDisplayBackend(backend) - }, - - parseMarkdown: (markdown: string) => window.api.parseMarkdownCommand(markdown), - - webviewZoom, - - getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(), - - setPinchZoomEnabled, - - runDesktopMenuAction, - - checkAppExists: async (appName: string) => { - return window.api.checkAppExists(appName) - }, - - async readClipboardImage() { - const image = await window.api.readClipboardImage().catch(() => null) - if (!image) return null - const blob = new Blob([image.buffer], { type: "image/png" }) - return new File([blob], `pasted-image-${Date.now()}.png`, { - type: "image/png", - }) - }, - } -} - let menuTrigger = null as null | ((id: string) => void) window.api.onMenuCommand((id) => { menuTrigger?.(id) @@ -325,7 +94,7 @@ function LoadingSplash() { } function DesktopRoot(props: { windowState: DesktopWindowState }) { - const platform = createPlatform(props.windowState) + const platform = createDesktopPlatform(props.windowState, updaterState) const loadLocale = async () => { const current = await platform.storage?.("opencode.global.dat").getItem("language") const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") @@ -345,9 +114,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { const [defaultServer] = createResource(() => platform.getDefaultServer?.()) const [locale] = createResource(loadLocale) - const router = (props: BaseRouterProps) => ( - - ) + const router = (routerProps: BaseRouterProps) => const onboarding = Promise.withResolvers() function handleClick(e: MouseEvent) { @@ -413,7 +180,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { startup={onboarding.promise} serverScoped={ } @@ -443,12 +210,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { } render(() => { - const [windowState] = createResource(async () => { - const api = window.api as typeof window.api & { - getWindowID?: () => Promise - } - return { id: await api.getWindowID?.() } - }) + const [windowState] = createResource(readDesktopWindowState) return ( } keyed> diff --git a/packages/desktop/src/renderer/platform.ts b/packages/desktop/src/renderer/platform.ts new file mode 100644 index 000000000000..f40449629c30 --- /dev/null +++ b/packages/desktop/src/renderer/platform.ts @@ -0,0 +1,213 @@ +import { + ACCEPTED_FILE_EXTENSIONS, + handleNotificationClick, + type Platform, + ServerConnection, +} from "@opencode-ai/app" +import type { UpdaterState } from "@opencode-ai/app/updater" +import type { AsyncStorage } from "@solid-primitives/storage" +import type { Accessor } from "solid-js" +import pkg from "../../package.json" +import { t } from "./i18n" +import { getDesktopWindowID, type DesktopWindowState } from "./window-state" +import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" + +export function createDesktopPlatform(windowState: DesktopWindowState, updaterState: Accessor): Platform { + const attachmentPaths = new WeakMap() + const os = (() => { + const ua = navigator.userAgent + if (ua.includes("Mac")) return "macos" + if (ua.includes("Windows")) return "windows" + if (ua.includes("Linux")) return "linux" + return undefined + })() + + const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => { + switch (action) { + case "view.resetZoom": + resetZoom() + return + case "view.zoomIn": + zoomIn() + return + case "view.zoomOut": + zoomOut() + return + } + + return window.api.runDesktopMenuAction(action) + } + + const storage = (() => { + const cache = new Map() + + const createStorage = (name: string) => { + const api: AsyncStorage = { + getItem: (key: string) => window.api.storeGet(name, key), + setItem: (key: string, value: string) => window.api.storeSet(name, key, value), + removeItem: (key: string) => window.api.storeDelete(name, key), + clear: () => window.api.storeClear(name), + key: async (index: number) => (await window.api.storeKeys(name))[index], + getLength: () => window.api.storeLength(name), + get length() { + return api.getLength() + }, + } + return api + } + + return (name = "default.dat") => { + const cached = cache.get(name) + if (cached) return cached + const api = createStorage(name) + cache.set(name, api) + return api + } + })() + + const wslServersApi = os === "windows" ? window.api.wslServers : undefined + + return { + platform: "desktop", + os, + version: pkg.version, + windowID: getDesktopWindowID(windowState), + + async openDirectoryPickerDialog(opts) { + return window.api.openDirectoryPicker({ + multiple: opts?.multiple ?? false, + title: opts?.title ?? t("desktop.dialog.chooseFolder"), + }) + }, + + async openAttachmentPickerDialog(opts, onFile) { + const result = await window.api.openFilePicker({ + multiple: opts?.multiple ?? false, + title: opts?.title ?? t("desktop.dialog.chooseFile"), + defaultPath: opts?.defaultPath, + extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS, + }) + if (!result) return + try { + for (const file of result.files) { + const selected = new File([await window.api.readPickedFile(result.token, file.path)], file.name) + attachmentPaths.set(selected, file.path) + await onFile(selected) + } + } finally { + await window.api.releasePickedFiles(result.token) + } + }, + + getPathForFile(file) { + return attachmentPaths.get(file) ?? window.api.getPathForFile(file) + }, + + async saveFilePickerDialog(opts) { + return window.api.saveFilePicker({ + title: opts?.title ?? t("desktop.dialog.saveFile"), + defaultPath: opts?.defaultPath, + }) + }, + + openLink(url: string) { + window.api.openLink(url) + }, + async openPath(path: string, app?: string) { + if (os === "windows") { + const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null + return window.api.openPath(path, resolvedApp ?? undefined) + } + return window.api.openPath(path, app) + }, + + back() { + window.history.back() + }, + + forward() { + window.history.forward() + }, + + storage, + + updater: { + state: updaterState, + check: () => window.api.updater.check(), + install: () => window.api.updater.install(), + }, + + exportDebugLogs: () => window.api.exportDebugLogs(), + + recordFatalRendererError: (error) => window.api.recordFatalRendererError(error), + + restart: async () => { + await window.api.killSidecar().catch(() => undefined) + window.api.relaunch() + }, + + notify: async (title, description, href) => { + const focused = await window.api.getWindowFocused().catch(() => document.hasFocus()) + if (focused) return + + const notification = new Notification(title, { + body: description ?? "", + icon: "https://opencode.ai/favicon-96x96-v3.png", + }) + notification.onclick = () => { + void window.api.showWindow() + void window.api.setWindowFocus() + handleNotificationClick(href) + notification.close() + } + }, + + fetch: (input, init) => { + if (input instanceof Request) return fetch(input) + return fetch(input, init) + }, + + getDefaultServer: async () => { + const url = await window.api.getDefaultServerUrl().catch(() => null) + if (!url) return null + return ServerConnection.Key.make(url) + }, + + setDefaultServer: async (url: string | null) => { + await window.api.setDefaultServerUrl(url) + }, + + wslServers: wslServersApi, + + getDisplayBackend: async () => { + return window.api.getDisplayBackend().catch(() => null) + }, + + setDisplayBackend: async (backend) => { + await window.api.setDisplayBackend(backend) + }, + + parseMarkdown: (markdown: string) => window.api.parseMarkdownCommand(markdown), + + webviewZoom, + + getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(), + + setPinchZoomEnabled, + + runDesktopMenuAction, + + checkAppExists: async (appName: string) => { + return window.api.checkAppExists(appName) + }, + + async readClipboardImage() { + const image = await window.api.readClipboardImage().catch(() => null) + if (!image) return null + const blob = new Blob([image.buffer], { type: "image/png" }) + return new File([blob], `pasted-image-${Date.now()}.png`, { + type: "image/png", + }) + }, + } +} diff --git a/packages/desktop/src/renderer/window-router.tsx b/packages/desktop/src/renderer/window-router.tsx new file mode 100644 index 000000000000..05cd8a0f7095 --- /dev/null +++ b/packages/desktop/src/renderer/window-router.tsx @@ -0,0 +1,31 @@ +import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidjs/router" +import { onCleanup } from "solid-js" +import { getDesktopWindowID, type DesktopWindowState } from "./window-state" + +function windowLastActiveUrlKey(windowID: string) { + return `opencode.desktop.window.${windowID}.last-active-url` +} + +export function getLastActiveUrl(windowState: DesktopWindowState) { + if (typeof localStorage !== "object") return "/" + try { + const value = localStorage.getItem(windowLastActiveUrlKey(getDesktopWindowID(windowState))) + if (value?.startsWith("/") && !value.startsWith("//")) return value + } catch {} + return "/" +} + +function setLastActiveUrl(windowState: DesktopWindowState, value: string) { + if (typeof localStorage !== "object") return + try { + localStorage.setItem(windowLastActiveUrlKey(getDesktopWindowID(windowState)), value) + } catch {} +} + +export function DesktopMemoryRouter(props: BaseRouterProps & { windowState: DesktopWindowState }) { + const history = createMemoryHistory() + const initialUrl = getLastActiveUrl(props.windowState) + if (initialUrl !== "/") history.set({ value: initialUrl, replace: true, scroll: false }) + onCleanup(history.listen((value) => setLastActiveUrl(props.windowState, value))) + return +} diff --git a/packages/desktop/src/renderer/window-state.ts b/packages/desktop/src/renderer/window-state.ts new file mode 100644 index 000000000000..28105d2a9423 --- /dev/null +++ b/packages/desktop/src/renderer/window-state.ts @@ -0,0 +1,14 @@ +export type DesktopWindowState = { + id?: string +} + +export function getDesktopWindowID(windowState: DesktopWindowState) { + return windowState.id ?? "browser" +} + +export async function readDesktopWindowState() { + const api = window.api as typeof window.api & { + getWindowID?: () => Promise + } + return { id: await api.getWindowID?.() } satisfies DesktopWindowState +}