Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 15 additions & 8 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,16 @@ const HomeRoute = () => (
</Suspense>
)

const SessionRoute = () => (
<SessionProviders>
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</SessionProviders>
)
function SessionRoute(props: { sessionChildren?: JSX.Element }) {
return (
<SessionProviders>
{props.sessionChildren}
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</SessionProviders>
)
}

const SessionIndexRoute = () => <Navigate href="session" />

Expand Down Expand Up @@ -271,6 +274,7 @@ export function AppInterface(props: {
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
sessionChildren?: JSX.Element
}) {
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
Expand All @@ -284,7 +288,10 @@ export function AppInterface(props: {
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
<Route
path="/session/:id?"
component={() => <SessionRoute sessionChildren={props.sessionChildren} />}
/>
</Route>
</Dynamic>
</GlobalSyncProvider>
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 2 additions & 0 deletions packages/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
73 changes: 73 additions & 0 deletions packages/desktop/src/duplicate-command.ts
Original file line number Diff line number Diff line change
@@ -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<Fork>
}
}
}

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),
}
}
104 changes: 104 additions & 0 deletions packages/desktop/src/duplicate.test.ts
Original file line number Diff line number Diff line change
@@ -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",
})
})
})
27 changes: 27 additions & 0 deletions packages/desktop/src/duplicate.tsx
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions packages/desktop/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -469,6 +470,7 @@ render(() => {
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
sessionChildren={<Duplicate />}
>
<Inner />
</AppInterface>
Expand Down
6 changes: 4 additions & 2 deletions packages/desktop/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down
Loading