diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
index 81cbdcd972..43d05893b9 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
@@ -46,7 +46,19 @@ export function DialogSessionList() {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => )
const warp = async (selection: WorkspaceSelection) => {
- if (selection.type === "none") return
+ if (selection.type === "none") {
+ await warpWorkspaceSession({
+ dialog,
+ sdk,
+ sync,
+ project,
+ toast,
+ workspaceID: null,
+ sessionID: session.id,
+ done: list,
+ })
+ return
+ }
const workspaceID = await (async () => {
if (selection.type === "existing") return selection.workspaceID
const result = await sdk.client.experimental.workspace
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
index 7bed2d353c..4b50af6f04 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
@@ -71,17 +71,21 @@ export async function warpWorkspaceSession(input: {
sync: ReturnType
project: ReturnType
toast: ReturnType
- workspaceID: string
+ workspaceID: string | null
sessionID: string
done?: () => void
showSuccessToast?: boolean
}): Promise {
- const result = await input.sdk.client.experimental.workspace
- .warp({
- id: input.workspaceID,
- sessionID: input.sessionID,
- })
- .catch(() => undefined)
+ const result = await (input.workspaceID === null
+ ? input.sdk.client.experimental.workspace.detach({
+ workspaceID: null,
+ sessionID: input.sessionID,
+ })
+ : input.sdk.client.experimental.workspace.warp({
+ id: input.workspaceID,
+ sessionID: input.sessionID,
+ })
+ ).catch(() => undefined)
if (!result || result.error) {
input.toast.show({
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
@@ -98,7 +102,7 @@ export async function warpWorkspaceSession(input: {
if (input.showSuccessToast !== false) {
input.toast.show({
- message: "Session warped into the new workspace",
+ message: input.workspaceID === null ? "Session moved to the local project" : "Session warped into the new workspace",
variant: "success",
})
}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index 2948318712..8d8c9e1635 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -212,16 +212,13 @@ export function Prompt(props: PromptProps) {
if (selection.type === "new") void createWorkspace(selection)
return
}
- if (selection.type === "none") {
- dialog.clear()
- return
- }
-
selectWorkspace(selection)
dialog.clear()
const workspace =
- selection.type === "existing"
+ selection.type === "none"
+ ? { id: null, name: "local project" }
+ : selection.type === "existing"
? { id: selection.workspaceID, name: selection.workspaceName }
: await createWorkspace(selection)
if (!workspace) return
@@ -1474,86 +1471,84 @@ export function Prompt(props: PromptProps) {
-
-
-
-
- [⋯]}>
-
-
-
-
- {(() => {
- const retry = createMemo(() => {
- const s = status()
- if (s.type !== "retry") return
- return s
- })
- const message = createMemo(() => {
- const r = retry()
- if (!r) return
- if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
- return "gemini is way too hot right now"
- if (r.message.length > 80) return r.message.slice(0, 80) + "..."
- return r.message
- })
- const isTruncated = createMemo(() => {
- const r = retry()
- if (!r) return false
- return r.message.length > 120
- })
- const [seconds, setSeconds] = createSignal(0)
- onMount(() => {
- const timer = setInterval(() => {
- const next = retry()?.next
- if (next) setSeconds(Math.round((next - Date.now()) / 1000))
- }, 1000)
-
- onCleanup(() => {
- clearInterval(timer)
- })
- })
- const handleMessageClick = () => {
- const r = retry()
- if (!r) return
- if (isTruncated()) {
- void DialogAlert.show(dialog, "Retry Error", r.message)
- }
- }
-
- const retryText = () => {
- const r = retry()
- if (!r) return ""
- const baseMessage = message()
- const truncatedHint = isTruncated() ? " (click to expand)" : ""
- const duration = formatDuration(seconds())
- const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
- return baseMessage + truncatedHint + retryInfo
- }
-
- return (
-
-
- {retryText()}
-
-
- )
- })()}
-
+
+
+
+ [⋯]}>
+
+
+
+
+ {(() => {
+ const retry = createMemo(() => {
+ const s = status()
+ if (s.type !== "retry") return
+ return s
+ })
+ const message = createMemo(() => {
+ const r = retry()
+ if (!r) return
+ if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
+ return "gemini is way too hot right now"
+ if (r.message.length > 80) return r.message.slice(0, 80) + "..."
+ return r.message
+ })
+ const isTruncated = createMemo(() => {
+ const r = retry()
+ if (!r) return false
+ return r.message.length > 120
+ })
+ const [seconds, setSeconds] = createSignal(0)
+ onMount(() => {
+ const timer = setInterval(() => {
+ const next = retry()?.next
+ if (next) setSeconds(Math.round((next - Date.now()) / 1000))
+ }, 1000)
+
+ onCleanup(() => {
+ clearInterval(timer)
+ })
+ })
+ const handleMessageClick = () => {
+ const r = retry()
+ if (!r) return
+ if (isTruncated()) {
+ void DialogAlert.show(dialog, "Retry Error", r.message)
+ }
+ }
+
+ const retryText = () => {
+ const r = retry()
+ if (!r) return ""
+ const baseMessage = message()
+ const truncatedHint = isTruncated() ? " (click to expand)" : ""
+ const duration = formatDuration(seconds())
+ const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
+ return baseMessage + truncatedHint + retryInfo
+ }
+
+ return (
+
+
+ {retryText()}
+
+
+ )
+ })()}
- 0 ? theme.primary : theme.text}>
- esc{" "}
- 0 ? theme.primary : theme.textMuted }}>
- {store.interrupt > 0 ? "again to interrupt" : "interrupt"}
-
-
-
+ 0 ? theme.primary : theme.text}>
+ esc{" "}
+ 0 ? theme.primary : theme.textMuted }}>
+ {store.interrupt > 0 ? "again to interrupt" : "interrupt"}
+
+
+
{(notice) => (
diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts
index 7976f5762a..97698b1abf 100644
--- a/packages/opencode/src/control-plane/workspace.ts
+++ b/packages/opencode/src/control-plane/workspace.ts
@@ -6,6 +6,7 @@ import { asc } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { inArray } from "drizzle-orm"
import { Project } from "@/project/project"
+import { Instance } from "@/project/instance"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Auth } from "@/auth"
@@ -83,7 +84,7 @@ export const CreateInput = Schema.Struct({
export type CreateInput = Schema.Schema.Type
export const SessionWarpInput = Schema.Struct({
- workspaceID: WorkspaceID,
+ workspaceID: Schema.NullOr(WorkspaceID),
sessionID: SessionID,
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type SessionWarpInput = Schema.Schema.Type
@@ -504,13 +505,6 @@ export const layer = Layer.effect(
sessionID: input.sessionID,
})
- const space = yield* get(input.workspaceID)
- if (!space)
- return yield* new WorkspaceNotFoundError({
- message: `Workspace not found: ${input.workspaceID}`,
- workspaceID: input.workspaceID,
- })
-
const current = yield* db((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
@@ -541,10 +535,36 @@ export const layer = Layer.effect(
// "claim" this session so any future events coming from
// the old workspace are ignored
- SyncEvent.claim(input.sessionID, input.workspaceID)
+ SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id)
}
}
+ if (input.workspaceID === null) {
+ yield* Effect.sync(() =>
+ SyncEvent.run(Session.Event.Updated, {
+ sessionID: input.sessionID,
+ info: {
+ workspaceID: null,
+ },
+ }),
+ )
+
+ log.info("session warp complete", {
+ workspaceID: input.workspaceID,
+ sessionID: input.sessionID,
+ target: "local",
+ })
+ return
+ }
+
+ const workspaceID = input.workspaceID
+ const space = yield* get(workspaceID)
+ if (!space)
+ return yield* new WorkspaceNotFoundError({
+ message: `Workspace not found: ${workspaceID}`,
+ workspaceID,
+ })
+
const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
@@ -624,8 +644,8 @@ export const layer = Layer.effect(
body,
})
return yield* new SessionWarpHttpError({
- message: `Failed to warp session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${response.status} ${body}`,
- workspaceID: input.workspaceID,
+ message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`,
+ workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
@@ -658,8 +678,8 @@ export const layer = Layer.effect(
body,
})
return yield* new SessionWarpHttpError({
- message: `Failed to steal session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${response.status} ${body}`,
- workspaceID: input.workspaceID,
+ message: `Failed to steal session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`,
+ workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts
index c631351955..6cfc3861ad 100644
--- a/packages/opencode/src/server/routes/control/workspace.ts
+++ b/packages/opencode/src/server/routes/control/workspace.ts
@@ -137,6 +137,25 @@ export const WorkspaceRoutes = lazy(() =>
return c.json(await Workspace.remove(id))
},
)
+ .post(
+ "/warp",
+ describeRoute({
+ summary: "Warp session into workspace",
+ description: "Move a session's sync history into the target workspace, or detach it to the local project.",
+ operationId: "experimental.workspace.detach",
+ responses: {
+ 204: {
+ description: "Session warped",
+ },
+ ...errors(400),
+ },
+ }),
+ validator("json", Workspace.SessionWarpInput.zodObject),
+ async (c) => {
+ await Workspace.sessionWarp(c.req.valid("json") as Workspace.SessionWarpInput)
+ return c.body(null, 204)
+ },
+ )
.post(
"/:id/warp",
describeRoute({
diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts
index 591d2b0ab2..8949cf492e 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts
@@ -16,6 +16,7 @@ export const WorkspacePaths = {
list: root,
status: `${root}/status`,
remove: `${root}/:id`,
+ warpLocal: `${root}/warp`,
warp: `${root}/:id/warp`,
} as const
@@ -72,6 +73,17 @@ export const WorkspaceApi = HttpApi.make("workspace")
description: "Remove an existing workspace.",
}),
),
+ HttpApiEndpoint.post("warpLocal", WorkspacePaths.warpLocal, {
+ payload: Workspace.SessionWarpInput,
+ success: described(HttpApiSchema.NoContent, "Session warped"),
+ error: HttpApiError.BadRequest,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "experimental.workspace.detach",
+ summary: "Warp session into workspace",
+ description: "Move a session's sync history into the target workspace, or detach it to the local project.",
+ }),
+ ),
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
params: { id: Workspace.Info.fields.id },
payload: WarpPayload,
diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts
index 69999122cf..ba50a218df 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts
@@ -56,12 +56,21 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
return HttpApiSchema.NoContent.make()
})
+ const warpLocal = Effect.fn("WorkspaceHttpApi.warpLocal")(function* (ctx: {
+ payload: typeof Workspace.SessionWarpInput.Type
+ }) {
+ const instance = yield* InstanceState.context
+ yield* Effect.promise(() => Instance.restore(instance, () => Workspace.sessionWarp(ctx.payload)))
+ return HttpApiSchema.NoContent.make()
+ })
+
return handlers
.handle("adaptors", adaptors)
.handle("list", list)
.handle("create", create)
.handle("status", status)
.handle("remove", remove)
+ .handle("warpLocal", warpLocal)
.handle("warp", warp)
}),
)
diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts
index 896c0fe901..39cde9dfd1 100644
--- a/packages/opencode/test/control-plane/workspace.test.ts
+++ b/packages/opencode/test/control-plane/workspace.test.ts
@@ -297,6 +297,16 @@ function sessionSequence(sessionID: SessionID) {
)?.seq
}
+function sessionSequenceOwner(sessionID: SessionID) {
+ return Database.use((db) =>
+ db
+ .select({ ownerID: EventSequenceTable.owner_id })
+ .from(EventSequenceTable)
+ .where(eq(EventSequenceTable.aggregate_id, sessionID))
+ .get(),
+ )?.ownerID
+}
+
function sessionUpdatedType() {
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
}
@@ -586,6 +596,134 @@ describe("workspace-old CRUD", () => {
expect(await WorkspaceOld.get(info.id)).toBeUndefined()
})
})
+
+ test("sessionWarp moves a session into a local workspace and claims ownership", async () => {
+ await withInstance(async (dir) => {
+ const previousType = unique("warp-prev-local")
+ const targetType = unique("warp-target-local")
+ const previous = workspaceInfo(Instance.project.id, previousType)
+ const target = workspaceInfo(Instance.project.id, targetType)
+ insertWorkspace(previous)
+ insertWorkspace(target)
+ registerAdaptor(Instance.project.id, previousType, localAdaptor(path.join(dir, "warp-prev-local")).adaptor)
+ registerAdaptor(Instance.project.id, targetType, localAdaptor(path.join(dir, "warp-target-local")).adaptor)
+ const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
+ attachSessionToWorkspace(session.id, previous.id)
+
+ await WorkspaceOld.sessionWarp({ workspaceID: target.id, sessionID: session.id })
+
+ expect(
+ Database.use((db) =>
+ db.select({ workspaceID: SessionTable.workspace_id }).from(SessionTable).where(eq(SessionTable.id, session.id)).get(),
+ )?.workspaceID,
+ ).toBe(target.id)
+ expect(sessionSequenceOwner(session.id)).toBe(target.id)
+ })
+ })
+
+ test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
+ await withInstance(async (dir) => {
+ const previousType = unique("warp-detach-local")
+ const previous = workspaceInfo(Instance.project.id, previousType)
+ insertWorkspace(previous)
+ registerAdaptor(Instance.project.id, previousType, localAdaptor(path.join(dir, "warp-detach-local")).adaptor)
+ const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
+ attachSessionToWorkspace(session.id, previous.id)
+
+ await WorkspaceOld.sessionWarp({ workspaceID: null, sessionID: session.id })
+
+ expect(
+ Database.use((db) =>
+ db.select({ workspaceID: SessionTable.workspace_id }).from(SessionTable).where(eq(SessionTable.id, session.id)).get(),
+ )?.workspaceID,
+ ).toBeNull()
+ expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id)
+ })
+ })
+
+ it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => {
+ const calls: FetchCall[] = []
+ let historySessionID: SessionID | undefined
+ let historyNextSeq = 0
+ return Effect.gen(function* () {
+ yield* HttpServer.serveEffect()(
+ Effect.gen(function* () {
+ const req = yield* HttpServerRequest.HttpServerRequest
+ const bodyText = yield* req.text
+ const call = {
+ url: new URL(req.url, "http://localhost"),
+ method: req.method,
+ headers: new Headers(req.headers),
+ bodyText,
+ json: bodyText ? JSON.parse(bodyText) : undefined,
+ }
+ calls.push(call)
+ if (call.url.pathname === "/warp-source/sync/history") {
+ return yield* HttpServerResponse.json([
+ {
+ id: `evt_${unique("warp-source-history")}`,
+ aggregate_id: historySessionID!,
+ seq: historyNextSeq,
+ type: sessionUpdatedType(),
+ data: { sessionID: historySessionID!, info: { title: "from source history" } },
+ },
+ ])
+ }
+ if (call.url.pathname === "/warp-target/sync/replay") return yield* HttpServerResponse.json({ sessionID: "ok" })
+ if (call.url.pathname === "/warp-target/sync/steal") return yield* HttpServerResponse.json({ sessionID: "ok" })
+ return HttpServerResponse.text("unexpected", { status: 500 })
+ }),
+ )
+ const url = yield* serverUrl()
+ yield* provideTmpdirInstance(
+ () =>
+ Effect.gen(function* () {
+ const workspace = yield* WorkspaceOld.Service
+ const sessionSvc = yield* SessionNs.Service
+ const previousType = unique("warp-remote-source")
+ const targetType = unique("warp-remote-target")
+ const previous = workspaceInfo(Instance.project.id, previousType)
+ const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" })
+ insertWorkspace(previous)
+ insertWorkspace(target)
+ registerAdaptor(Instance.project.id, previousType, remoteAdaptor(`${url}/warp-source`).adaptor)
+ registerAdaptor(Instance.project.id, targetType, remoteAdaptor(`${url}/warp-target`).adaptor)
+ const session = yield* sessionSvc.create({})
+ attachSessionToWorkspace(session.id, previous.id)
+ historySessionID = session.id
+ historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
+
+ yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id })
+
+ expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([
+ "POST /warp-source/sync/history",
+ "POST /warp-target/sync/replay",
+ "POST /warp-target/sync/steal",
+ ])
+ expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 })
+ expect(calls[1].json).toMatchObject({
+ directory: "remote-target-dir",
+ events: [
+ {
+ aggregateID: session.id,
+ seq: 0,
+ type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version),
+ },
+ {
+ aggregateID: session.id,
+ seq: historyNextSeq,
+ type: sessionUpdatedType(),
+ },
+ ],
+ })
+ expect(calls[2].json).toEqual({ sessionID: session.id })
+ expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
+ expect(sessionSequenceOwner(session.id)).toBe(target.id)
+ }),
+ { git: true },
+ )
+ })
+ })
})
describe("workspace-old sync state", () => {
diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts
index 32a08715ca..f40edfad44 100644
--- a/packages/opencode/test/sync/index.test.ts
+++ b/packages/opencode/test/sync/index.test.ts
@@ -5,7 +5,7 @@ import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { SyncEvent } from "../../src/sync"
import { Database } from "@/storage/db"
-import { EventTable } from "../../src/sync/event.sql"
+import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
import { Identifier } from "../../src/id/id"
import { Flag } from "@opencode-ai/core/flag/flag"
import { initProjectors } from "../../src/server/projectors"
@@ -233,5 +233,72 @@ describe("SyncEvent", () => {
expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
}),
)
+
+ test(
+ "claims unowned event sequence on replay with ownerID",
+ withInstance(() => {
+ const { Created } = setup()
+ const id = Identifier.descending("message")
+
+ SyncEvent.replay(
+ {
+ id: "evt_1",
+ type: SyncEvent.versionedType(Created.type, Created.version),
+ seq: 0,
+ aggregateID: id,
+ data: { id, name: "owned" },
+ },
+ { publish: false, ownerID: "owner-1" },
+ )
+
+ const row = Database.use((db) =>
+ db
+ .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
+ .from(EventSequenceTable)
+ .get(),
+ )
+ expect(row).toEqual({ seq: 0, ownerID: "owner-1" })
+ }),
+ )
+
+ test(
+ "ignores replay from a different owner after sequence is claimed",
+ withInstance(() => {
+ const { Created } = setup()
+ const id = Identifier.descending("message")
+
+ SyncEvent.replay(
+ {
+ id: "evt_1",
+ type: SyncEvent.versionedType(Created.type, Created.version),
+ seq: 0,
+ aggregateID: id,
+ data: { id, name: "first" },
+ },
+ { publish: false, ownerID: "owner-1" },
+ )
+ SyncEvent.replay(
+ {
+ id: "evt_2",
+ type: SyncEvent.versionedType(Created.type, Created.version),
+ seq: 1,
+ aggregateID: id,
+ data: { id, name: "ignored" },
+ },
+ { publish: false, ownerID: "owner-2" },
+ )
+
+ const events = Database.use((db) => db.select().from(EventTable).all())
+ const sequence = Database.use((db) =>
+ db
+ .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
+ .from(EventSequenceTable)
+ .get(),
+ )
+ expect(events).toHaveLength(1)
+ expect(events[0].id).toBe("evt_1")
+ expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" })
+ }),
+ )
})
})
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index c7bf8919c3..413d311c71 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -32,6 +32,8 @@ import type {
ExperimentalWorkspaceAdaptorListResponses,
ExperimentalWorkspaceCreateErrors,
ExperimentalWorkspaceCreateResponses,
+ ExperimentalWorkspaceDetachErrors,
+ ExperimentalWorkspaceDetachResponses,
ExperimentalWorkspaceListResponses,
ExperimentalWorkspaceRemoveErrors,
ExperimentalWorkspaceRemoveResponses,
@@ -654,6 +656,49 @@ export class Workspace extends HeyApiClient {
})
}
+ /**
+ * Warp session into workspace
+ *
+ * Move a session's sync history into the target workspace, or detach it to the local project.
+ */
+ public detach(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ workspaceID?: string | null
+ sessionID?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "workspaceID" },
+ { in: "body", key: "sessionID" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<
+ ExperimentalWorkspaceDetachResponses,
+ ExperimentalWorkspaceDetachErrors,
+ ThrowOnError
+ >({
+ url: "/experimental/workspace/warp",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
/**
* Remove workspace
*
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 060b4e4b20..1e5b32c81b 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -2520,6 +2520,39 @@ export type ExperimentalWorkspaceStatusResponses = {
export type ExperimentalWorkspaceStatusResponse =
ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses]
+export type ExperimentalWorkspaceDetachData = {
+ body?: {
+ workspaceID: string | null
+ sessionID: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/workspace/warp"
+}
+
+export type ExperimentalWorkspaceDetachErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type ExperimentalWorkspaceDetachError =
+ ExperimentalWorkspaceDetachErrors[keyof ExperimentalWorkspaceDetachErrors]
+
+export type ExperimentalWorkspaceDetachResponses = {
+ /**
+ * Session warped
+ */
+ 204: void
+}
+
+export type ExperimentalWorkspaceDetachResponse =
+ ExperimentalWorkspaceDetachResponses[keyof ExperimentalWorkspaceDetachResponses]
+
export type ExperimentalWorkspaceRemoveData = {
body?: never
path: {