From f8dc02c5f0ab7507eb6fb23db3d417306ddc6302 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 18 Mar 2026 17:33:55 +0000 Subject: [PATCH 1/4] desktop: add duplicate session command Register the desktop duplicate command in the command list and add the dependencies it needs. --- bun.lock | 2 ++ packages/desktop/package.json | 2 ++ packages/desktop/src/duplicate.tsx | 51 ++++++++++++++++++++++++++++++ packages/desktop/src/index.tsx | 2 ++ packages/desktop/tsconfig.json | 3 +- 5 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 packages/desktop/src/duplicate.tsx 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/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.tsx b/packages/desktop/src/duplicate.tsx new file mode 100644 index 00000000000..584f701e65e --- /dev/null +++ b/packages/desktop/src/duplicate.tsx @@ -0,0 +1,51 @@ +import { useCommand, useLanguage, usePrompt, useSDK } from "@opencode-ai/app" +import { base64Encode } from "@opencode-ai/util/encode" +import { showToast } from "@opencode-ai/ui/toast" +import { useNavigate, useParams } from "@solidjs/router" + +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", () => [ + { + id: "desktop.session.duplicate", + title: language.t("command.session.duplicate"), + description: language.t("command.session.duplicate.description"), + slash: "duplicate", + disabled: !params.id, + onSelect: () => { + const sessionID = params.id + if (!sessionID) return + + const dir = base64Encode(sdk.directory) + const value = prompt.current() + const cursor = prompt.cursor() + sdk.client.session + .fork({ sessionID }) + .then((result: { data?: { id: string } | null }) => { + if (!result.data) { + showToast({ title: language.t("common.requestFailed") }) + return + } + + prompt.set(value, cursor, { dir, id: result.data.id }) + navigate(`/${dir}/session/${result.data.id}`) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ + title: language.t("common.requestFailed"), + description: message, + }) + }) + }, + }, + ]) + + 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..3b689b85dae 100644 --- a/packages/desktop/tsconfig.json +++ b/packages/desktop/tsconfig.json @@ -12,11 +12,12 @@ "resolveJsonModule": true, "strict": true, "isolatedModules": true, + "disableSourceOfProjectReferenceRedirect": true, "noEmit": true, "emitDeclarationOnly": false, "outDir": "node_modules/.ts-dist", "types": ["vite/client"] }, - "references": [{ "path": "../app" }], + "references": [{ "path": "../app" }, { "path": "../sdk/js" }], "include": ["src", "package.json"] } From f47e5c20cf4278b4078258887bdae99f3760dd2d Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 18 Mar 2026 17:38:30 +0000 Subject: [PATCH 2/4] docs: describe desktop duplicate command Add the duplicate session label copy and mention the desktop-only slash command in the commands docs. --- packages/app/src/i18n/en.ts | 2 ++ packages/web/src/content/docs/commands.mdx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) 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/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). --- From aa923439478e7c9c65037375e1544d3f4ec7aec3 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 18 Mar 2026 17:42:06 +0000 Subject: [PATCH 3/4] fix(app): wire desktop duplicate command into session state Mount desktop session commands inside the session providers, export the hooks they need, and restore the prompt after navigating to the duplicated session. --- packages/app/src/app.tsx | 23 +++++++++++++++-------- packages/app/src/index.ts | 3 +++ packages/desktop/src/duplicate.tsx | 6 ++++-- 3 files changed, 22 insertions(+), 10 deletions(-) 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/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/src/duplicate.tsx b/packages/desktop/src/duplicate.tsx index 584f701e65e..5b1d08f6a61 100644 --- a/packages/desktop/src/duplicate.tsx +++ b/packages/desktop/src/duplicate.tsx @@ -22,9 +22,9 @@ export function Duplicate() { const sessionID = params.id if (!sessionID) return - const dir = base64Encode(sdk.directory) const value = prompt.current() const cursor = prompt.cursor() + const dir = base64Encode(sdk.directory) sdk.client.session .fork({ sessionID }) .then((result: { data?: { id: string } | null }) => { @@ -33,8 +33,10 @@ export function Duplicate() { return } - prompt.set(value, cursor, { dir, id: result.data.id }) navigate(`/${dir}/session/${result.data.id}`) + requestAnimationFrame(() => { + prompt.set(value, cursor) + }) }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) From e59e78abec9bd978bad81996eba45b20e943f25c Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 18 Mar 2026 22:45:24 +0000 Subject: [PATCH 4/4] test(desktop): cover duplicate session command --- packages/desktop/src/duplicate-command.ts | 73 +++++++++++++++ packages/desktop/src/duplicate.test.ts | 104 ++++++++++++++++++++++ packages/desktop/src/duplicate.tsx | 46 +++------- packages/desktop/tsconfig.json | 3 +- 4 files changed, 189 insertions(+), 37 deletions(-) create mode 100644 packages/desktop/src/duplicate-command.ts create mode 100644 packages/desktop/src/duplicate.test.ts 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 index 5b1d08f6a61..6168f87a833 100644 --- a/packages/desktop/src/duplicate.tsx +++ b/packages/desktop/src/duplicate.tsx @@ -1,7 +1,7 @@ import { useCommand, useLanguage, usePrompt, useSDK } from "@opencode-ai/app" -import { base64Encode } from "@opencode-ai/util/encode" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate, useParams } from "@solidjs/router" +import { duplicateCommand } from "./duplicate-command" export function Duplicate() { const command = useCommand() @@ -12,41 +12,15 @@ export function Duplicate() { const sdk = useSDK() command.register("desktop.session.duplicate", () => [ - { - id: "desktop.session.duplicate", - title: language.t("command.session.duplicate"), - description: language.t("command.session.duplicate.description"), - slash: "duplicate", - disabled: !params.id, - onSelect: () => { - const sessionID = params.id - if (!sessionID) return - - const value = prompt.current() - const cursor = prompt.cursor() - const dir = base64Encode(sdk.directory) - sdk.client.session - .fork({ sessionID }) - .then((result: { data?: { id: string } | null }) => { - if (!result.data) { - showToast({ title: language.t("common.requestFailed") }) - return - } - - navigate(`/${dir}/session/${result.data.id}`) - requestAnimationFrame(() => { - prompt.set(value, cursor) - }) - }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ - title: language.t("common.requestFailed"), - description: message, - }) - }) - }, - }, + duplicateCommand({ + id: params.id, + t: language.t, + prompt, + sdk, + navigate, + toast: showToast, + frame: requestAnimationFrame, + }), ]) return null diff --git a/packages/desktop/tsconfig.json b/packages/desktop/tsconfig.json index 3b689b85dae..9ef0f12470c 100644 --- a/packages/desktop/tsconfig.json +++ b/packages/desktop/tsconfig.json @@ -19,5 +19,6 @@ "types": ["vite/client"] }, "references": [{ "path": "../app" }, { "path": "../sdk/js" }], - "include": ["src", "package.json"] + "include": ["src", "package.json"], + "exclude": ["src/**/*.test.ts"] }