feat(core): moving sessions (#30640)

This commit is contained in:
James Long 2026-06-04 15:07:46 -04:00 committed by GitHub
commit ba1b660d57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2180 additions and 476 deletions

View file

@ -0,0 +1,129 @@
import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import path from "path"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "@tui/context/sdk"
import { useTheme } from "@tui/context/theme"
import { useKV } from "@tui/context/kv"
import { useSync } from "@tui/context/sync"
import { Global } from "@opencode-ai/core/global"
import { Locale } from "@/util/locale"
import "opentui-spinner/solid"
const REFRESH_FRAMES = ["■", "⬝"]
export type MoveSessionSelection = { type: "directory"; directory: string } | { type: "new" }
export function DialogMoveSession(props: { projectID: string; onSelect: (selection: MoveSessionSelection) => void }) {
const dialog = useDialog()
const sdk = useSDK()
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const kv = useKV()
const sync = useSync()
const [refreshing, setRefreshing] = createSignal(false)
const [directories] = createResource(
() => props.projectID,
async (projectID) => {
setRefreshing(true)
const [, project] = await Promise.all([
sdk.client.experimental.projectCopy
.refresh({ projectID }, { throwOnError: true })
.finally(() => setRefreshing(false)),
sdk.client.project.current({}, { throwOnError: true }),
])
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
return {
directories: directories.data ?? [],
main: project.data?.id === projectID ? project.data.worktree : undefined,
}
},
)
const options = createMemo<DialogSelectOption<string | undefined>[]>(() => {
if (directories.loading) return [{ title: "Loading project directories...", value: undefined }]
if (directories.error) return [{ title: "Failed to load project directories", value: undefined }]
const data = directories()
const roots = data ? [...new Set(data.main ? [data.main, ...data.directories] : data.directories)] : []
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
const subdirectories = sync.data.session
.filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path))
.map((session) => session.directory)
.filter((directory) => !roots.includes(directory))
.filter((directory, index, directories) => directories.indexOf(directory) === index)
.map((location) => ({
location,
root: roots
.filter((root) => {
const relative = path.relative(root, location)
return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)
})
.toSorted((a, b) => b.length - a.length)[0],
}))
.filter((item): item is { location: string; root: string } => item.root !== undefined)
const list = [...roots.map((location) => ({ location, root: location })), ...subdirectories].toSorted((a, b) => {
const root = roots.indexOf(a.root) - roots.indexOf(b.root)
if (root !== 0) return root
if (a.location === a.root) return -1
if (b.location === b.root) return 1
return a.location.localeCompare(b.location)
})
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
return list.map((item) => {
const title =
Global.Path.home &&
(item.location === Global.Path.home || item.location.startsWith(Global.Path.home + path.sep))
? item.location.replace(Global.Path.home, "~")
: item.location
const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location)
const visible = Locale.truncateLeft(title, titleWidth)
const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
return {
title,
titleView: suffix ? (
<>
{visible.slice(0, split)}
<span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
</>
) : undefined,
value: item.location,
category: item.root === data?.main ? "Project" : "Working copies",
titleWidth,
truncateTitle: "left" as const,
}
})
})
onMount(() => dialog.setSize("xlarge"))
return (
<box minHeight={Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2))}>
<DialogSelect
title="Move session"
options={options()}
onSelect={(option) => {
if (option.value) props.onSelect({ type: "directory", directory: option.value })
}}
actions={[
{
command: "dialog.move_session.new",
title: "new",
onTrigger: () => props.onSelect({ type: "new" }),
},
]}
footer={
<Show when={refreshing()}>
<box flexDirection="row" gap={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}></text>}>
<spinner color={theme.textMuted} frames={REFRESH_FRAMES} interval={160} />
</Show>
<text fg={theme.textMuted}>refreshing</text>
</box>
</Show>
}
/>
</box>
)
}

View file

