diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 3f1bf9222faa..74fd5d654174 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { Icon } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client" @@ -43,12 +44,39 @@ export type PromptInputV2ComposerProps = { export type PromptInputV2ControllerProps = Omit export type PromptInputV2ComposerController = PromptInputV2Interaction & { readonly model: PromptInputProps["controls"]["model"] + readonly session: PromptInputProps["controls"]["session"] } export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { const dialog = useDialog() const command = useCommand() const language = useLanguage() + const permission = usePermission() + const sdk = useSDK() + + const autoAcceptActive = createMemo(() => { + const sessionID = props.controller.session.id + if (!sessionID) return permission.isAutoAcceptingDirectory(sdk().directory) + return permission.isAutoAccepting(sessionID, sdk().directory) + }) + + const toggleAutoAccept = () => { + const sessionID = props.controller.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"), + }) + } useCommands(props) useEditHandler(props) @@ -73,6 +101,16 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { } /> } + controls={ + + } /> ) @@ -150,7 +188,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): const comments = useComments() const dialog = useDialog() const command = useCommand() - const permission = usePermission() const language = useLanguage() const platform = usePlatform() const prompt = props.state ?? usePrompt() @@ -251,17 +288,11 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): ) } - const accepting = createMemo(() => { - const id = props.controls.session.id - if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) - return permission.isAutoAccepting(id, sdk().directory) - }) const submission = createPromptSubmit({ prompt, info, imageAttachments: attachments, commentCount, - autoAccept: accepting, mode, working, editor: () => editor, @@ -464,10 +495,45 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): }, }, }) - Object.defineProperty(controller, "model", { get: () => props.controls.model }) + Object.defineProperties(controller, { + model: { get: () => props.controls.model }, + session: { get: () => props.controls.session }, + }) return controller as PromptInputV2ComposerController } +function PromptInputV2PermissionControl(props: { + active: boolean + title: string + keybind: string[] + onClick: () => void +}) { + return ( + + {props.title} + + + } + > + } + variant="ghost-muted" + size="large" + classList={{ "bg-surface-success-base text-icon-success-base": props.active }} + onClick={props.onClick} + aria-label={props.title} + aria-pressed={props.active} + /> + + ) +} + function PromptInputV2ModelControl(props: { loading: boolean paid: boolean diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 625ebdeca332..892a7765bb3e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1192,12 +1192,30 @@ 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 autoAcceptActive = createMemo(() => { + const sessionID = props.controls.session.id + if (!sessionID) return permission.isAutoAcceptingDirectory(sdk().directory) + return permission.isAutoAccepting(sessionID, 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 ?? createPromptSubmit({ @@ -1205,7 +1223,6 @@ export const PromptInput: Component = (props) => { info, imageAttachments, commentCount, - autoAccept: () => accepting(), mode: () => store.mode, working, editor: () => editorRef, @@ -1780,6 +1797,40 @@ export const PromptInput: Component = (props) => { +
+ + + +
diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index f563a509822a..87d3fa8a99f9 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -28,6 +28,7 @@ let params: { id?: string } = {} let search: { draftId?: string } = {} let selected = "/repo/worktree-a" let variant: string | undefined +let autoAcceptDirectory = false let permissionServer = "server-a" let createSessionGate: Promise | undefined @@ -138,7 +139,13 @@ beforeAll(async () => { enabledAutoAccept.push({ server, sessionID, directory }) }, }) - return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) } + return { + usePermission: () => ({ + currentServerState: () => state(permissionServer), + isAutoAccepting: () => false, + isAutoAcceptingDirectory: () => autoAcceptDirectory, + }), + } }) mock.module("@/context/server", () => ({ @@ -262,6 +269,7 @@ beforeEach(() => { syncedDirectories.length = 0 selected = "/repo/worktree-a" variant = undefined + autoAcceptDirectory = false permissionServer = "server-a" createSessionGate = undefined for (const key of Object.keys(storedSessions)) delete storedSessions[key] @@ -274,7 +282,6 @@ describe("prompt submit worktree selection", () => { info: () => undefined, imageAttachments: () => [], commentCount: () => 0, - autoAccept: () => false, mode: () => "shell", working: () => false, editor: () => undefined, @@ -307,12 +314,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, @@ -335,6 +343,7 @@ describe("prompt submit worktree selection", () => { }) test("keeps auto-accept bound to the submission server", async () => { + autoAcceptDirectory = true let release = () => {} createSessionGate = new Promise((resolve) => { release = resolve @@ -344,7 +353,6 @@ describe("prompt submit worktree selection", () => { info: () => undefined, imageAttachments: () => [], commentCount: () => 0, - autoAccept: () => true, mode: () => "shell", working: () => false, editor: () => undefined, @@ -374,7 +382,6 @@ describe("prompt submit worktree selection", () => { info: () => undefined, imageAttachments: () => [], commentCount: () => 0, - autoAccept: () => false, mode: () => "normal", working: () => false, editor: () => undefined, @@ -403,7 +410,6 @@ describe("prompt submit worktree selection", () => { info: () => ({ id: "session-1" }), imageAttachments: () => [], commentCount: () => 0, - autoAccept: () => false, mode: () => "normal", working: () => false, editor: () => undefined, @@ -440,7 +446,6 @@ describe("prompt submit worktree selection", () => { info: () => ({ id: "session-1" }), imageAttachments: () => [], commentCount: () => 0, - autoAccept: () => false, mode: () => "normal", working: () => false, editor: () => undefined, @@ -468,7 +473,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 203722fc6c74..232383978367 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 @@ -317,7 +316,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const projectDirectory = sdk().directory const permissionState = permission.currentServerState() const isNewSession = !params.id - const shouldAutoAccept = isNewSession && input.autoAccept() + const shouldAutoAccept = isNewSession && permission.isAutoAcceptingDirectory(projectDirectory) const worktreeSelection = input.newSessionWorktree?.() || "main" let sessionDirectory = projectDirectory diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index c2b568041832..f8fbf9891f41 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -330,12 +330,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) @@ -425,7 +421,15 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont dismissTabsToast() { setStore("general", "shouldDisplayTabsToast", false) }, + } + } + + return { + ready, + get current() { + return store }, + general: createGeneralSettings(), visibility: { fileTree: visible(showFileTree), search: visible(showSearch), diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx index fd0df68d2791..abba1984161a 100644 --- a/packages/app/src/pages/layout-new.tsx +++ b/packages/app/src/pages/layout-new.tsx @@ -6,11 +6,13 @@ import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { usePlatform } from "@/context/platform" import { setNavigate } from "@/utils/notification-click" import { setV2Toast, ToastRegion } from "@/utils/toast" +import { useDesktopDeepLinks } from "./layout/use-desktop-deep-links" export default function NewLayout(props: ParentProps) { const platform = usePlatform() const navigate = useNavigate() setNavigate(navigate) + useDesktopDeepLinks() createEffect(() => setV2Toast(true)) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 545eee827a0b..fb27a686b631 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, @@ -1170,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) @@ -1210,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) @@ -1250,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)) { @@ -1275,17 +1279,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/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", () => { 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) + }) +} 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) +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index c7c1643092d1..20371271f61e 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -46,8 +46,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", @@ -252,20 +251,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 b7d2d9a74dad..ec7bf4f125f5 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,239 +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) - }, - async revealPath(path: string) { - return window.api.revealPath(path) - }, - - 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(), - - setForceFocus: (enabled) => window.api.setForceFocus(enabled), - - 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) @@ -330,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") @@ -350,8 +114,8 @@ 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() @@ -419,7 +183,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { startup={onboarding.promise} serverScoped={ } @@ -449,12 +213,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..338aa5ddd1e5 --- /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) + }, + async revealPath(path: string) { + return window.api.revealPath(path) + }, + + 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(), + + setForceFocus: (enabled) => window.api.setForceFocus(enabled), + + 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 +} diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index ff33de56a8a4..15c697b33f60 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -38,6 +38,7 @@ export type PromptInputV2Props = { readOnly?: boolean class?: string modelControl?: JSX.Element + controls?: JSX.Element } export function PromptInputV2(props: PromptInputV2Props) { @@ -232,6 +233,7 @@ export function PromptInputV2(props: PromptInputV2Props) { )} + {props.controls}