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).
---