Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/review-agent-manager-worktrees.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@kilocode/cli": minor
"kilo-code": minor
---

Review all committed and uncommitted Agent Manager worktree changes with `/review worktree`.
13 changes: 11 additions & 2 deletions packages/kilo-vscode/src/agent-manager/WorktreeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,7 @@ export class WorktreeManager {
throw new Error("This PR's branch is already checked out in another worktree")
}

const base = await this.resolvePRBase(info)
await this.fetchPRBranch(info, parsed, isFork, forkOwner)

if (isFork && forkOwner) {
Expand All @@ -1083,7 +1084,15 @@ export class WorktreeManager {
await this.git.raw(["branch", branch, `${forkOwner}/${info.headRefName}`])
}

return this.createWorktreeImpl({ existingBranch: branch })
const result = await this.createWorktreeImpl({ existingBranch: branch })
return { ...result, parentBranch: base.branch, remote: base.remote }
}

private async resolvePRBase(info: PRInfo): Promise<{ branch: string; remote?: string }> {
if (info.baseRefName === undefined) return this.resolveBaseBranch()
validateGitRef(info.baseRefName, "base branch")
const point = await this.resolveStartPoint(info.baseRefName, undefined, { allowFallback: false })
return { branch: point.branch, remote: point.remote }
}

private async fetchPRInfo(parsed: { owner: string; repo: string; number: number }): Promise<PRInfo> {
Expand All @@ -1096,7 +1105,7 @@ export class WorktreeManager {
"--repo",
`${parsed.owner}/${parsed.repo}`,
"--json",
"headRefName,headRepositoryOwner,isCrossRepository,title",
"headRefName,baseRefName,headRepositoryOwner,isCrossRepository,title",
],
30000,
)
Expand Down
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/agent-manager/git-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface PRUrlParts {

export interface PRInfo {
headRefName: string
baseRefName?: string
headRepositoryOwner?: { login: string }
isCrossRepository: boolean
title: string
Expand Down
21 changes: 21 additions & 0 deletions packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { clearIfOn } from "../../webview-ui/src/context/session-cloud-prune"
const ROOT = path.resolve(import.meta.dir, "../..")
const SESSION_FILE = path.join(ROOT, "webview-ui/src/context/session.tsx")
const CHATVIEW_FILE = path.join(ROOT, "webview-ui/src/components/chat/ChatView.tsx")
const AGENT_MANAGER_FILE = path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx")
const PROMPT_UTILS_FILE = path.join(ROOT, "webview-ui/src/components/chat/prompt-input-utils.ts")
const PROMPT_FILE = path.join(ROOT, "webview-ui/src/components/chat/PromptInput.tsx")
const KILOPROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts")
Expand Down Expand Up @@ -148,6 +149,26 @@ describe("ChatView prompt-block contract", () => {
})
})

describe("review worktree visibility contract", () => {
it("passes the worktree prop from ChatView to PromptInput", () => {
const source = readFile(CHATVIEW_FILE)
expect(source).toMatch(/worktree\?: boolean/)
expect(source).toMatch(/<PromptInput[\s\S]*worktree=\{props\.worktree\}/)
})

it("hides review worktree unless PromptInput is explicitly in a worktree", () => {
const source = readFile(PROMPT_FILE)
expect(source).toMatch(/worktree\?: boolean/)
expect(source).toMatch(/if \(props\.worktree !== true\) hidden\.add\("review worktree"\)/)
})

it("uses registered worktree membership for Agent Manager visibility", () => {
const source = readFile(AGENT_MANAGER_FILE)
expect(source).toMatch(/worktree=\{worktrees\(\)\.some\(\(wt\) => wt\.id === selection\(\)\)\}/)
expect(source).not.toMatch(/worktree=\{selection\(\(\)\) !== LOCAL\}/)
})
})

