Merge pull request #7541 from Kilo-Org/emphasized-meadowlark

feat(vscode): add "Continue in Worktree" button
This commit is contained in:
Marius
2026-03-25 20:45:01 +01:00
committed by GitHub
14 changed files with 931 additions and 26 deletions
+26
View File
@@ -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
+5
View File
@@ -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)
})
})
})
@@ -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">
+2 -2
View File
@@ -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