@ -103,7 +103,7 @@ export function DialogWorkspaceFileChanges(props: {
</scrollbox>
<box paddingLeft={2} paddingRight={2}>
<text fg={theme.textMuted} wrapMode="word">
Do you want to apply these changes after warping?
Do you want to move these changes with the session?
</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>

View file

@ -51,18 +51,13 @@ import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { DialogSkill } from "../dialog-skill"
import {
confirmWorkspaceFileChanges,
openWorkspaceSelect,
warpWorkspaceSession,
type WorkspaceSelection,
} from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { Flag } from "@opencode-ai/core/flag/flag"
import { type WorkspaceStatus } from "../workspace-label"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
import { useTuiConfig } from "../../context/tui-config"
import { usePromptWorkspace } from "./workspace"
import { usePromptMove } from "./move"
export type PromptProps = {
sessionID?: string
@ -195,109 +190,12 @@ export function Prompt(props: PromptProps) {
})
const editorContextLabelState = createMemo(() => editor.labelState())
const [auto, setAuto] = createSignal<AutocompleteRef>()
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
const [warpNotice, setWarpNotice] = createSignal<string>()
const workspace = usePromptWorkspace(props.sessionID)
const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID })
const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
function selectWorkspace(selection: WorkspaceSelection | undefined) {
setWorkspaceSelection(selection)
}
function setCreatingWorkspace(creating: boolean) {
setWorkspaceCreating(creating)
}
function showWarpNotice(name: string) {
setWarpNotice(`Warped to ${name}`)
setTimeout(() => setWarpNotice(undefined), 4000)
}
async function createWorkspace(selection: Extract<WorkspaceSelection, { type: "new" }>) {
setCreatingWorkspace(true)
let result
try {
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
} catch (err) {
selectWorkspace(undefined)
setCreatingWorkspace(false)
toast.show({
title: "Creating workspace failed",
message: errorMessage(err),
variant: "error",
})
return
}
if (result.error || !result.data) {
selectWorkspace(undefined)
setCreatingWorkspace(false)
toast.show({
title: "Creating workspace failed",
message: errorMessage(result.error ?? "no response"),
variant: "error",
})
return
}
await project.workspace.sync()
const workspace = result.data
selectWorkspace({
type: "existing",
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
})
setCreatingWorkspace(false)
return workspace
}
async function warpSession(selection: WorkspaceSelection) {
if (!props.sessionID) {
selectWorkspace(selection)
dialog.clear()
if (selection.type === "new") void createWorkspace(selection)
return
}
const sourceWorkspaceID = project.workspace.current()
const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
if (copyChanges === undefined) return
selectWorkspace(selection)
dialog.clear()
const workspace =
selection.type === "none"
? { id: null, name: "local project" }
: selection.type === "existing"
? { id: selection.workspaceID, name: selection.workspaceName }
: await createWorkspace(selection)
if (!workspace) return
const warped = await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
sourceWorkspaceID,
workspaceID: workspace.id,
sessionID: props.sessionID,
copyChanges,
})
if (warped) showWarpNotice(workspace.name)
}
createEffect(() => {
if (!workspaceCreating()) {
setWorkspaceCreatingDots(3)
return
}
const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000)
onCleanup(() => clearInterval(timer))
})
function promptModelWarning() {
toast.show({
variant: "warning",
@ -623,16 +521,17 @@ export function Prompt(props: PromptProps) {
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slashName: "warp",
run: () => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warpSession(selection)
},
})
workspace.open()
},
},
{
title: "Move session",
desc: "Move the session to another project directory",
name: "session.move",
category: "Session",
slashName: "move",
run: () => {
move.open()
},
},
].map((entry) => ({
@ -656,6 +555,7 @@ export function Prompt(props: PromptProps) {
"prompt.stash.list",
"session.interrupt",
"workspace.set",
"session.move",
]),
}))
@ -1025,7 +925,7 @@ export function Prompt(props: PromptProps) {
}
async function submitInner() {
setWarpNotice(undefined)
workspace.clearNotice()
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
@ -1035,7 +935,7 @@ export function Prompt(props: PromptProps) {
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (workspaceCreating()) return false
if (workspace.creating() || move.creating()) return false
if (auto()?.visible) return false
if (!store.prompt.input) return false
const agent = local.agent.current()
@ -1058,16 +958,7 @@ export function Prompt(props: PromptProps) {
dialog.replace(() => (
<DialogWorkspaceUnavailable
onRestore={() => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warpSession(selection)
},
})
workspace.open()
return false
}}
/>
@ -1077,16 +968,22 @@ export function Prompt(props: PromptProps) {
const variant = local.model.variant.current()
let sessionID = props.sessionID
let finishMoveProgress = false
if (sessionID == null) {
const workspace = workspaceSelection()
const selectedWorkspace = workspace.selection()
const workspaceID = iife(() => {
if (!workspace) return undefined
if (workspace.type === "none") return undefined
if (workspace.type === "existing") return workspace.workspaceID
if (!selectedWorkspace) return undefined
if (selectedWorkspace.type === "none") return undefined
if (selectedWorkspace.type === "existing") return selectedWorkspace.workspaceID
return undefined
})
const directory = await move.getDirectory(store.prompt.input)
if (move.pending() && !directory) return false
finishMoveProgress = Boolean(move.progress())
const res = await sdk.client.session.create({
directory,
workspace: workspaceID,
agent: agent.name,
model: {
@ -1097,6 +994,7 @@ export function Prompt(props: PromptProps) {
})
if (res.error) {
if (finishMoveProgress) move.finishSubmit()
console.log("Creating a session failed:", res.error)
toast.show({
@ -1146,6 +1044,7 @@ export function Prompt(props: PromptProps) {
: []
if (store.mode === "shell") {
move.startSubmit()
void sdk.client.session.shell({
sessionID,
agent: agent.name,
@ -1164,6 +1063,7 @@ export function Prompt(props: PromptProps) {
return sync.data.command.some((x) => x.name === command)
})
) {
move.startSubmit()
// Parse command from first line, preserve multi-line content in arguments
const firstLineEnd = inputText.indexOf("\n")
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
@ -1187,6 +1087,7 @@ export function Prompt(props: PromptProps) {
})),
})
} else {
move.startSubmit()
sdk.client.session
.prompt({
sessionID,
@ -1231,6 +1132,7 @@ export function Prompt(props: PromptProps) {
}, 50)
}
input.clear()
if (finishMoveProgress) move.finishSubmit()
return true
}
const exit = useExit()
@ -1427,29 +1329,6 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})
const workspaceLabel = createMemo<
| { type: "new"; workspaceType: string }
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
| undefined
>(() => {
const selected = workspaceSelection()
if (!selected) return
if (selected.type === "none") return
if (props.sessionID && !workspaceCreating()) return
if (selected.type === "new") {
return {
type: "new",
workspaceType: selected.workspaceType,
}
}
return {
type: "existing",
workspaceType: selected.workspaceType,
workspaceName: selected.workspaceName,
status: selected.type === "existing" ? "connected" : undefined,
}
})
const spinnerDef = createMemo(() => {
const agent =
status().type !== "idle"
@ -1474,6 +1353,7 @@ export function Prompt(props: PromptProps) {
}
})
const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3)))
const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48)))
return (
<>
@ -1717,25 +1597,25 @@ export function Prompt(props: PromptProps) {
</text>
</box>
</Match>
<Match when={warpNotice()}>
<Match when={workspace.notice()}>
{(notice) => (
<box paddingLeft={3}>
<text fg={theme.accent}>{notice()}</text>
</box>
)}
</Match>
<Match when={workspaceLabel()}>
{(workspace) => (
<Match when={workspace.label()}>
{(label) => (
<box paddingLeft={3} flexDirection="row" gap={1}>
<Show when={workspaceCreating()}>
<Show when={workspace.creating()}>
<Spinner color={theme.accent} />
</Show>
<text fg={workspaceCreating() ? theme.accent : theme.text}>
<text fg={workspace.creating() ? theme.accent : theme.text}>
{(() => {
const item = workspace()
const item = label()
if (item.type === "new") {
if (workspaceCreating())
return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}`
if (workspace.creating())
return `Creating ${item.workspaceType}${".".repeat(workspace.creatingDots())}`
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
@ -1752,6 +1632,21 @@ export function Prompt(props: PromptProps) {
</box>
)}
</Match>
<Match when={move.progress()}>
{(progress) => (
<box paddingLeft={3}>
<Spinner color={theme.accent}>
{progress()}
<span style={{ fg: theme.textMuted }}>{".".repeat(move.creatingDots())}</span>
</Spinner>
</box>
)}
</Match>
<Match when={move.pendingNew()}>
<box paddingLeft={3}>
<text fg={theme.accent}>(new working copy)</text>
</box>
</Match>
<Match when={true}>{props.hint ?? <text />}</Match>
</Switch>
<Show when={status().type !== "retry"}>

View file

@ -0,0 +1,158 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { errorMessage } from "@/util/error"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { useToast } from "@tui/ui/toast"
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
import { useHomeSessionDestination } from "../../routes/home/session-destination"
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const toast = useToast()
const homeDestination = useHomeSessionDestination()
const [creating, setCreating] = createSignal(false)
const [creatingDots, setCreatingDots] = createSignal(3)
const [progress, setProgress] = createSignal<string>()
async function create(context?: string) {
const projectID = input.projectID()
if (!projectID) return
setCreating(true)
setProgress("Creating copy")
try {
const result = await sdk.client.experimental.projectCopy.create(
{
projectID,
strategy: "git_worktree",
directory: path.join(Global.Path.data, "worktree", projectID.slice(0, 6)),
context,
},
{ throwOnError: true },
)
const directory = result.data?.directory
if (!directory) throw new Error("No project copy directory returned")
setProgress("Creating session")
return directory
} catch (err) {
homeDestination?.clear()
setProgress(undefined)
setCreating(false)
toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
return
}
}
function open() {
const projectID = input.projectID()
if (!projectID) return
dialog.replace(() => (
<DialogMoveSession
projectID={projectID}
onSelect={(selection) => {
const sessionID = input.sessionID()
if (!sessionID) {
homeDestination?.setDestination(selection)
dialog.clear()
return
}
void moveExistingSession(sessionID, selection)
}}
/>
))
}
function sessionContext(sessionID: string) {
const session = sync.session.get(sessionID)
const messages = (sync.data.message[sessionID] ?? [])
.slice(-6)
.map((message) =>
[
message.role + ":",
...(sync.data.part[message.id] ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])),
].join(" "),
)
return [session?.title, ...messages].filter(Boolean).join("\n") || undefined
}
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
const session = sync.session.get(sessionID)
const status = await sdk.client.vcs.status({ directory: session?.directory }).catch(() => undefined)
const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no"
if (!choice) return
dialog.clear()
const directory = selection.type === "new" ? await create(sessionContext(sessionID)) : selection.directory
if (!directory) {
setProgress(undefined)
dialog.clear()
return
}
setProgress("Moving session")
await sdk.client.experimental.controlPlane
.moveSession(
{
sessionID,
destination: { directory },
moveChanges: choice === "yes",
},
{ throwOnError: true },
)
.then(() => dialog.clear())
.catch((error) => {
toast.error(error)
dialog.clear()
})
.finally(() => {
setProgress(undefined)
setCreating(false)
})
}
const pending = createMemo(() => Boolean(homeDestination?.destination()))
const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new")
async function getDirectory(context?: string) {
const value = homeDestination?.destination()
if (!value) return
if (value.type === "directory") {
return value.directory
}
return await create(context)
}
function startSubmit() {
if (progress()) setProgress("Submitting prompt")
}
function finishSubmit() {
homeDestination?.clear()
setProgress(undefined)
setCreating(false)
}
createEffect(() => {
if (!creating()) {
setCreatingDots(3)
return
}
const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
onCleanup(() => clearInterval(timer))
})
return {
creating,
creatingDots,
finishSubmit,
getDirectory,
open,
pending,
pendingNew,
progress,
startSubmit,
}
}

View file

@ -0,0 +1,137 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "@tui/context/sdk"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useToast } from "@tui/ui/toast"
import { errorMessage } from "@/util/error"
import {
confirmWorkspaceFileChanges,
openWorkspaceSelect,
warpWorkspaceSession,
type WorkspaceSelection,
} from "../dialog-workspace-create"
import type { WorkspaceStatus } from "../workspace-label"
export function usePromptWorkspace(sessionID?: string) {
const dialog = useDialog()
const sdk = useSDK()
const project = useProject()
const sync = useSync()
const toast = useToast()
const [selection, setSelection] = createSignal<WorkspaceSelection>()
const [creating, setCreating] = createSignal(false)
const [creatingDots, setCreatingDots] = createSignal(3)
const [notice, setNotice] = createSignal<string>()
async function create(selection: Extract<WorkspaceSelection, { type: "new" }>) {
setCreating(true)
let result
try {
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
} catch (err) {
setSelection(undefined)
setCreating(false)
toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
return
}
if (result.error || !result.data) {
setSelection(undefined)
setCreating(false)
toast.show({
title: "Creating workspace failed",
message: errorMessage(result.error ?? "no response"),
variant: "error",
})
return
}
await project.workspace.sync()
const workspace = result.data
setSelection({
type: "existing",
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
})
setCreating(false)
return workspace
}
async function warp(selection: WorkspaceSelection) {
if (!sessionID) {
setSelection(selection)
dialog.clear()
if (selection.type === "new") void create(selection)
return
}
const sourceWorkspaceID = project.workspace.current()
const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
if (copyChanges === undefined) return
setSelection(selection)
dialog.clear()
const workspace =
selection.type === "none"
? { id: null, name: "local project" }
: selection.type === "existing"
? { id: selection.workspaceID, name: selection.workspaceName }
: await create(selection)
if (!workspace) return
const warped = await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
sourceWorkspaceID,
workspaceID: workspace.id,
sessionID,
copyChanges,
})
if (warped) showNotice(workspace.name)
}
function showNotice(name: string) {
setNotice(`Warped to ${name}`)
setTimeout(() => setNotice(undefined), 4000)
}
function clearNotice() {
setNotice(undefined)
}
function open() {
void openWorkspaceSelect({ dialog, sdk, sync, project, toast, onSelect: warp })
}
createEffect(() => {
if (!creating()) {
setCreatingDots(3)
return
}
const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
onCleanup(() => clearInterval(timer))
})
const label = createMemo<
| { type: "new"; workspaceType: string }
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
| undefined
>(() => {
const selected = selection()
if (!selected) return
if (selected.type === "none") return
if (sessionID && !creating()) return
if (selected.type === "new") return { type: "new", workspaceType: selected.workspaceType }
return {
type: "existing",
workspaceType: selected.workspaceType,
workspaceName: selected.workspaceName,
status: selected.type === "existing" ? "connected" : undefined,
}
})
return { selection, creating, creatingDots, notice, label, open, warp, clearNotice }
}

View file

@ -204,6 +204,7 @@ export const Definitions = {
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
"dialog.move_session.new": keybind("ctrl+w", "New project copy"),
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),

View file

@ -253,6 +253,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "session.next.moved": {
const result = Binary.search(store.session, event.properties.sessionID, (s) => s.id)
if (!result.found) break
setStore(
"session",
result.index,
produce((session) => {
session.directory = event.properties.location.directory
session.path = event.properties.subdirectory
session.workspaceID = event.properties.location.workspaceID
session.time.updated = event.properties.timestamp
}),
)
break
}
case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status)
break

View file

@ -2,12 +2,17 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { InternalTuiPlugin } from "../../plugin/internal"
import { createMemo, Match, Show, Switch } from "solid-js"
import { Global } from "@opencode-ai/core/global"
import { useHomeSessionDestination } from "../../routes/home/session-destination"
const id = "internal:home-footer"
function Directory(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
const destination = useHomeSessionDestination()
const dir = createMemo(() => {
const selected = destination?.destination()
if (selected?.type === "new") return
if (selected?.type === "directory") return selected.directory.replace(Global.Path.home, "~")
const dir = props.api.state.path.directory || process.cwd()
const out = dir.replace(Global.Path.home, "~")
const branch = props.api.state.vcs?.branch
@ -15,7 +20,7 @@ function Directory(props: { api: TuiPluginApi }) {
return out
})
return <text fg={theme().textMuted}>{dir()}</text>
return <Show when={dir()}>{(value) => <text fg={theme().textMuted}>{value()}</text>}</Show>
}
function Mcp(props: { api: TuiPluginApi }) {

View file

@ -5,7 +5,7 @@ import { Global } from "@opencode-ai/core/global"
const id = "internal:sidebar-footer"
function View(props: { api: TuiPluginApi }) {
function View(props: { api: TuiPluginApi; sessionID: string }) {
const theme = () => props.api.theme.current
const has = createMemo(() =>
props.api.state.provider.some(
@ -15,9 +15,11 @@ function View(props: { api: TuiPluginApi }) {
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
const show = createMemo(() => !has() && !done())
const path = createMemo(() => {
const dir = props.api.state.path.directory || process.cwd()
const session = props.api.state.session.get(props.sessionID)
const dir = session?.directory || props.api.state.path.directory || process.cwd()
const out = dir.replace(Global.Path.home, "~")
const text = props.api.state.vcs?.branch ? out + ":" + props.api.state.vcs.branch : out
const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
const text = branch ? out + ":" + branch : out
const list = text.split("/")
return {
parent: list.slice(0, -1).join("/"),
@ -79,8 +81,8 @@ const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
sidebar_footer() {
return <View api={api} />
sidebar_footer(_ctx, props) {
return <View api={api} sessionID={props.session_id} />
},
},
})

View file

@ -11,6 +11,7 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { useEditorContext } from "@tui/context/editor"
import { useTerminalDimensions } from "@opentui/solid"
import { useTuiConfig } from "../context/tui-config"
import { HomeSessionDestinationProvider } from "./home/session-destination"
let once = false
const placeholder = {
@ -66,7 +67,7 @@ export function Home() {
})
return (
<>
<HomeSessionDestinationProvider>
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
@ -88,6 +89,6 @@ export function Home() {
<box width="100%" flexShrink={0}>
<TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
</box>
</>
</HomeSessionDestinationProvider>
)
}

View file

@ -0,0 +1,26 @@
import { createContext, createSignal, useContext, type Accessor, type ParentProps, type Setter } from "solid-js"
export type HomeSessionDestination = { type: "directory"; directory: string } | { type: "new" }
type Context = {
destination: Accessor<HomeSessionDestination | undefined>
setDestination: Setter<HomeSessionDestination | undefined>
clear: () => void
}
const HomeSessionDestinationContext = createContext<Context>()
export function HomeSessionDestinationProvider(props: ParentProps) {
const [destination, setDestination] = createSignal<HomeSessionDestination>()
return (
<HomeSessionDestinationContext.Provider
value={{ destination, setDestination, clear: () => setDestination(undefined) }}
>
{props.children}
</HomeSessionDestinationContext.Provider>
)
}
export function useHomeSessionDestination() {
return useContext(HomeSessionDestinationContext)
}

View file

@ -23,6 +23,7 @@ import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
export interface DialogSelectProps<T> {
title: string
placeholder?: string
footer?: JSX.Element
options: DialogSelectOption<T>[]
flat?: boolean
ref?: (ref: DialogSelectRef<T>) => void
@ -49,10 +50,13 @@ export interface DialogSelectProps<T> {
export interface DialogSelectOption<T = any> {
title: string
titleView?: JSX.Element
value: T
description?: string
details?: string[]
footer?: JSX.Element | string
titleWidth?: number
truncateTitle?: boolean | "left"
category?: string
categoryView?: JSX.Element
disabled?: boolean
@ -472,7 +476,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</Show>
<Option
title={option.title}
titleView={option.titleView}
footer={flatten() ? (option.category ?? option.footer) : option.footer}
titleWidth={option.titleWidth}
truncateTitle={option.truncateTitle}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
@ -498,7 +505,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</scrollbox>
</Show>
</box>
<Show when={visibleActions().length} fallback={<box flexShrink={0} />}>
<Show when={props.footer || visibleActions().length} fallback={<box flexShrink={0} />}>
<box
paddingRight={2}
paddingLeft={4}
@ -508,6 +515,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
paddingTop={1}
>
<box flexDirection="row" gap={2}>
{props.footer}
<For each={left()}>
{(item) => (
<text>
@ -539,10 +547,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
function Option(props: {
title: string
titleView?: JSX.Element
description?: string
active?: boolean
current?: boolean
footer?: JSX.Element | string
titleWidth?: number
truncateTitle?: boolean | "left"
gutter?: () => JSX.Element
onMouseOver?: () => void
}) {
@ -569,7 +580,12 @@ function Option(props: {
wrapMode="none"
paddingLeft={3}
>
{Locale.truncate(props.title, 61)}
{props.titleView ??
(props.truncateTitle === false
? props.title
: props.truncateTitle === "left"
? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
: Locale.truncate(props.title, props.titleWidth ?? 61))}
<Show when={props.description}>
<span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
</Show>

View file

@ -5,6 +5,7 @@ import { InstanceDisposed } from "@/server/event"
import { Question } from "@/question"
import { ConfigApi } from "./groups/config"
import { ControlApi } from "./groups/control"
import { ControlPlaneApi } from "./groups/control-plane"
import { EventApi } from "./groups/event"
import { ExperimentalApi } from "./groups/experimental"
import { FileApi } from "./groups/file"
@ -42,6 +43,7 @@ const EventSchema = Schema.Union([
export const RootHttpApi = HttpApi.make("opencode-root")
.addHttpApi(ControlApi)
.addHttpApi(ControlPlaneApi)
.addHttpApi(GlobalApi)
.middleware(SchemaErrorMiddleware)
.middleware(Authorization)

View file

@ -0,0 +1,35 @@
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { described } from "./metadata"
const root = "/experimental/control-plane"
export const MoveSessionPayload = Schema.Struct({ ...MoveSession.Input.fields })
export class ApiMoveSessionError extends Schema.ErrorClass<ApiMoveSessionError>("MoveSessionError")(
{
name: Schema.Literal("MoveSessionError"),
data: Schema.Struct({
message: Schema.String,
}),
},
{ httpApiStatus: 400 },
) {}
export const ControlPlaneApi = HttpApi.make("controlPlane").add(
HttpApiGroup.make("controlPlane")
.add(
HttpApiEndpoint.post("moveSession", `${root}/move-session`, {
payload: MoveSessionPayload,
success: described(HttpApiSchema.NoContent, "Session moved"),
error: ApiMoveSessionError,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.controlPlane.moveSession",
summary: "Move session",
description: "Move a session to another project directory, optionally transferring local changes.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "controlPlane", description: "Control-plane orchestration routes." })),
)

View file

@ -1,31 +1,50 @@
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/experimental/project/:projectID/copy"
const CreateQuery = Schema.Struct({
workspace: WorkspaceRoutingQueryFields.workspace,
})
export const CreatePayload = Schema.Struct({
strategy: ProjectCopy.StrategyID,
directory: ProjectCopy.CreateInput.fields.directory,
name: ProjectCopy.CreateInput.fields.name,
context: ProjectCopy.CreateInput.fields.context,
})
export const RemovePayload = Schema.Struct({
directory: ProjectCopy.RemoveInput.fields.directory,
})
export class ApiProjectCopyError extends Schema.ErrorClass<ApiProjectCopyError>("ProjectCopyError")(
{
name: Schema.Literal("ProjectCopyError"),
data: Schema.Struct({
message: Schema.String,
}),
},
{ httpApiStatus: 400 },
) {}
export const ProjectCopyApi = HttpApi.make("projectCopy").add(
HttpApiGroup.make("projectCopy")
.add(
HttpApiEndpoint.post("create", root, {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
query: CreateQuery,
payload: CreatePayload,
success: described(ProjectCopy.Copy, "Project copy created"),
error: HttpApiError.BadRequest,
error: ApiProjectCopyError,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.create",
@ -38,7 +57,7 @@ export const ProjectCopyApi = HttpApi.make("projectCopy").add(
query: WorkspaceRoutingQuery,
payload: RemovePayload,
success: described(HttpApiSchema.NoContent, "Project copy removed"),
error: HttpApiError.BadRequest,
error: ApiProjectCopyError,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.remove",
@ -51,7 +70,7 @@ export const ProjectCopyApi = HttpApi.make("projectCopy").add(
query: WorkspaceRoutingQuery,
payload: HttpApiSchema.NoContent,
success: described(HttpApiSchema.NoContent, "Project copies refreshed"),
error: HttpApiError.BadRequest,
error: ApiProjectCopyError,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.refresh",

View file

@ -0,0 +1,35 @@
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { SessionV2 } from "@opencode-ai/core/session"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { RootHttpApi } from "../api"
import { ApiMoveSessionError, MoveSessionPayload } from "../groups/control-plane"
export const controlPlaneHandlers = HttpApiBuilder.group(RootHttpApi, "controlPlane", (handlers) =>
Effect.gen(function* () {
const service = yield* MoveSession.Service
const moveSession = Effect.fn("ControlPlaneHttpApi.moveSession")(function* (ctx: {
payload: typeof MoveSessionPayload.Type
}) {
yield* service.moveSession(ctx.payload).pipe(
Effect.mapError(
(error) =>
new ApiMoveSessionError({
name: "MoveSessionError",
data: { message: message(error) },
}),
),
)
})
return handlers.handle("moveSession", moveSession)
}),
)
function message(error: MoveSession.Error) {
if (error instanceof SessionV2.NotFoundError) return `Session not found: ${error.sessionID}`
if (error instanceof MoveSession.DestinationProjectMismatchError)
return "Destination directory belongs to another project"
return error.message
}

View file

@ -2,26 +2,104 @@ import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { InstanceState } from "@/effect/instance-state"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { Effect, Stream } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CreatePayload, RemovePayload } from "../groups/project-copy"
import { ApiProjectCopyError, CreatePayload, RemovePayload } from "../groups/project-copy"
import { Agent } from "@/agent/agent"
import { LLM } from "@/session/llm"
import { LLMEvent } from "@opencode-ai/llm"
import { MessageID, SessionID } from "@/session/schema"
import { Provider } from "@/provider/provider"
import { Slug } from "@opencode-ai/core/util/slug"
const FALLBACK_AGENT: Agent.Info = {
name: "title",
mode: "primary" as const,
permission: [],
options: {},
native: true,
prompt: "",
}
function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
return effect.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return effect.pipe(
Effect.mapError(
(error) =>
new ApiProjectCopyError({
name: "ProjectCopyError",
data: { message: message(error) },
}),
),
)
}
export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopy", (handlers) =>
Effect.gen(function* () {
const llm = yield* LLM.Service
const agent = yield* Agent.Service
const provider = yield* Provider.Service
const service = yield* ProjectCopy.Service
const generateName = Effect.fn("ProjectCopyHttpApi.generateName")(function* (context: string | undefined) {
const text = context?.trim()
if (!text) return Slug.create()
const [titleAgent, fallback] = yield* Effect.all(
[
agent.get("title").pipe(Effect.catch(() => Effect.succeed(FALLBACK_AGENT))),
provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined))),
],
{ concurrency: 2 },
)
if (!fallback) return Slug.create()
const model = titleAgent.model
? yield* provider.getModel(titleAgent.model.providerID, titleAgent.model.modelID)
: ((yield* provider.getSmallModel(fallback.providerID)) ??
(yield* provider.getModel(fallback.providerID, fallback.modelID)))
const sessionID = SessionID.descending()
const result = yield* llm
.stream({
agent: titleAgent,
user: {
id: MessageID.ascending(),
sessionID,
role: "user",
time: { created: Date.now() },
agent: titleAgent.name,
model: { providerID: model.providerID, modelID: model.id },
},
system: [],
small: true,
tools: {},
model,
sessionID,
retries: 2,
messages: [
{
role: "user",
content: `Generate a short filesystem-safe name for a project working copy based on this context:\n${text}`,
},
],
})
.pipe(
Stream.filter(LLMEvent.is.textDelta),
Stream.map((event) => event.text),
Stream.mkString,
)
return slugify(result) || Slug.create()
})
const create = Effect.fn("ProjectCopyHttpApi.create")(function* (ctx: {
params: { projectID: ProjectV2.ID }
payload: typeof CreatePayload.Type
}) {
const name =
ctx.payload.name ??
(yield* generateName(ctx.payload.context).pipe(Effect.catch(() => Effect.succeed(Slug.create()))))
return yield* badRequest(
service.create({
...ctx.payload,
name,
projectID: ctx.params.projectID,
sourceDirectory: AbsolutePath.make((yield* InstanceState.context).worktree),
}),
@ -51,3 +129,24 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
return handlers.handle("create", create).handle("remove", remove).handle("refresh", refresh)
}),
)
function slugify(input: string) {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
}
function message(error: ProjectCopy.Error) {
if (error instanceof ProjectCopy.SourceDirectoryNotFoundError)
return `Project copy source not found: ${error.directory}`
if (error instanceof ProjectCopy.DestinationExistsError)
return `Project copy destination already exists: ${error.directory}`
if (error instanceof ProjectCopy.DirectoryUnavailableError)
return `Project copy directory unavailable: ${error.directory}`
if (error instanceof ProjectCopy.StrategyNotFoundError)
return `Project copy strategy not found for: ${error.directory}`
return error.message
}

View file

@ -28,6 +28,7 @@ import { Plugin } from "@/plugin"
import { Project } from "@/project/project"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { ProviderAuth } from "@/provider/auth"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
@ -35,6 +36,7 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Question } from "@/question"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
import { LLM } from "@/session/llm"
import { SessionPrompt } from "@/session/prompt"
import { SessionRevert } from "@/session/revert"
import { SessionRunState } from "@/session/run-state"
@ -70,6 +72,7 @@ import { PtyConnectApi } from "./groups/pty"
import { eventHandlers } from "./handlers/event"
import { configHandlers } from "./handlers/config"
import { controlHandlers } from "./handlers/control"
import { controlPlaneHandlers } from "./handlers/control-plane"
import { experimentalHandlers } from "./handlers/experimental"
import { fileHandlers } from "./handlers/file"
import { globalHandlers } from "./handlers/global"
@ -120,7 +123,7 @@ const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provi
const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, globalHandlers]),
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
Layer.provide(schemaErrorLayer),
Layer.provide(httpApiAuthLayer),
)
@ -215,6 +218,7 @@ export function createRoutes(
Config.defaultLayer,
Format.defaultLayer,
LSP.defaultLayer,
LLM.defaultLayer,
Installation.defaultLayer,
MCP.defaultLayer,
ModelsDev.defaultLayer,
@ -223,6 +227,7 @@ export function createRoutes(
Project.defaultLayer,
ProjectV2.defaultLayer,
ProjectCopy.defaultLayer,
MoveSession.defaultLayer,
ProviderAuth.defaultLayer,
Provider.defaultLayer,
PtyTicket.defaultLayer,

View file

@ -0,0 +1,63 @@
import { NodeHttpServer } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Context, Effect, Layer, Option, Ref } from "effect"
import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Auth } from "../../src/auth"
import { Config } from "../../src/config/config"
import { Installation } from "../../src/installation"
import { ServerAuth } from "../../src/server/auth"
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane"
import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
import { testEffect } from "../lib/effect"
const input = MoveSession.Input.make({
sessionID: SessionV2.ID.make("ses_move"),
destination: { directory: AbsolutePath.make("/destination") },
moveChanges: true,
})
const called = Ref.makeUnsafe<MoveSession.Input | undefined>(undefined)
const apiLayer = HttpRouter.serve(
HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
Layer.provide([authorizationLayer, schemaErrorLayer]),
// Raw HttpApi routes expose an opaque handler context at the request boundary.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
HttpRouter.provideRequest(Layer.succeedContext(Context.empty() as Context.Context<unknown>)),
),
{ disableListenLog: true, disableLogger: true },
).pipe(
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provide(Layer.mock(Auth.Service)({})),
Layer.provide(Layer.mock(Config.Service)({})),
Layer.provide(Layer.mock(Installation.Service)({})),
Layer.provide(
Layer.mock(MoveSession.Service)({
moveSession: (value) => Ref.set(called, value),
}),
),
Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })),
)
const it = testEffect(apiLayer)
describe("control-plane HttpApi", () => {
it.live("moves a session through the root control-plane route", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.post("/experimental/control-plane/move-session").pipe(
HttpClientRequest.setBody(HttpBody.jsonUnsafe(input)),
HttpClient.execute,
)
expect(response.status).toBe(204)
expect(yield* Ref.get(called)).toEqual(input)
}),
)
})

View file

@ -506,6 +506,14 @@ const scenarios: Scenario[] = [
body: {},
}))
.status(400),
http.protected
.post("/experimental/control-plane/move-session", "experimental.controlPlane.moveSession")
.global()
.at(() => ({
path: "/experimental/control-plane/move-session",
body: {},
}))
.status(400),
http.protected
.get("/experimental/tool", "tool.list")
.at((ctx) => ({

View file

@ -6,10 +6,12 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Auth } from "../../src/auth"
import { Config } from "../../src/config/config"
import { Installation } from "../../src/installation"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { ServerAuth } from "../../src/server/auth"
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane"
import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
@ -17,7 +19,7 @@ import { testEffect } from "../lib/effect"
const apiLayer = HttpRouter.serve(
HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, globalHandlers]),
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
Layer.provide([authorizationLayer, schemaErrorLayer]),
// Raw HttpApi routes expose an opaque handler context at the request boundary.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
@ -28,6 +30,7 @@ const apiLayer = HttpRouter.serve(
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provide(Layer.mock(Auth.Service)({})),
Layer.provide(Layer.mock(Config.Service)({})),
Layer.provide(Layer.mock(MoveSession.Service)({})),
Layer.provide(
Layer.mock(Installation.Service)({
method: () => Effect.succeed("npm"),

View file

@ -43,9 +43,10 @@ describe("project directories and copies endpoints", () => {
const projectID = (yield* json<{ id: string }>(current)).id
const base = `/project/${projectID}`
const copies = `/experimental/project/${projectID}/copy`
const createdDirectory = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy")
const createdParent = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy")
const createdDirectory = path.join(createdParent, "copy")
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(createdDirectory, { recursive: true, force: true })).pipe(Effect.ignore),
Effect.promise(() => fs.rm(createdParent, { recursive: true, force: true })).pipe(Effect.ignore),
)
const initial = yield* request(test.directory, `${base}/directories`)
@ -55,11 +56,11 @@ describe("project directories and copies endpoints", () => {
const create = yield* request(test.directory, copies, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ strategy: "git_worktree", directory: createdDirectory }),
body: JSON.stringify({ strategy: "git_worktree", directory: createdParent, name: "copy" }),
})
expect(create.status).toBe(200)
const created = yield* json<{ directory: string }>(create)
expect(created.directory).toContain("-http-copy")
expect(created.directory).toBe(createdDirectory)
const listed = yield* request(test.directory, `${base}/directories`)
expect(yield* json<string[]>(listed)).toContain(created.directory)