describe("isPromptBlocked signature contract", () => {
const source = readFile(PROMPT_UTILS_FILE)

Expand Down
29 changes: 28 additions & 1 deletion packages/kilo-vscode/tests/unit/use-slash-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"
import { createRoot } from "solid-js"
import { createRoot, createSignal } from "solid-js"
import { useSlashCommand } from "../../webview-ui/src/hooks/useSlashCommand"
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"

Expand Down Expand Up @@ -211,13 +211,15 @@ describe("useSlashCommand sandbox action", () => {
expect(ctx.slash.results()).toContainEqual(
expect.objectContaining({ name: "review", description: expect.stringContaining("Review code changes") }),
)
expect(ctx.slash.results().find((command) => command.name === "review")?.description).not.toContain("worktree")
ctx.slash.select(ctx.slash.results().find((c) => c.name === "review")!, textarea, (text) => (state.text = text))
expect(state.text).toBe("/review ")
expect(ctx.slash.results().map((command) => command.name)).toEqual([
"review uncommitted",
"review staged",
"review unpushed",
"review branch",
"review worktree",
"review quick",
])
ctx.dispose()
Expand All @@ -242,6 +244,31 @@ describe("useSlashCommand sandbox action", () => {
ctx.dispose()
})

it("reactively re-includes worktree review without changing nested ordering", () => {
const [allowed, setAllowed] = createSignal(false)
const ctx = setup(() => {}, { exclude: () => (allowed() ? new Set() : new Set(["review worktree"])) })

ctx.slash.onInput("/review ", 8)
expect(ctx.slash.results().map((command) => command.name)).toEqual([
"review uncommitted",
"review staged",
"review unpushed",
"review branch",
"review quick",
])

setAllowed(true)
expect(ctx.slash.results().map((command) => command.name)).toEqual([
"review uncommitted",
"review staged",
"review unpushed",
"review branch",
"review worktree",
"review quick",
])
ctx.dispose()
})

it("preserves model, agent, and variant metadata on loaded server commands", () => {
const ctx = setup(() => {})

Expand Down
61 changes: 60 additions & 1 deletion packages/kilo-vscode/tests/unit/worktree-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1251,6 +1251,7 @@ describe("WorktreeManager.createWorktree advanced", () => {
}
internal.fetchPRInfo = async () => ({
headRefName: "topic",
baseRefName: "main",
isCrossRepository: false,
title: "Topic PR",
})
Expand All @@ -1260,7 +1261,8 @@ describe("WorktreeManager.createWorktree advanced", () => {
const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim()

expect(worktreeHead).toBe(remoteHead)
expect(result.parentBranch).toBe("topic")
expect(result.parentBranch).toBe("main")
expect(result.remote).toBe("origin")
})

it("does not track a deleted PR source branch when using the pull ref fallback", async () => {
Expand Down Expand Up @@ -1295,6 +1297,63 @@ describe("WorktreeManager.createWorktree advanced", () => {

expect(worktreeHead).toBe(head)
expect(upstream.trim()).toBe("")
expect(result.parentBranch).toBe("main")
expect(result.remote).toBe("origin")
})

it("preserves a non-default PR target branch for comparison", async () => {
const { clone } = await createTempRepoWithOrigin()
const git = simpleGit(clone)
await git.checkoutLocalBranch("develop")
await fs.writeFile(path.join(clone, "develop.txt"), "develop")
await git.add(".")
await git.commit("develop commit")
await git.push("origin", "develop")
await git.checkout("main")
await git.checkoutLocalBranch("topic")
await fs.writeFile(path.join(clone, "topic.txt"), "topic")
await git.add(".")
await git.commit("topic commit")
await git.push("origin", "topic")
await git.checkout("main")

const manager = createManager(clone)
const internal = manager as unknown as {
fetchPRInfo: (parsed: { owner: string; repo: string; number: number }) => Promise<PRInfo>
}
internal.fetchPRInfo = async () => ({
headRefName: "topic",
baseRefName: "develop",
isCrossRepository: false,
title: "Topic PR",
})

const result = await manager.createFromPR("https://github.com/org/repo/pull/1")
const target = (await git.revparse(["refs/remotes/origin/develop"])).trim()
const head = (await simpleGit(result.path).revparse(["HEAD"])).trim()

expect(result.parentBranch).toBe("develop")
expect(result.remote).toBe("origin")
expect(head).not.toBe(target)
})

it("fails before creating a worktree for an unavailable PR target", async () => {
const { clone } = await createTempRepoWithOrigin()
const manager = createManager(clone)
const internal = manager as unknown as {
fetchPRInfo: (parsed: { owner: string; repo: string; number: number }) => Promise<PRInfo>
}
internal.fetchPRInfo = async () => ({
headRefName: "topic",
baseRefName: "missing",
isCrossRepository: false,
title: "Topic PR",
})

await expect(manager.createFromPR("https://github.com/org/repo/pull/1")).rejects.toThrow(
'Could not resolve start point for branch "missing"',
)
expect(existsSync(path.join(clone, ".kilo", "worktrees"))).toBe(false)
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2563,6 +2563,7 @@ const AgentManagerContent: Component = () => {
onForkSession={readOnly() ? undefined : handleForkSession}
readonly={readOnly()}
continueInWorktree={selection() === LOCAL}
worktree={worktrees().some((wt) => wt.id === selection())}
promptBoxId={`agent-manager:${selection() ?? "unassigned"}`}
terminalContext={() => selection() ?? undefined}
deferFocusToQuestion={hasQuestionOption}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface ChatViewProps {
readonly?: boolean
/** When true, show the "Continue in Worktree" button. Defaults to true in the sidebar. */
continueInWorktree?: boolean
worktree?: boolean
promptBoxId?: string
terminalContext?: () => string | undefined
deferFocusToQuestion?: () => boolean
Expand Down Expand Up @@ -387,6 +388,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
blocked={blocked}
suggesting={suggesting}
questioning={questioning}
worktree={props.worktree}
boxId={props.promptBoxId}
terminalContext={props.terminalContext}
deferFocusToQuestion={props.deferFocusToQuestion}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ interface PromptInputProps {
questioning?: () => boolean
/** When true, defer prompt focus while switching to a pending question */
deferFocusToQuestion?: () => boolean
worktree?: boolean
boxId?: string
terminalContext?: () => string | undefined
pendingSessionID?: string
Expand Down Expand Up @@ -310,6 +311,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const hidden = new Set<string>()
if (session.variantList(sid()).length === 0) hidden.add("variant")
if (!sandboxVisible()) hidden.add("sandbox")
if (props.worktree !== true) hidden.add("review worktree")
return hidden
},
)
Expand Down
5 changes: 5 additions & 0 deletions packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ export function useSlashCommand(
{ name: "review staged", description: "Review staged changes only", hints: [] },
{ name: "review unpushed", description: "Review local commits ahead of upstream", hints: [] },
{ name: "review branch", description: "Review current branch against base branch", hints: [] },
{
name: "review worktree",
description: "Review committed and uncommitted worktree changes against its base",
hints: [],
},
{
name: "review quick",
description: "Fast single-pass review with minimal token usage",
Expand Down
Loading
Loading