diff --git a/bun.lock b/bun.lock index 2aa72ea94af..5bb303e5a40 100644 --- a/bun.lock +++ b/bun.lock @@ -190,9 +190,11 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@opencode-ai/util": "workspace:*", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", "@tauri-apps/api": "^2", "@tauri-apps/plugin-clipboard-manager": "~2", "@tauri-apps/plugin-deep-link": "~2", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e370862212b..cf010c92620 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -56,13 +56,16 @@ const HomeRoute = () => ( ) -const SessionRoute = () => ( - - }> - - - -) +function SessionRoute(props: { sessionChildren?: JSX.Element }) { + return ( + + {props.sessionChildren} + }> + + + + ) +} const SessionIndexRoute = () => @@ -271,6 +274,7 @@ export function AppInterface(props: { servers?: Array router?: Component disableHealthCheck?: boolean + sessionChildren?: JSX.Element }) { return ( @@ -284,7 +288,10 @@ export function AppInterface(props: { - + } + /> diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7f6816de9e3..0c1b3112a9b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -81,6 +81,8 @@ export const dict = { "command.session.redo.description": "Redo the last undone message", "command.session.compact": "Compact session", "command.session.compact.description": "Summarize the session to reduce context size", + "command.session.duplicate": "Duplicate session", + "command.session.duplicate.description": "Create a new chat with the current session history", "command.session.fork": "Fork from message", "command.session.fork.description": "Create a new session from a previous message", "command.session.share": "Share session", diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 6c870dfa4d0..d2baaa96416 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,5 +1,8 @@ export { AppBaseProviders, AppInterface } from "./app" export { useCommand } from "./context/command" +export { useLanguage } from "./context/language" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" +export { usePrompt } from "./context/prompt" +export { useSDK } from "./context/sdk" export { ServerConnection } from "./context/server" export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 73ec5278f6e..64709645a3e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -15,8 +15,10 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@opencode-ai/util": "workspace:*", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", + "@solidjs/router": "catalog:", "@tauri-apps/api": "^2", "@tauri-apps/plugin-clipboard-manager": "~2", "@tauri-apps/plugin-deep-link": "~2", diff --git a/packages/desktop/src/duplicate-command.ts b/packages/desktop/src/duplicate-command.ts new file mode 100644 index 00000000000..a0eabced98b --- /dev/null +++ b/packages/desktop/src/duplicate-command.ts @@ -0,0 +1,73 @@ +import { base64Encode } from "@opencode-ai/util/encode" + +type Fork = { data?: { id: string } | null } + +export type State = { + current(): unknown + cursor(): number | undefined + set(value: unknown, cursor?: number, scope?: unknown): void +} + +export type SDK = { + directory: string + client: { + session: { + fork: (opts: { sessionID: string }) => Promise + } + } +} + +export function duplicateSession(opts: { + id?: string + t: (key: string) => string + prompt: State + sdk: SDK + navigate: (href: string) => void + toast: (opts: { title: string; description?: string }) => void + frame: (cb: FrameRequestCallback) => void +}) { + if (!opts.id) return Promise.resolve() + + const value = opts.prompt.current() + const cursor = opts.prompt.cursor() + const dir = base64Encode(opts.sdk.directory) + + return opts.sdk.client.session + .fork({ sessionID: opts.id }) + .then((result: Fork) => { + if (!result.data) { + opts.toast({ title: opts.t("common.requestFailed") }) + return + } + + opts.navigate(`/${dir}/session/${result.data.id}`) + opts.frame(() => { + opts.prompt.set(value, cursor) + }) + }) + .catch((err: unknown) => { + opts.toast({ + title: opts.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + }) +} + +export function duplicateCommand(opts: { + id?: string + t: (key: string) => string + prompt: State + sdk: SDK + navigate: (href: string) => void + toast: (opts: { title: string; description?: string }) => void + frame: (cb: FrameRequestCallback) => void +}) { + return { + id: "desktop.session.duplicate", + title: opts.t("command.session.duplicate"), + description: opts.t("command.session.duplicate.description"), + slash: "duplicate", + disabled: !opts.id, + onSelect: () => duplicateSession(opts), + } +} diff --git a/packages/desktop/src/duplicate.test.ts b/packages/desktop/src/duplicate.test.ts new file mode 100644 index 00000000000..50ccb3d4c32 --- /dev/null +++ b/packages/desktop/src/duplicate.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, mock, test } from "bun:test" +import { base64Encode } from "@opencode-ai/util/encode" +import { duplicateCommand, duplicateSession } from "./duplicate-command" + +function state() { + return { + current: () => [{ type: "text", content: "draft" }], + cursor: () => 3, + set: mock(() => {}), + } +} + +function sdk(result: Promise<{ data?: { id: string } | null }>) { + const fork = mock(() => result) + return { + fork, + value: { + directory: "/tmp/work", + client: { + session: { fork }, + }, + }, + } +} + +describe("duplicate command", () => { + test("disables the slash command without a session id", () => { + const prompt = state() + const cmd = duplicateCommand({ + t: (key) => key, + prompt, + sdk: sdk(Promise.resolve({ data: { id: "next" } })).value, + navigate: mock(() => {}), + toast: mock(() => {}), + frame: (cb) => void cb(0), + }) + + expect(cmd.disabled).toBe(true) + expect(cmd.slash).toBe("duplicate") + }) + + test("forks the session, navigates, and restores the draft", async () => { + const prompt = state() + const nav = mock(() => {}) + const toast = mock(() => {}) + const api = sdk(Promise.resolve({ data: { id: "next" } })) + const frame = mock((cb: FrameRequestCallback) => void cb(0)) + + await duplicateSession({ + id: "sess-1", + t: (key) => key, + prompt, + sdk: api.value, + navigate: nav, + toast, + frame, + }) + + expect(api.fork).toHaveBeenCalledWith({ sessionID: "sess-1" }) + expect(nav).toHaveBeenCalledWith(`/${base64Encode("/tmp/work")}/session/next`) + expect(frame).toHaveBeenCalledTimes(1) + expect(prompt.set).toHaveBeenCalledWith([{ type: "text", content: "draft" }], 3) + expect(toast).not.toHaveBeenCalled() + }) + + test("shows a failure toast when the fork response is empty", async () => { + const prompt = state() + const nav = mock(() => {}) + const toast = mock(() => {}) + + await duplicateSession({ + id: "sess-1", + t: (key) => key, + prompt, + sdk: sdk(Promise.resolve({ data: null })).value, + navigate: nav, + toast, + frame: (cb) => void cb(0), + }) + + expect(nav).not.toHaveBeenCalled() + expect(prompt.set).not.toHaveBeenCalled() + expect(toast).toHaveBeenCalledWith({ title: "common.requestFailed" }) + }) + + test("shows the thrown error message when the fork request fails", async () => { + const toast = mock(() => {}) + + await duplicateSession({ + id: "sess-1", + t: (key) => key, + prompt: state(), + sdk: sdk(Promise.reject(new Error("boom"))).value, + navigate: mock(() => {}), + toast, + frame: (cb) => void cb(0), + }) + + expect(toast).toHaveBeenCalledWith({ + title: "common.requestFailed", + description: "boom", + }) + }) +}) diff --git a/packages/desktop/src/duplicate.tsx b/packages/desktop/src/duplicate.tsx new file mode 100644 index 00000000000..6168f87a833 --- /dev/null +++ b/packages/desktop/src/duplicate.tsx @@ -0,0 +1,27 @@ +import { useCommand, useLanguage, usePrompt, useSDK } from "@opencode-ai/app" +import { showToast } from "@opencode-ai/ui/toast" +import { useNavigate, useParams } from "@solidjs/router" +import { duplicateCommand } from "./duplicate-command" + +export function Duplicate() { + const command = useCommand() + const params = useParams() + const navigate = useNavigate() + const language = useLanguage() + const prompt = usePrompt() + const sdk = useSDK() + + command.register("desktop.session.duplicate", () => [ + duplicateCommand({ + id: params.id, + t: language.t, + prompt, + sdk, + navigate, + toast: showToast, + frame: requestAnimationFrame, + }), + ]) + + return null +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 65149f34bc1..c1a517589bb 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -31,6 +31,7 @@ import "./styles.css" import { Channel } from "@tauri-apps/api/core" import { commands, type InitStep } from "./bindings" import { createMenu } from "./menu" +import { Duplicate } from "./duplicate" const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { @@ -469,6 +470,7 @@ render(() => { } > diff --git a/packages/desktop/tsconfig.json b/packages/desktop/tsconfig.json index 47e63cc87ff..9ef0f12470c 100644 --- a/packages/desktop/tsconfig.json +++ b/packages/desktop/tsconfig.json @@ -12,11 +12,13 @@ "resolveJsonModule": true, "strict": true, "isolatedModules": true, + "disableSourceOfProjectReferenceRedirect": true, "noEmit": true, "emitDeclarationOnly": false, "outDir": "node_modules/.ts-dist", "types": ["vite/client"] }, - "references": [{ "path": "../app" }], - "include": ["src", "package.json"] + "references": [{ "path": "../app" }, { "path": "../sdk/js" }], + "include": ["src", "package.json"], + "exclude": ["src/**/*.test.ts"] } diff --git a/packages/web/src/content/docs/commands.mdx b/packages/web/src/content/docs/commands.mdx index 1d7e4f1c21a..a95c4ed42ba 100644 --- a/packages/web/src/content/docs/commands.mdx +++ b/packages/web/src/content/docs/commands.mdx @@ -9,7 +9,7 @@ Custom commands let you specify a prompt you want to run when that command is ex /my-command ``` -Custom commands are in addition to the built-in commands like `/init`, `/undo`, `/redo`, `/share`, `/help`. [Learn more](/docs/tui#commands). +Custom commands are in addition to the built-in commands like `/init`, `/undo`, `/redo`, `/share`, `/help`. On desktop, `/duplicate` is also available to clone the current session into a new chat. [Learn more](/docs/tui#commands). ---