Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e89284e
WIP: feat(sessions): new session migration functionality
sim590 Apr 7, 2026
11c0ad2
feat(server): auto-tag route spans with route params (session.id, mes…
kitlangton Apr 17, 2026
2b73a08
feat(tui): show session ID in sidebar on non-prod channels (#23185)
kitlangton Apr 17, 2026
1eafb21
feat(effect-zod): add catchall (StructWithRest) support to the walker…
kitlangton Apr 17, 2026
8d2d871
refactor(server): align route-span attrs with OTel semantic conventio…
kitlangton Apr 17, 2026
0c1ffc6
refactor(config): migrate provider (Model + Info) to Effect Schema (#…
kitlangton Apr 17, 2026
280b9d4
chore: generate
opencode-agent[bot] Apr 17, 2026
f3d1fd9
feat(effect-zod): transform support + walk memoization + flattened ch…
kitlangton Apr 17, 2026
6b7f34d
chore: generate
opencode-agent[bot] Apr 17, 2026
8d6ea7c
docs(effect): refresh migration status specs (#23206)
kitlangton Apr 18, 2026
98b60ac
refactor(v2): tag session unions and exhaustively match events (#23201)
kitlangton Apr 18, 2026
8b3ab3c
Merge remote-tracking branch 'origin/dev' into session-migration-menu
sim590 Apr 18, 2026
1f91d3e
feat(tui): add delete and orphan legend to session migration dialog
sim590 Apr 18, 2026
4dbdb9d
fix(tui): use cwd instead of worktree for migration destination
sim590 Apr 18, 2026
4f58f3f
fix(tui): refresh session list after migration
sim590 Apr 18, 2026
750a758
Merge remote-tracking branch 'origin/dev' into session-migration-menu
sim590 Apr 18, 2026
30f2161
feat(tui): show orphan legend only when orphaned sessions exist
sim590 Apr 18, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { Locale } from "@/util"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { Flag } from "@/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename"
import { DialogSessionMigrate } from "./dialog-session-migrate"
import { useKV } from "../context/kv"
import { Keybind } from "@/util"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
Expand Down Expand Up @@ -174,6 +177,14 @@ export function DialogSessionList() {
dialog.setSize("large")
})

useKeyboard((evt) => {
if (keybind.match("session_migrate", evt)) {
evt.preventDefault()
evt.stopPropagation()
dialog.replace(() => <DialogSessionMigrate />)
}
})

return (
<DialogSelect
title="Sessions"
Expand Down Expand Up @@ -247,6 +258,11 @@ export function DialogSessionList() {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
{
keybind: keybind.all.session_migrate?.[0],
title: "migrate",
onTrigger: () => {},
},
{
keybind: Keybind.parse("ctrl+w")[0],
title: "new workspace",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { createResource, createMemo, createSignal, onMount, Show } from "solid-js"
import { Locale } from "@/util"
import { useSDK } from "../context/sdk"
import { useTheme } from "../context/theme"
import { useKeybind } from "../context/keybind"
import { DialogSessionRescue } from "./dialog-session-rescue"
import type { GlobalSession } from "@opencode-ai/sdk/v2"

export function DialogSessionMigrate() {
const dialog = useDialog()
const sdk = useSDK()
const { theme } = useTheme()
const keybind = useKeybind()
const [toDelete, setToDelete] = createSignal<string>()

const [data, { refetch }] = createResource(async () => {
const [res, orphans] = await Promise.all([
sdk.fetch(`${sdk.url}/experimental/session?roots=true&limit=200`),
sdk.client.session.orphans(),
])
const all: GlobalSession[] = (await res.json()) ?? []
const ids = new Set((orphans.data ?? []).map((x) => x.id))
return { sessions: all, orphans: ids }
})

const options = createMemo(() => {
const items = data()
if (!items) return []
const deleting = toDelete()
return items.sessions.map((x) => ({
title: deleting === x.id ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
value: x.id,
description: x.directory,
footer: Locale.time(x.time.updated),
category: x.project?.name ?? x.project?.worktree ?? "global",
get gutter() {
return items.orphans.has(x.id) ? <text fg={theme.warning}>!</text> : undefined
},
}))
})

onMount(() => {
dialog.setSize("large")
})

return (
<box>
<DialogSelect
title="Migrate Session"
placeholder="Search sessions"
options={options()}
onSelect={(option) => {
const session = data()?.sessions.find((x) => x.id === option.value)
if (!session) return
dialog.replace(() => <DialogSessionRescue session={session} onDone={refetch} />)
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
await sdk.client.session.delete({ sessionID: option.value })
setToDelete(undefined)
await refetch()
return
}
setToDelete(option.value)
},
},
]}
/>
<Show when={data()?.orphans.size}>
<box paddingLeft={4} paddingRight={4} paddingBottom={1}>
<text fg={theme.textMuted}>
NOTE: <span style={{ fg: theme.warning }}>!</span> means the session is orphan
</text>
</box>
</Show>
</box>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { createResource, createMemo, onMount } from "solid-js"
import { useSDK } from "../context/sdk"
import { useSync } from "../context/sync"
import os from "os"

interface MigrateSession {
id: string
title: string
directory: string
projectID: string
time: { created: number; updated: number }
project: { id: string; name?: string; worktree: string } | null
}

interface DialogSessionRescueProps {
session: MigrateSession
onDone: () => void
}

export function DialogSessionRescue(props: DialogSessionRescueProps) {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()

const [data] = createResource(async () => {
const [cur, all] = await Promise.all([sdk.client.project.current(), sdk.client.project.list()])
return { current: cur.data, projects: all.data ?? [] }
})

const options = createMemo(() => {
const result: Array<{
title: string
value: { projectID: string; directory: string }
description?: string
category?: string
}> = []

const proj = data()?.current
if (proj) {
const dir = sdk.directory ?? proj.worktree
result.push({
title: proj.name ?? proj.worktree,
value: { projectID: proj.id, directory: dir },
description: dir,
category: "Current",
})
}

result.push({
title: "Home (~)",
value: { projectID: "global", directory: os.homedir() },
description: os.homedir(),
category: "Special",
})

for (const p of data()?.projects ?? []) {
if (p.id === proj?.id) continue
if (p.id === "global") continue
result.push({
title: p.name ?? p.worktree,
value: { projectID: p.id, directory: p.worktree },
description: p.worktree,
category: "Projects",
})
}

return result
})

onMount(() => {
dialog.setSize("large")
})

return (
<DialogSelect
title={`Migrate: ${props.session.title}`}
placeholder="Choose destination"
options={options()}
onSelect={async (option) => {
await sdk.client.session.migrate({
sessionID: props.session.id,
projectID: option.value.projectID,
body_directory: option.value.directory,
})
await sync.session.refresh()
props.onDone()
const { DialogSessionMigrate } = await import("./dialog-session-migrate")
dialog.replace(() => <DialogSessionMigrate />)
}}
/>
)
}
1 change: 1 addition & 0 deletions packages/opencode/src/config/keybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const KeybindsSchema = Schema.Struct({
session_fork: keybind("none", "Fork session from message"),
session_rename: keybind("ctrl+r", "Rename session"),
session_delete: keybind("ctrl+d", "Delete session"),
session_migrate: keybind("ctrl+o", "Migrate session to another project"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"),
Expand Down
65 changes: 65 additions & 0 deletions packages/opencode/src/server/routes/instance/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Log } from "@/util"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { ProjectID } from "@/project/schema"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { Bus } from "@/bus"
Expand Down Expand Up @@ -100,6 +101,32 @@ export const SessionRoutes = lazy(() =>
return Object.fromEntries(yield* svc.list())
}),
)
.get(
"/orphans",
describeRoute({
summary: "List orphaned sessions",
description:
"Get sessions whose directory no longer exists on disk. These sessions may have been orphaned when a project was moved or deleted.",
operationId: "session.orphans",
responses: {
200: {
description: "List of orphaned sessions",
content: {
"application/json": {
schema: resolver(Session.GlobalInfo.array()),
},
},
},
},
}),
async (c) => {
const sessions: Session.GlobalInfo[] = []
for (const session of Session.listOrphans()) {
sessions.push(session)
}
return c.json(sessions)
},
)
.get(
"/:sessionID",
describeRoute({
Expand Down Expand Up @@ -396,6 +423,44 @@ export const SessionRoutes = lazy(() =>
return yield* svc.fork({ ...body, sessionID })
}),
)
.post(
"/:sessionID/migrate",
describeRoute({
summary: "Migrate session",
description: "Migrate a session to a new project and directory. Also migrates child sessions (sub-agents).",
operationId: "session.migrate",
responses: {
200: {
description: "Migrated session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: SessionID.zod,
}),
),
validator(
"json",
z.object({
projectID: ProjectID.zod,
directory: z.string(),
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const result = await Session.migrate({ ...body, sessionID })
return c.json(result)
},
)
.post(
"/:sessionID/abort",
describeRoute({
Expand Down
Loading
Loading