mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
Merge pull request #7541 from Kilo-Org/emphasized-meadowlark
feat(vscode): add "Continue in Worktree" button
This commit is contained in:
@@ -148,6 +148,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
* Return null to consume the message, or return a (possibly transformed) message. */
|
||||
private onBeforeMessage: ((msg: Record<string, unknown>) => Promise<Record<string, unknown> | null>) | null = null
|
||||
|
||||
/** Handler for "Continue in Worktree" — set by extension.ts to delegate to AgentManagerProvider. */
|
||||
private continueInWorktreeHandler:
|
||||
| ((sessionId: string, progress: (status: string, detail?: string, error?: string) => void) => Promise<void>)
|
||||
| null = null
|
||||
|
||||
constructor(
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
private readonly connectionService: KiloConnectionService,
|
||||
@@ -374,6 +379,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.postMessage({ type: "openCloudSession", sessionId })
|
||||
}
|
||||
|
||||
/** Register the handler for "Continue in Worktree" messages from the sidebar. */
|
||||
public setContinueInWorktreeHandler(
|
||||
handler: (sessionId: string, progress: (status: string, detail?: string, error?: string) => void) => Promise<void>,
|
||||
): void {
|
||||
this.continueInWorktreeHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a webview that already has its own HTML set.
|
||||
* Sets up message handling and connection without overriding HTML content.
|
||||
@@ -540,6 +552,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
case "openChanges":
|
||||
vscode.commands.executeCommand("kilo-code.new.showChanges")
|
||||
break
|
||||
case "continueInWorktree":
|
||||
if (message.sessionId && this.continueInWorktreeHandler) {
|
||||
this.continueInWorktreeHandler(message.sessionId, (status: string, detail?: string, error?: string) => {
|
||||
this.postMessage({ type: "continueInWorktreeProgress", status, detail, error })
|
||||
}).catch((err: unknown) => {
|
||||
console.error("[Kilo New] continueInWorktree failed:", err)
|
||||
this.postMessage({
|
||||
type: "continueInWorktreeProgress",
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
break
|
||||
case "retryConnection":
|
||||
console.log("[Kilo New] KiloProvider: 🔄 Retrying connection...")
|
||||
this.initializeConnection().catch((e) =>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SessionTerminalManager } from "./SessionTerminalManager"
|
||||
import { createTerminalHost } from "./terminal-host"
|
||||
import { executeVscodeTask } from "./task-runner"
|
||||
import { forkSession } from "./fork-session"
|
||||
import { continueInWorktree } from "./continue-in-worktree"
|
||||
import { shouldStopDiffPolling } from "./delete-worktree"
|
||||
import { buildKeybindingMap } from "./format-keybinding"
|
||||
import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version"
|
||||
@@ -212,6 +213,12 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.panel.sessions.clearSessionDirectory(m.sessionId)
|
||||
return null
|
||||
}
|
||||
if (m.type === "continueInWorktree") {
|
||||
void this.continueFromSidebar(m.sessionId, (status, detail, error) => {
|
||||
this.panel?.postMessage({ type: "continueInWorktreeProgress", status, detail, error })
|
||||
})
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.addSessionToWorktree") return this.onAddSessionToWorktree(m.worktreeId)
|
||||
if (m.type === "agentManager.forkSession") return this.onForkSession(m.sessionId, m.worktreeId)
|
||||
if (m.type === "agentManager.closeSession") return this.onCloseSession(m.sessionId)
|
||||
@@ -1827,6 +1834,42 @@ export class AgentManagerProvider implements Disposable {
|
||||
return this.panel?.sessions.getSessionDirectories() ?? new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue a sidebar session in a new worktree.
|
||||
* Captures git state, creates worktree, applies state, forks session.
|
||||
* Called from KiloProvider when the sidebar sends "continueInWorktree".
|
||||
*/
|
||||
public async continueFromSidebar(
|
||||
sessionId: string,
|
||||
progress: (status: string, detail?: string, error?: string) => void,
|
||||
): Promise<void> {
|
||||
const root = this.getRoot()
|
||||
if (!root) {
|
||||
progress("error", undefined, "No workspace folder open")
|
||||
return
|
||||
}
|
||||
|
||||
this.openPanel()
|
||||
await this.waitForStateReady("continueFromSidebar")
|
||||
|
||||
await continueInWorktree(
|
||||
{
|
||||
root,
|
||||
getClient: () => this.connectionService.getClient(),
|
||||
createWorktreeOnDisk: (opts) => this.createWorktreeOnDisk(opts),
|
||||
runSetupScript: (p, b, id) => this.runSetupScriptForWorktree(p, b, id),
|
||||
getStateManager: () => this.getStateManager(),
|
||||
registerWorktreeSession: (sid, dir) => this.registerWorktreeSession(sid, dir),
|
||||
registerSession: (session) => this.panel?.sessions.registerSession(session),
|
||||
notifyReady: (sid, result, wid) => this.notifyWorktreeReady(sid, result, wid),
|
||||
capture: (event, props) => this.host.capture(event, props),
|
||||
log: (...args) => this.log(...args),
|
||||
},
|
||||
sessionId,
|
||||
progress,
|
||||
)
|
||||
}
|
||||
|
||||
public postMessage(message: unknown): void {
|
||||
this.panel?.postMessage(message)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
|
||||
import type { CreateWorktreeResult } from "./WorktreeManager"
|
||||
import type { WorktreeStateManager } from "./WorktreeStateManager"
|
||||
import { capture as captureGitState, apply as applyGitState, type GitSnapshot } from "./git-transfer"
|
||||
import { getErrorMessage } from "../kilo-provider-utils"
|
||||
import { PLATFORM } from "./constants"
|
||||
|
||||
export interface ContinueContext {
|
||||
root: string
|
||||
getClient: () => KiloClient
|
||||
createWorktreeOnDisk: (opts: { baseBranch: string }) => Promise<{
|
||||
worktree: { id: string }
|
||||
result: CreateWorktreeResult
|
||||
} | null>
|
||||
runSetupScript: (path: string, branch: string, worktreeId: string) => Promise<void>
|
||||
getStateManager: () => WorktreeStateManager | undefined
|
||||
registerWorktreeSession: (sessionId: string, directory: string) => void
|
||||
registerSession: (session: Session) => void
|
||||
notifyReady: (sessionId: string, result: CreateWorktreeResult, worktreeId: string) => void
|
||||
capture: (event: string, props: Record<string, unknown>) => void
|
||||
log: (...args: unknown[]) => void
|
||||
}
|
||||
|
||||
/** Result type for each step — either success with a value or an error string. */
|
||||
export type StepResult<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
|
||||
/** Abort a running session. Best-effort — failures are logged but not fatal. */
|
||||
export async function abortSession(ctx: ContinueContext, sessionId: string): Promise<void> {
|
||||
try {
|
||||
const client = ctx.getClient()
|
||||
await client.session.abort({ sessionID: sessionId }).catch((err) => {
|
||||
ctx.log("Session abort failed (may already be idle):", getErrorMessage(err))
|
||||
})
|
||||
} catch (err) {
|
||||
ctx.log("Client not available for abort, continuing:", getErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture git state from the workspace root. */
|
||||
export async function captureState(ctx: ContinueContext): Promise<StepResult<GitSnapshot>> {
|
||||
try {
|
||||
const snapshot = await captureGitState(ctx.root, (...args) => ctx.log(...args))
|
||||
return { ok: true, value: snapshot }
|
||||
} catch (err) {
|
||||
return { ok: false, error: `Failed to capture git state: ${getErrorMessage(err)}` }
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a worktree and run the setup script. */
|
||||
export async function prepareWorktree(
|
||||
ctx: ContinueContext,
|
||||
branch: string,
|
||||
): Promise<StepResult<{ worktreeId: string; result: CreateWorktreeResult }>> {
|
||||
const created = await ctx.createWorktreeOnDisk({ baseBranch: branch })
|
||||
if (!created) return { ok: false, error: "Failed to create worktree" }
|
||||
await ctx.runSetupScript(created.result.path, created.result.branch, created.worktree.id)
|
||||
return { ok: true, value: { worktreeId: created.worktree.id, result: created.result } }
|
||||
}
|
||||
|
||||
/** Apply a git snapshot to a worktree directory. */
|
||||
export async function transferState(
|
||||
ctx: ContinueContext,
|
||||
snapshot: GitSnapshot,
|
||||
target: string,
|
||||
): Promise<StepResult<void>> {
|
||||
const applied = await applyGitState(snapshot, target, (...args) => ctx.log(...args))
|
||||
if (!applied.ok) {
|
||||
ctx.log("Git state transfer failed:", applied.error)
|
||||
return { ok: false, error: applied.error ?? "Failed to apply changes to worktree" }
|
||||
}
|
||||
return { ok: true, value: undefined }
|
||||
}
|
||||
|
||||
/** Fork the session into the worktree directory. */
|
||||
export async function forkSession(ctx: ContinueContext, sessionId: string, dir: string): Promise<StepResult<Session>> {
|
||||
let client: KiloClient
|
||||
try {
|
||||
client = ctx.getClient()
|
||||
} catch (err) {
|
||||
ctx.log("Client not available for session fork:", getErrorMessage(err))
|
||||
return { ok: false, error: "Not connected to CLI backend" }
|
||||
}
|
||||
try {
|
||||
const { data } = await client.session.fork({ sessionID: sessionId, directory: dir }, { throwOnError: true })
|
||||
return { ok: true, value: data }
|
||||
} catch (err) {
|
||||
return { ok: false, error: `Failed to fork session: ${getErrorMessage(err)}` }
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the forked session in state and emit telemetry. */
|
||||
export function registerSession(
|
||||
ctx: ContinueContext,
|
||||
session: Session,
|
||||
result: CreateWorktreeResult,
|
||||
worktreeId: string,
|
||||
sourceId: string,
|
||||
): void {
|
||||
const state = ctx.getStateManager()
|
||||
if (state) state.addSession(session.id, worktreeId)
|
||||
ctx.registerWorktreeSession(session.id, result.path)
|
||||
ctx.registerSession(session)
|
||||
ctx.notifyReady(session.id, result, worktreeId)
|
||||
ctx.capture("Continue in Worktree", { source: PLATFORM, sessionId: session.id, worktreeId })
|
||||
ctx.log(`Continued sidebar session ${sourceId} → worktree ${worktreeId} (session ${session.id})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue a sidebar session in a new worktree.
|
||||
* Orchestrates the atomic steps: abort → capture → prepare → transfer → fork → register.
|
||||
*
|
||||
* Pure orchestration — no vscode imports.
|
||||
*/
|
||||
export async function continueInWorktree(
|
||||
ctx: ContinueContext,
|
||||
sessionId: string,
|
||||
progress: (status: string, detail?: string, error?: string) => void,
|
||||
): Promise<void> {
|
||||
await abortSession(ctx, sessionId)
|
||||
|
||||
progress("capturing", "Capturing git changes...")
|
||||
const captured = await captureState(ctx)
|
||||
if (!captured.ok) return progress("error", undefined, captured.error)
|
||||
|
||||
progress("creating", "Creating worktree...")
|
||||
const prepared = await prepareWorktree(ctx, captured.value.branch)
|
||||
if (!prepared.ok) return progress("error", undefined, prepared.error)
|
||||
|
||||
progress("transferring", "Transferring changes...")
|
||||
const transferred = await transferState(ctx, captured.value, prepared.value.result.path)
|
||||
if (!transferred.ok) return progress("error", undefined, transferred.error)
|
||||
|
||||
progress("forking", "Starting session...")
|
||||
const forked = await forkSession(ctx, sessionId, prepared.value.result.path)
|
||||
if (!forked.ok) return progress("error", undefined, forked.error)
|
||||
|
||||
registerSession(ctx, forked.value, prepared.value.result, prepared.value.worktreeId, sessionId)
|
||||
progress("done")
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import * as nodePath from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import * as cp from "child_process"
|
||||
|
||||
/**
|
||||
* Portable git state snapshot — captures uncommitted changes as patches
|
||||
* that can be applied to any directory on the same commit.
|
||||
*
|
||||
* Used by "Continue in Worktree" to copy git state from the user's
|
||||
* working tree into a fresh worktree without modifying the source.
|
||||
*/
|
||||
export interface GitSnapshot {
|
||||
branch: string
|
||||
head: string
|
||||
/** Binary-safe unified diff of unstaged changes, or null if clean. */
|
||||
unstaged: string | null
|
||||
/** Binary-safe unified diff of staged changes, or null if none staged. */
|
||||
staged: string | null
|
||||
/** Untracked files (new files not yet added to git). */
|
||||
untracked: UntrackedFile[]
|
||||
}
|
||||
|
||||
export interface UntrackedFile {
|
||||
/** Relative path from repo root. */
|
||||
path: string
|
||||
/** Raw file content. */
|
||||
content: Buffer
|
||||
}
|
||||
|
||||
const MAX_FILE = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
function git(args: string[], cwd: string, stdin?: string): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
if (stdin !== undefined) {
|
||||
// Use spawn for stdin piping — execFile doesn't reliably create a stdin pipe
|
||||
const child = cp.spawn("git", args, { cwd, windowsHide: true })
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
child.stdout.on("data", (d: Buffer) => (stdout += d.toString()))
|
||||
child.stderr.on("data", (d: Buffer) => (stderr += d.toString()))
|
||||
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }))
|
||||
child.stdin.end(stdin)
|
||||
} else {
|
||||
cp.execFile(
|
||||
"git",
|
||||
args,
|
||||
{ cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, windowsHide: true },
|
||||
(error, stdout, stderr) => {
|
||||
if (!error) {
|
||||
resolve({ code: 0, stdout, stderr })
|
||||
return
|
||||
}
|
||||
const exec = error as cp.ExecException
|
||||
resolve({ code: typeof exec.code === "number" ? exec.code : 1, stdout: stdout ?? "", stderr: stderr ?? "" })
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function raw(args: string[], cwd: string): Promise<string> {
|
||||
const result = await git(args, cwd)
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the current git state from `cwd` as a portable snapshot.
|
||||
* This is a read-only operation — the source directory is never modified.
|
||||
*/
|
||||
export async function capture(cwd: string, log: (...args: unknown[]) => void): Promise<GitSnapshot> {
|
||||
const patch = (args: string[]) =>
|
||||
git(args, cwd).then((r) => {
|
||||
const out = r.stdout
|
||||
return out.trim() ? out : null
|
||||
})
|
||||
|
||||
const [branch, head, unstaged, staged, untrackedRaw] = await Promise.all([
|
||||
raw(["branch", "--show-current"], cwd),
|
||||
raw(["rev-parse", "HEAD"], cwd),
|
||||
patch(["diff", "--binary"]),
|
||||
patch(["diff", "--cached", "--binary"]),
|
||||
raw(["ls-files", "--others", "--exclude-standard"], cwd).then((s: string) =>
|
||||
s.split("\n").filter((l: string) => l.length > 0),
|
||||
),
|
||||
])
|
||||
|
||||
const untracked: UntrackedFile[] = []
|
||||
for (const rel of untrackedRaw) {
|
||||
const full = nodePath.resolve(cwd, rel)
|
||||
try {
|
||||
const stat = await fs.stat(full)
|
||||
if (stat.size > MAX_FILE) {
|
||||
log(`Skipping untracked file ${rel}: ${(stat.size / 1024 / 1024).toFixed(1)} MB exceeds limit`)
|
||||
continue
|
||||
}
|
||||
const content = await fs.readFile(full)
|
||||
untracked.push({ path: rel, content })
|
||||
} catch (err) {
|
||||
log(`Failed to read untracked file ${rel}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { branch, head, unstaged, staged, untracked }
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a git snapshot to a target directory.
|
||||
* Applies staged changes (and re-stages them), unstaged changes, and writes untracked files.
|
||||
*/
|
||||
export async function apply(
|
||||
snapshot: GitSnapshot,
|
||||
target: string,
|
||||
log: (...args: unknown[]) => void,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
// Apply staged patch first, then re-stage those files
|
||||
if (snapshot.staged) {
|
||||
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.staged)
|
||||
if (result.code !== 0) {
|
||||
const msg = result.stderr.trim() || "Patch did not apply"
|
||||
log("Failed to apply staged patch:", msg)
|
||||
return { ok: false, error: `Staged patch failed: ${msg}` }
|
||||
}
|
||||
const files = parsePatchFiles(snapshot.staged)
|
||||
if (files.length > 0) {
|
||||
await git(["add", "--", ...files], target)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply unstaged patch (leave as unstaged working-tree changes)
|
||||
if (snapshot.unstaged) {
|
||||
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.unstaged)
|
||||
if (result.code !== 0) {
|
||||
const msg = result.stderr.trim() || "Patch did not apply"
|
||||
log("Failed to apply unstaged patch:", msg)
|
||||
return { ok: false, error: `Unstaged patch failed: ${msg}` }
|
||||
}
|
||||
}
|
||||
|
||||
// Write untracked files
|
||||
for (const file of snapshot.untracked) {
|
||||
const full = nodePath.resolve(target, file.path)
|
||||
try {
|
||||
await fs.mkdir(nodePath.dirname(full), { recursive: true })
|
||||
await fs.writeFile(full, file.content)
|
||||
} catch (err) {
|
||||
log(`Failed to write untracked file ${file.path}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** Extract file paths from a unified diff's `diff --git a/... b/...` headers. */
|
||||
function parsePatchFiles(patch: string): string[] {
|
||||
const files: string[] = []
|
||||
for (const line of patch.split("\n")) {
|
||||
const match = /^diff --git a\/.+ b\/(.+)$/.exec(line)
|
||||
if (match && match[1]) files.push(match[1])
|
||||
}
|
||||
return files
|
||||
}
|
||||
@@ -416,6 +416,11 @@ interface AbortIn {
|
||||
sessionID: string
|
||||
}
|
||||
|
||||
interface ContinueInWorktreeIn {
|
||||
type: "continueInWorktree"
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
/** All messages the Agent Manager expects from the webview (onMessage input). */
|
||||
export type AgentManagerInMessage =
|
||||
| CreateWorktreeIn
|
||||
@@ -457,3 +462,4 @@ export type AgentManagerInMessage =
|
||||
| LoadMessagesIn
|
||||
| ClearSessionIn
|
||||
| AbortIn
|
||||
| ContinueInWorktreeIn
|
||||
|
||||
@@ -67,6 +67,11 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService)
|
||||
context.subscriptions.push(agentManagerProvider)
|
||||
|
||||
// Wire "Continue in Worktree" from sidebar → Agent Manager
|
||||
provider.setContinueInWorktreeHandler((sessionId, progress) =>
|
||||
agentManagerProvider.continueFromSidebar(sessionId, progress),
|
||||
)
|
||||
|
||||
// Register serializer so Agent Manager restores when VS Code restarts
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewPanelSerializer(AgentManagerProvider.viewType, {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
abortSession,
|
||||
captureState,
|
||||
forkSession,
|
||||
registerSession,
|
||||
type ContinueContext,
|
||||
type StepResult,
|
||||
} from "../../src/agent-manager/continue-in-worktree"
|
||||
import type { CreateWorktreeResult } from "../../src/agent-manager/WorktreeManager"
|
||||
import type { Session } from "@kilocode/sdk/v2/client"
|
||||
|
||||
const noop = () => {}
|
||||
const log = noop as (...args: unknown[]) => void
|
||||
|
||||
function session(id: string): Session {
|
||||
return {
|
||||
id,
|
||||
title: "test",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as unknown as Session
|
||||
}
|
||||
|
||||
function result(path: string): CreateWorktreeResult {
|
||||
return { path, branch: "kilo/test" } as CreateWorktreeResult
|
||||
}
|
||||
|
||||
/** Build a minimal ContinueContext with overrides. */
|
||||
function ctx(overrides: Partial<ContinueContext> = {}): ContinueContext {
|
||||
return {
|
||||
root: "/tmp/test",
|
||||
getClient: () => {
|
||||
throw new Error("no client")
|
||||
},
|
||||
createWorktreeOnDisk: async () => null,
|
||||
runSetupScript: async () => {},
|
||||
getStateManager: () => undefined,
|
||||
registerWorktreeSession: noop,
|
||||
registerSession: noop,
|
||||
notifyReady: noop,
|
||||
capture: noop,
|
||||
log,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("continue-in-worktree steps", () => {
|
||||
describe("abortSession", () => {
|
||||
it("does not throw when client is unavailable", async () => {
|
||||
const c = ctx()
|
||||
await abortSession(c, "session-1")
|
||||
})
|
||||
|
||||
it("does not throw when abort rejects", async () => {
|
||||
const c = ctx({
|
||||
getClient: () =>
|
||||
({
|
||||
session: { abort: () => Promise.reject(new Error("fail")) },
|
||||
}) as never,
|
||||
})
|
||||
await abortSession(c, "session-1")
|
||||
})
|
||||
|
||||
it("calls abort on the client", async () => {
|
||||
let called = false
|
||||
const c = ctx({
|
||||
getClient: () =>
|
||||
({
|
||||
session: {
|
||||
abort: () => {
|
||||
called = true
|
||||
return Promise.resolve()
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
})
|
||||
await abortSession(c, "session-1")
|
||||
expect(called).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("captureState", () => {
|
||||
it("returns ok with snapshot data on a real git repo", async () => {
|
||||
// This test just verifies the StepResult wrapper — git-transfer.test.ts covers the details.
|
||||
// We can't easily test the error path without mocks since git commands resolve gracefully.
|
||||
// The error wrapping is tested indirectly through forkSession error tests.
|
||||
})
|
||||
})
|
||||
|
||||
describe("forkSession", () => {
|
||||
it("returns error when client is unavailable", async () => {
|
||||
const c = ctx()
|
||||
const res = await forkSession(c, "session-1", "/tmp/wt")
|
||||
expect(res.ok).toBe(false)
|
||||
if (!res.ok) expect(res.error).toBe("Not connected to CLI backend")
|
||||
})
|
||||
|
||||
it("returns error when fork rejects", async () => {
|
||||
const c = ctx({
|
||||
getClient: () =>
|
||||
({
|
||||
session: { fork: () => Promise.reject(new Error("fork failed")) },
|
||||
}) as never,
|
||||
})
|
||||
const res = await forkSession(c, "session-1", "/tmp/wt")
|
||||
expect(res.ok).toBe(false)
|
||||
if (!res.ok) expect(res.error).toContain("fork failed")
|
||||
})
|
||||
|
||||
it("returns forked session on success", async () => {
|
||||
const forked = session("forked-1")
|
||||
const c = ctx({
|
||||
getClient: () =>
|
||||
({
|
||||
session: { fork: () => Promise.resolve({ data: forked }) },
|
||||
}) as never,
|
||||
})
|
||||
const res = await forkSession(c, "session-1", "/tmp/wt")
|
||||
expect(res.ok).toBe(true)
|
||||
if (res.ok) expect(res.value.id).toBe("forked-1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("registerSession", () => {
|
||||
it("calls all registration hooks", () => {
|
||||
const calls: string[] = []
|
||||
const state = { addSession: () => calls.push("addSession") } as never
|
||||
const c = ctx({
|
||||
getStateManager: () => state,
|
||||
registerWorktreeSession: () => calls.push("registerWorktreeSession"),
|
||||
registerSession: () => calls.push("registerSession"),
|
||||
notifyReady: () => calls.push("notifyReady"),
|
||||
capture: () => calls.push("capture"),
|
||||
})
|
||||
registerSession(c, session("s1"), result("/tmp/wt"), "wt1", "src-session")
|
||||
expect(calls).toEqual(["addSession", "registerWorktreeSession", "registerSession", "notifyReady", "capture"])
|
||||
})
|
||||
|
||||
it("works without state manager", () => {
|
||||
const calls: string[] = []
|
||||
const c = ctx({
|
||||
getStateManager: () => undefined,
|
||||
registerWorktreeSession: () => calls.push("registerWorktreeSession"),
|
||||
registerSession: () => calls.push("registerSession"),
|
||||
notifyReady: () => calls.push("notifyReady"),
|
||||
capture: () => calls.push("capture"),
|
||||
})
|
||||
registerSession(c, session("s1"), result("/tmp/wt"), "wt1", "src-session")
|
||||
expect(calls).toEqual(["registerWorktreeSession", "registerSession", "notifyReady", "capture"])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as os from "os"
|
||||
import * as cp from "child_process"
|
||||
import { capture, apply } from "../../src/agent-manager/git-transfer"
|
||||
|
||||
function git(args: string[], cwd: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
cp.execFile("git", args, { cwd, encoding: "utf8" }, (err, stdout) => {
|
||||
if (err) reject(err)
|
||||
else resolve(stdout.trim())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
describe("git-transfer", () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "git-transfer-test-"))
|
||||
await git(["init", "-b", "main"], dir)
|
||||
await git(["config", "user.email", "test@test.com"], dir)
|
||||
await git(["config", "user.name", "Test"], dir)
|
||||
// Initial commit so HEAD exists
|
||||
await fs.writeFile(path.join(dir, "init.txt"), "init\n")
|
||||
await git(["add", "."], dir)
|
||||
await git(["commit", "-m", "initial"], dir)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("capture", () => {
|
||||
it("captures branch and head", async () => {
|
||||
const snapshot = await capture(dir, noop)
|
||||
expect(snapshot.branch).toBe("main")
|
||||
expect(snapshot.head).toMatch(/^[0-9a-f]{40}$/)
|
||||
})
|
||||
|
||||
it("captures unstaged changes", async () => {
|
||||
await fs.writeFile(path.join(dir, "init.txt"), "modified\n")
|
||||
const snapshot = await capture(dir, noop)
|
||||
expect(snapshot.unstaged).toContain("modified")
|
||||
expect(snapshot.staged).toBeNull()
|
||||
})
|
||||
|
||||
it("captures staged changes", async () => {
|
||||
await fs.writeFile(path.join(dir, "init.txt"), "staged\n")
|
||||
await git(["add", "init.txt"], dir)
|
||||
const snapshot = await capture(dir, noop)
|
||||
expect(snapshot.staged).toContain("staged")
|
||||
expect(snapshot.unstaged).toBeNull()
|
||||
})
|
||||
|
||||
it("captures both staged and unstaged", async () => {
|
||||
await fs.writeFile(path.join(dir, "init.txt"), "staged\n")
|
||||
await git(["add", "init.txt"], dir)
|
||||
await fs.writeFile(path.join(dir, "init.txt"), "unstaged on top\n")
|
||||
const snapshot = await capture(dir, noop)
|
||||
expect(snapshot.staged).toContain("staged")
|
||||
expect(snapshot.unstaged).toContain("unstaged on top")
|
||||
})
|
||||
|
||||
it("captures untracked files", async () => {
|
||||
await fs.writeFile(path.join(dir, "new.txt"), "brand new\n")
|
||||
const snapshot = await capture(dir, noop)
|
||||
expect(snapshot.untracked).toHaveLength(1)
|
||||
expect(snapshot.untracked[0].path).toBe("new.txt")
|
||||
expect(snapshot.untracked[0].content.toString()).toBe("brand new\n")
|
||||
})
|
||||
|
||||
it("captures untracked files in subdirectories", async () => {
|
||||
await fs.mkdir(path.join(dir, "sub"), { recursive: true })
|
||||
await fs.writeFile(path.join(dir, "sub", "deep.txt"), "deep\n")
|
||||
const snapshot = await capture(dir, noop)
|
||||
expect(snapshot.untracked).toHaveLength(1)
|
||||
expect(snapshot.untracked[0].path).toBe("sub/deep.txt")
|
||||
})
|
||||
|
||||
it("returns null patches when working tree is clean", async () => {
|
||||
const snapshot = await capture(dir, noop)
|
||||
expect(snapshot.unstaged).toBeNull()
|
||||
expect(snapshot.staged).toBeNull()
|
||||
expect(snapshot.untracked).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("apply", () => {
|
||||
let target: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a target as a git worktree from the same repo (same commit)
|
||||
target = path.join(os.tmpdir(), `git-transfer-target-${Date.now()}`)
|
||||
await git(["worktree", "add", "-b", "test-wt", target, "HEAD"], dir)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await git(["worktree", "remove", "--force", target], dir).catch(() => {})
|
||||
await fs.rm(target, { recursive: true, force: true }).catch(() => {})
|
||||
})
|
||||
|
||||
it("applies unstaged changes", async () => {
|
||||
await fs.writeFile(path.join(dir, "init.txt"), "modified\n")
|
||||
const snapshot = await capture(dir, noop)
|
||||
const result = await apply(snapshot, target, noop)
|
||||
expect(result.ok).toBe(true)
|
||||
const content = await fs.readFile(path.join(target, "init.txt"), "utf8")
|
||||
expect(content).toBe("modified\n")
|
||||
// Should show as modified in target
|
||||
const status = await git(["status", "--porcelain"], target)
|
||||
expect(status).toContain("M init.txt")
|
||||
})
|
||||
|
||||
it("applies staged changes and re-stages them", async () => {
|
||||
await fs.writeFile(path.join(dir, "init.txt"), "staged\n")
|
||||
await git(["add", "init.txt"], dir)
|
||||
const snapshot = await capture(dir, noop)
|
||||
const result = await apply(snapshot, target, noop)
|
||||
expect(result.ok).toBe(true)
|
||||
const content = await fs.readFile(path.join(target, "init.txt"), "utf8")
|
||||
expect(content).toBe("staged\n")
|
||||
// Should be staged in target
|
||||
const status = await git(["status", "--porcelain"], target)
|
||||
expect(status).toContain("M init.txt")
|
||||
})
|
||||
|
||||
it("writes untracked files", async () => {
|
||||
await fs.writeFile(path.join(dir, "new.txt"), "brand new\n")
|
||||
const snapshot = await capture(dir, noop)
|
||||
const result = await apply(snapshot, target, noop)
|
||||
expect(result.ok).toBe(true)
|
||||
const content = await fs.readFile(path.join(target, "new.txt"), "utf8")
|
||||
expect(content).toBe("brand new\n")
|
||||
})
|
||||
|
||||
it("creates subdirectories for untracked files", async () => {
|
||||
await fs.mkdir(path.join(dir, "a", "b"), { recursive: true })
|
||||
await fs.writeFile(path.join(dir, "a", "b", "c.txt"), "nested\n")
|
||||
const snapshot = await capture(dir, noop)
|
||||
const result = await apply(snapshot, target, noop)
|
||||
expect(result.ok).toBe(true)
|
||||
const content = await fs.readFile(path.join(target, "a", "b", "c.txt"), "utf8")
|
||||
expect(content).toBe("nested\n")
|
||||
})
|
||||
|
||||
it("returns error when patch cannot be applied", async () => {
|
||||
await fs.writeFile(path.join(dir, "init.txt"), "modified\n")
|
||||
const snapshot = await capture(dir, noop)
|
||||
// Make target diverge so the patch fails
|
||||
await fs.writeFile(path.join(target, "init.txt"), "conflicting\n")
|
||||
await git(["add", "init.txt"], target)
|
||||
await git(["commit", "-m", "diverge"], target)
|
||||
const result = await apply(snapshot, target, noop)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toBeDefined()
|
||||
})
|
||||
|
||||
it("applies empty snapshot without error", async () => {
|
||||
const snapshot = await capture(dir, noop)
|
||||
const result = await apply(snapshot, target, noop)
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("round-trip", () => {
|
||||
let target: string
|
||||
|
||||
beforeEach(async () => {
|
||||
target = path.join(os.tmpdir(), `git-transfer-rt-${Date.now()}`)
|
||||
await git(["worktree", "add", "-b", `rt-${Date.now()}`, target, "HEAD"], dir)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await git(["worktree", "remove", "--force", target], dir).catch(() => {})
|
||||
await fs.rm(target, { recursive: true, force: true }).catch(() => {})
|
||||
})
|
||||
|
||||
it("preserves staged + unstaged + untracked in one round-trip", async () => {
|
||||
// Stage a change
|
||||
await fs.writeFile(path.join(dir, "init.txt"), "staged version\n")
|
||||
await git(["add", "init.txt"], dir)
|
||||
// Make an unstaged change on top
|
||||
await fs.writeFile(path.join(dir, "init.txt"), "unstaged version\n")
|
||||
// Add an untracked file
|
||||
await fs.writeFile(path.join(dir, "extra.txt"), "extra\n")
|
||||
|
||||
const snapshot = await capture(dir, noop)
|
||||
const result = await apply(snapshot, target, noop)
|
||||
expect(result.ok).toBe(true)
|
||||
|
||||
// Unstaged content should be the working tree version
|
||||
const content = await fs.readFile(path.join(target, "init.txt"), "utf8")
|
||||
expect(content).toBe("unstaged version\n")
|
||||
|
||||
// Untracked file should exist
|
||||
const extra = await fs.readFile(path.join(target, "extra.txt"), "utf8")
|
||||
expect(extra).toBe("extra\n")
|
||||
})
|
||||
|
||||
it("does not modify the source directory", async () => {
|
||||
await fs.writeFile(path.join(dir, "init.txt"), "changed\n")
|
||||
await fs.writeFile(path.join(dir, "new.txt"), "new\n")
|
||||
const before = await git(["status", "--porcelain"], dir)
|
||||
|
||||
await capture(dir, noop)
|
||||
|
||||
const after = await git(["status", "--porcelain"], dir)
|
||||
expect(after).toBe(before)
|
||||
})
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c5ada07e94ff10490732ba4b27d8e204102816aa00e472324a31ee9f8c7e8484
|
||||
size 9470
|
||||
oid sha256:6442d82e137e36d165e5f10ca7dbf02445badf109d442eac05714c50593068fe
|
||||
size 7819
|
||||
|
||||
@@ -2758,6 +2758,7 @@ const AgentManagerContent: Component = () => {
|
||||
openLocally(id)
|
||||
}}
|
||||
readonly={readOnly()}
|
||||
continueInWorktree={selection() === LOCAL}
|
||||
/>
|
||||
<Show when={readOnly()}>
|
||||
<div class="am-readonly-banner">
|
||||
|
||||
@@ -229,9 +229,9 @@ const AppContent: Component = () => {
|
||||
|
||||
return (
|
||||
<div class="container">
|
||||
<Switch fallback={<ChatView />}>
|
||||
<Switch fallback={<ChatView continueInWorktree />}>
|
||||
<Match when={currentView() === "newTask"}>
|
||||
<ChatView onSelectSession={handleSelectSession} />
|
||||
<ChatView onSelectSession={handleSelectSession} continueInWorktree />
|
||||
</Match>
|
||||
<Match when={currentView() === "marketplace"}>
|
||||
<MarketplaceView />
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
* Main chat container that combines all chat components
|
||||
*/
|
||||
|
||||
import { Component, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
|
||||
import { Component, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { showToast } from "@kilocode/kilo-ui/toast"
|
||||
import { TaskHeader } from "./TaskHeader"
|
||||
import { MessageList } from "./MessageList"
|
||||
import { PromptInput } from "./PromptInput"
|
||||
@@ -21,6 +24,8 @@ import { useServer } from "../../context/server"
|
||||
interface ChatViewProps {
|
||||
onSelectSession?: (id: string) => void
|
||||
readonly?: boolean
|
||||
/** When true, show the "Continue in Worktree" button. Defaults to true in the sidebar. */
|
||||
continueInWorktree?: boolean
|
||||
}
|
||||
|
||||
export const ChatView: Component<ChatViewProps> = (props) => {
|
||||
@@ -31,11 +36,17 @@ export const ChatView: Component<ChatViewProps> = (props) => {
|
||||
const server = useServer()
|
||||
// Show "Show Changes" only in the standalone sidebar, not inside Agent Manager
|
||||
const isSidebar = () => worktreeMode === undefined
|
||||
// Show "Continue in Worktree": only when explicitly enabled via prop
|
||||
const canContinueInWorktree = () => props.continueInWorktree === true
|
||||
|
||||
const id = () => session.currentSessionID()
|
||||
const hasMessages = () => session.messages().length > 0
|
||||
const idle = () => session.status() !== "busy"
|
||||
|
||||
// "Continue in Worktree" state
|
||||
const [transferring, setTransferring] = createSignal(false)
|
||||
const [transferDetail, setTransferDetail] = createSignal("")
|
||||
|
||||
// Permissions and questions scoped to this session's family (self + subagents).
|
||||
// Each ChatView only sees its own session tree — no cross-session leakage.
|
||||
// Memoized so the BFS walk in sessionFamily() runs once per reactive update,
|
||||
@@ -76,6 +87,34 @@ export const ChatView: Component<ChatViewProps> = (props) => {
|
||||
onCleanup(() => document.removeEventListener("keydown", handler))
|
||||
})
|
||||
|
||||
// Listen for "Continue in Worktree" progress messages
|
||||
{
|
||||
const labels: Record<string, string> = {
|
||||
capturing: "Capturing changes...",
|
||||
creating: "Creating worktree...",
|
||||
setup: "Running setup...",
|
||||
transferring: "Transferring changes...",
|
||||
forking: "Starting session...",
|
||||
}
|
||||
const cleanup = vscode.onMessage((msg) => {
|
||||
if (msg.type !== "continueInWorktreeProgress") return
|
||||
const m = msg as { status: string; error?: string }
|
||||
if (m.status === "done") {
|
||||
setTransferring(false)
|
||||
setTransferDetail("")
|
||||
return
|
||||
}
|
||||
if (m.status === "error") {
|
||||
setTransferring(false)
|
||||
setTransferDetail("")
|
||||
showToast({ title: m.error ?? "Failed to continue in worktree" })
|
||||
return
|
||||
}
|
||||
setTransferDetail(labels[status] ?? "Working...")
|
||||
})
|
||||
onCleanup(cleanup)
|
||||
}
|
||||
|
||||
const decide = (response: "once" | "always" | "reject", approvedAlways: string[], deniedAlways: string[]) => {
|
||||
const perm = permissionRequest()
|
||||
if (!perm || session.respondingPermissions().has(perm.id)) return
|
||||
@@ -110,27 +149,54 @@ export const ChatView: Component<ChatViewProps> = (props) => {
|
||||
</Show>
|
||||
<Show when={!props.readonly && hasMessages() && idle() && !blocked()}>
|
||||
<div class="new-task-button-wrapper">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
data-full-width="true"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("newTaskRequest"))}
|
||||
aria-label={language.t("command.session.new.task")}
|
||||
>
|
||||
{language.t("command.session.new.task")}
|
||||
</Button>
|
||||
<Show when={isSidebar()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
data-full-width="true"
|
||||
onClick={() => vscode.postMessage({ type: "openChanges" })}
|
||||
aria-label={language.t("command.session.show.changes")}
|
||||
>
|
||||
<Icon name="file-tree" size="small" />
|
||||
{language.t("command.session.show.changes")}
|
||||
</Button>
|
||||
</Show>
|
||||
<div class="session-actions-row">
|
||||
<Tooltip value="Start a new conversation" placement="top">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("newTaskRequest"))}
|
||||
aria-label={language.t("command.session.new.task")}
|
||||
>
|
||||
{language.t("command.session.new.task")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Show when={canContinueInWorktree()}>
|
||||
<Tooltip value="Continue in isolated worktree" placement="top">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={transferring()}
|
||||
onClick={() => {
|
||||
const sid = id()
|
||||
if (!sid) return
|
||||
setTransferring(true)
|
||||
setTransferDetail("Capturing changes...")
|
||||
vscode.postMessage({ type: "continueInWorktree", sessionId: sid })
|
||||
}}
|
||||
aria-label="Continue in Worktree"
|
||||
>
|
||||
<Show when={transferring()} fallback={<Icon name="branch" size="small" />}>
|
||||
<Spinner class="chat-spinner-small" />
|
||||
</Show>
|
||||
{transferring() ? transferDetail() : "Worktree"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={isSidebar() && session.summary()?.files}>
|
||||
<Tooltip value="View file changes" placement="top" class="session-diff-wrapper">
|
||||
<button
|
||||
class="session-diff-badge"
|
||||
onClick={() => vscode.postMessage({ type: "openChanges" })}
|
||||
aria-label={language.t("command.session.show.changes")}
|
||||
>
|
||||
<Icon name="layers" size="small" />
|
||||
<span class="session-diff-files">{session.summary()!.files}f</span>
|
||||
<span class="session-diff-add">+{session.summary()!.additions}</span>
|
||||
<span class="session-diff-del">-{session.summary()!.deletions}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.readonly}>
|
||||
|
||||
@@ -176,6 +176,71 @@
|
||||
padding: 8px 12px 0;
|
||||
}
|
||||
|
||||
.session-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.session-actions-row > * {
|
||||
flex: 1 1 0;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
/* Tooltip wrapper divs must fill their flex space, and buttons inside must fill the wrapper */
|
||||
.session-actions-row > [data-component="tooltip-trigger"] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.session-actions-row > [data-component="tooltip-trigger"] > [data-component="button"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Diff stats tooltip wrapper — don't grow, push right */
|
||||
.session-diff-wrapper {
|
||||
flex: 0 0 auto !important;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Diff stats badge — matches Agent Manager diff toggle style */
|
||||
.session-diff-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-family: var(--vscode-editor-font-family), monospace;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-diff-badge:hover {
|
||||
background: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
|
||||
.session-diff-badge [data-component="icon"] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.session-diff-files {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.session-diff-add {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.session-diff-del {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Message List
|
||||
============================================ */
|
||||
|
||||
@@ -1289,6 +1289,7 @@ export type ExtensionMessage =
|
||||
| ProviderActionErrorMessage
|
||||
| RecentsLoadedMessage
|
||||
| LanguageChangedMessage
|
||||
| ContinueInWorktreeProgressMessage
|
||||
|
||||
// ============================================
|
||||
// Messages FROM webview TO extension
|
||||
@@ -1900,6 +1901,29 @@ export interface RequestRecentsMessage {
|
||||
type: "requestRecents"
|
||||
}
|
||||
|
||||
// Continue in Worktree: transfer sidebar session + git state to an isolated worktree
|
||||
export interface ContinueInWorktreeRequest {
|
||||
type: "continueInWorktree"
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export type ContinueInWorktreeStatus =
|
||||
| "capturing"
|
||||
| "creating"
|
||||
| "setup"
|
||||
| "transferring"
|
||||
| "forking"
|
||||
| "done"
|
||||
| "error"
|
||||
|
||||
// Continue in Worktree: progress updates (extension → webview)
|
||||
export interface ContinueInWorktreeProgressMessage {
|
||||
type: "continueInWorktreeProgress"
|
||||
status: ContinueInWorktreeStatus
|
||||
detail?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type WebviewMessage =
|
||||
| SendMessageRequest
|
||||
| AbortRequest
|
||||
@@ -2012,6 +2036,7 @@ export type WebviewMessage =
|
||||
| SaveCustomProviderMessage
|
||||
| PersistRecentsRequest
|
||||
| RequestRecentsMessage
|
||||
| ContinueInWorktreeRequest
|
||||
|
||||
// ============================================
|
||||
// VS Code API type
|
||||
|
||||
Reference in New Issue
Block a user