Merge branch 'main' into feat/worktree-for-cli

This commit is contained in:
bagatao@anaconda.com
2026-08-13 11:05:48 +01:00
committed by GitHub
32 changed files with 1092 additions and 133 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep the Changes chip and Git changes visible across tab switches in multi-repository workspaces.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add Agent Manager PR comment actions: resolve/unresolve review threads, jump to comments section, and scroll-to-top for PR diff view.
+15 -1
View File
@@ -64,7 +64,10 @@ jobs:
exit 0
fi
echo 'general=true' >> "$GITHUB_OUTPUT"
echo 'settings=[{"os":"linux","index":1,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":true},{"os":"linux","index":2,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":false},{"os":"macos","index":1,"total":1,"host":"macos-15","run":true,"packages":true},{"os":"windows","index":1,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":true},{"os":"windows","index":2,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":3,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":4,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false}]' >> "$GITHUB_OUTPUT"
# kilocode_change - Windows is 6 shards (was 4): CLI tests now run at KILO_TEST_CONCURRENCY=2
# instead of the default 4 to cut CPU contention on the 4-vCPU runner; more shards keep
# per-shard wall-clock within the job timeout despite the lower per-shard parallelism.
echo 'settings=[{"os":"linux","index":1,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":true},{"os":"linux","index":2,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":false},{"os":"macos","index":1,"total":1,"host":"macos-15","run":true,"packages":true},{"os":"windows","index":1,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":true},{"os":"windows","index":2,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":3,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":4,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":5,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":6,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false}]' >> "$GITHUB_OUTPUT"
# kilocode_change end
unit:
# kilocode_change start
@@ -172,6 +175,17 @@ jobs:
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" # kilocode_change - was Windows-only; the CLI now starts a watcher per instance, too heavy/racy for unit tests. Watcher tests opt back in.
KILO_TEST_PROFILE: ${{ matrix.settings.os == 'macos' && 'darwin' || '' }}
KILO_TEST_SHARD: ${{ format('{0}/{1}', matrix.settings.index, matrix.settings.total) }}
# kilocode_change - cap parallelism on the 4-vCPU Windows runner. At the default
# min(4, cpus)=4, four heavy real-server test files share 4 vCPUs (~1 each) and blow
# their per-test timeouts; 2 gives each process real CPU headroom. Windows grows to
# 6 shards to absorb the lower per-shard parallelism. Linux/macOS (not timeout
# offenders; macOS is a single unsharded job) keep the default.
KILO_TEST_CONCURRENCY: ${{ matrix.settings.os == 'windows' && '2' || '' }}
# kilocode_change - raise the per-file kill deadline on Windows only. Heavy real-server
# files run ~230-270s serially there (vs ~40s on macOS/Linux), leaving only ~30s under
# the 300s default; 600s gives healthy-but-slow files real margin without masking hangs
# elsewhere (Linux/macOS keep the 300s default).
KILO_TEST_FILE_TIMEOUT: ${{ matrix.settings.os == 'windows' && '600000' || '' }}
# kilocode_change end
# kilocode_change start
+80 -19
View File
@@ -72,6 +72,7 @@ import { interceptMessage } from "./kilo-provider/git-changes-request"
import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
import { clearCommandsCache, loadCommands } from "./kilo-provider/commands"
import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-page"
import { editPaths } from "./kilo-provider/session-edits"
import {
dismissNotification,
fetchAndSendNotifications as fetchNotifications,
@@ -385,6 +386,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private readonly anacondaDesktop = new AnacondaDesktopBridge()
private sessionStatusMap = new Map<string, SessionStatus["type"]>() // Latest status used for destructive config warnings.
private sessionDirectories = new Map<string, string>() // Per-session directory overrides, such as Agent Manager worktrees.
private sessionGitDirectories = new Map<string, string>() // Stable Git root resolved for each session.
private sessionGitRecoveries = new Set<string>() // Sessions whose older history was scanned for a Git root.
private readonly aborts = new SessionAbort()
private projectID: string | undefined // Current workspace project ID used to filter sessions.
private loadMessagesAbort: AbortController | null = null // Current load request cancellation.
@@ -911,6 +914,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return this.currentSession?.id ?? undefined
}
/** Return the Git root used by the Changes panel for a session. */
public getSessionGitDirectory(sessionId: string): string | undefined {
return this.sessionGitDirectories.get(sessionId)
}
/**
* Re-fetch and send the full session list to the webview.
* Called by AgentManagerProvider after worktree recovery completes.
@@ -1055,7 +1063,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
openAgentManager: () => vscode.commands.executeCommand("kilo-code.new.agentManagerOpen"),
openAdvancedWorktree: () => vscode.commands.executeCommand("kilo-code.new.agentManager.advancedWorktree"),
openChanges: (sessionId?: string, turnId?: string) =>
vscode.commands.executeCommand("kilo-code.new.showChanges", { sessionId, turnId }),
vscode.commands.executeCommand("kilo-code.new.showChanges", {
sessionId,
turnId,
directory: sessionId ? this.sessionGitDirectories.get(sessionId) : undefined,
}),
openProfile: () => vscode.commands.executeCommand("kilo-code.new.profileButtonClicked"),
currentSessionId: this.currentSession?.id,
createWorktree: async (baseBranch, branchName) => {
@@ -1903,7 +1915,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
/** Non-blocking: refresh session metadata + status for the webview after switching. */
private refreshSessionDetails(sessionID: string, dir: string, signal?: AbortSignal): void {
if (!this.client) return
void this.refreshGitStatus(dir)
void this.refreshGitStatus(this.sessionGitDirectories.get(sessionID) ?? dir, sessionID)
const revision = this.revisions.get(sessionID)
const refresh = (this.refreshes.get(sessionID) ?? 0) + 1
this.refreshes.set(sessionID, refresh)
@@ -2006,6 +2018,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
parts: this.slimParts(m.parts),
createdAt: new Date(m.info.time.created).toISOString(),
}))
if (mode === "replace" || mode === "reconcile") {
void this.recoverSessionGitStatus(
page.items.flatMap((message) => message.parts),
sessionID,
page.cursor,
)
}
for (const message of messages) {
this.connectionService.recordMessageSessionId(message.id, message.sessionID)
}
@@ -2054,6 +2073,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
if (dir) {
this.sessionDirectories.set(sessionID, dir)
}
const git = this.sessionGitDirectories.get(parentSessionID)
if (git) this.sessionGitDirectories.set(sessionID, git)
}
try {
@@ -2228,6 +2249,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.visibleTaskStreams.delete(sessionID)
this.syncedChildSessions.delete(sessionID)
this.sessionDirectories.delete(sessionID)
this.sessionGitDirectories.delete(sessionID)
this.sessionGitRecoveries.delete(sessionID)
this.aborts.delete(sessionID)
this.lastReconciledAt.delete(sessionID)
this.checkpoints.delete(sessionID)
@@ -4783,21 +4806,54 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
event: Extract<ProviderEvent, { type: "message.part.updated" }>,
sessionID?: string,
) {
const part = event.properties.part as {
type?: string
metadata?: Record<string, unknown>
state?: { status?: string; input?: Record<string, unknown>; metadata?: Record<string, unknown> }
}
if (part.type !== "tool" || part.state?.status !== "completed") return
const values = [part.metadata?.filepath, part.state?.metadata?.filepath, part.state?.input?.filePath]
const file = values.find((value): value is string => typeof value === "string" && value.length > 0)
if (!file) return
void this.refreshGitStatusFromParts([event.properties.part], sessionID)
}
private async refreshGitStatusFromParts(parts: unknown[], sessionID?: string, recover = false): Promise<boolean> {
const base = this.getWorkspaceDirectory(sessionID)
const value = file.split(",")[0].trim()
const pathName = path.isAbsolute(value) ? value : path.resolve(base, value)
const directory = path.dirname(pathName)
if (!this.isCurrentProjectGitDirectory(directory, sessionID)) return
void this.refreshGitStatus(directory)
const edits = editPaths(parts, base)
if (!recover && edits.length === 0) return false
const cached = sessionID ? this.sessionGitDirectories.get(sessionID) : undefined
if (cached) {
await this.refreshGitStatus(cached, sessionID)
return true
}
const root = await this.resolveGitRoot(base)
if (root) {
await this.refreshGitStatus(root, sessionID)
return true
}
const file = edits.find((item) => this.isCurrentProjectGitDirectory(item, sessionID))
if (!file) return false
await this.refreshGitStatus(path.dirname(file), sessionID)
return sessionID ? this.sessionGitDirectories.has(sessionID) : true
}
private async recoverSessionGitStatus(parts: unknown[], sessionID: string, cursor?: string): Promise<void> {
if (await this.refreshGitStatusFromParts(parts, sessionID, true)) return
if (!cursor || !this.client || !this.trackedSessionIds.has(sessionID)) return
if (this.sessionGitRecoveries.has(sessionID)) return
this.sessionGitRecoveries.add(sessionID)
const directory = this.getWorkspaceDirectory(sessionID)
const history = await retry(() =>
this.client!.session.messages({ sessionID, directory, limit: 0 }, { throwOnError: true }),
).catch((error: unknown) => {
console.warn("[Kilo New] KiloProvider: Failed to recover session Git directory:", error)
return undefined
})
if (!history) {
this.sessionGitRecoveries.delete(sessionID)
return
}
if (!this.trackedSessionIds.has(sessionID)) return
await this.refreshGitStatusFromParts(
history.data.flatMap((message) => message.parts),
sessionID,
)
}
private isCurrentProjectGitDirectory(directory: string, sessionID?: string): boolean {
@@ -4810,15 +4866,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
}
public async refreshGitStatus(directory = this.getWorkspaceDirectory()): Promise<void> {
public async refreshGitStatus(directory = this.getWorkspaceDirectory(), sessionID?: string): Promise<void> {
const client = this.client
if (!client) return
const revision = ++this.gitStatusRevision
const active = !sessionID || sessionID === this.contextSessionID
const revision = active ? ++this.gitStatusRevision : undefined
const repo = await hasGit(client, directory)
const root = await this.resolveGitRoot(directory)
if (revision !== this.gitStatusRevision) return
const found = repo || root !== undefined
const target = root ?? directory
if (found && sessionID && !this.sessionGitDirectories.has(sessionID)) {
this.sessionGitDirectories.set(sessionID, target)
}
if (sessionID && sessionID !== this.contextSessionID) return
if (revision === undefined || revision !== this.gitStatusRevision) return
const changed = !this.cachedGitDirectory || !sameDirectory(this.cachedGitDirectory, target)
if (changed) {
this.cachedStats = null
@@ -5,8 +5,8 @@ import { execWithShellEnv } from "./shell-env"
import { execGhRead } from "./gh"
import { classifyPRError } from "./git-import"
import type { Semaphore } from "./semaphore"
import { parsePRResult, checkStatus, formatCheckDuration, parseComments, parseReviewers } from "./am-pr-utils"
import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types"
import { parsePRResult, checkStatus, formatCheckDuration, parseComments, parseReviewers } from "./pr/am-pr-utils"
import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./pr/am-pr-types"
interface PRStatusPollerOptions {
getWorktrees: () => Worktree[]
@@ -485,7 +485,9 @@ export class PRStatusPoller {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
reviewThreads(first: 100) {
totalCount
nodes {
id
isResolved
comments(first: 1) {
nodes {
@@ -496,6 +498,7 @@ export class PRStatusPoller {
line
url
createdAt
diffHunk
}
}
}
@@ -520,8 +523,10 @@ export class PRStatusPoller {
{ cwd, timeout: 15_000 },
)
const pr = JSON.parse(stdout)?.data?.repository?.pullRequest
const comments = parseComments((pr?.reviewThreads?.nodes ?? []) as GhThread[])
return { total: comments.length, unresolved: comments.filter((c) => !c.resolved).length, comments }
const threads = pr?.reviewThreads
const comments = parseComments((threads?.nodes ?? []) as GhThread[])
const totalCount = threads?.totalCount ?? comments.length
return { total: totalCount, unresolved: comments.filter((c) => !c.resolved).length, comments }
} catch (err) {
this.options.log("Failed to fetch PR comments:", err)
return { total: 0, unresolved: 0, comments: [] }
@@ -9,6 +9,7 @@ import type { AgentManagerOutMessage, PRStatus } from "./types"
import type { Disposable } from "./host"
import type { Semaphore } from "./semaphore"
import { PRStatusPoller } from "./PRStatusPoller"
import { resolveComment, unresolveComment } from "./pr/PRActions"
interface PRBridgeHost {
getWorktrees(): Worktree[]
@@ -85,6 +86,49 @@ export class PRStatusBridge {
if (url) this.host.openExternal(url)
return true
}
const isResolve = m.type === "agentManager.resolveComment"
const isUnresolve = m.type === "agentManager.unresolveComment"
if (isResolve || isUnresolve) {
const id = m.worktreeId as string
const threadId = m.threadId as string
const wt = this.host.getWorktrees().find((w: Worktree) => w.id === id)
const cwd = wt?.path ?? this.host.getWorkspaceRoot()
const resultType = isResolve ? "agentManager.resolveCommentResult" : "agentManager.unresolveCommentResult"
if (!cwd) {
this.host.log("resolveComment: no cwd for worktree", id)
this.host.postToWebview({
type: resultType,
worktreeId: id,
threadId,
success: false,
})
return true
}
const action = isResolve ? resolveComment : unresolveComment
action(threadId, cwd).then(
() => {
this.host.postToWebview({
type: resultType,
worktreeId: id,
threadId,
success: true,
})
// Refresh PR data after successful mutation to get updated comment state
this.poller.refresh(id)
},
(err: unknown) => {
const msg = err instanceof Error ? err.message : String(err)
this.host.log(`${resultType} failed: ${msg}`)
this.host.postToWebview({
type: resultType,
worktreeId: id,
threadId,
success: false,
})
},
)
return true
}
return false
}
@@ -0,0 +1,30 @@
import { execGhRead } from "../gh"
import { GH_MUTATION_TIMEOUT } from "./pr-constants"
export async function resolveComment(threadId: string, cwd: string): Promise<void> {
const mutation = `mutation($id: ID!) { resolveReviewThread(input: { threadId: $id }) { thread { isResolved } } }`
try {
await execGhRead(["api", "graphql", "-f", `query=${mutation}`, "-F", `id=${threadId}`], {
cwd,
timeout: GH_MUTATION_TIMEOUT,
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const stderr = (err as Record<string, unknown>).stderr
throw new Error(`Could not resolve thread: ${msg}${stderr ? `${stderr}` : ""}`)
}
}
export async function unresolveComment(threadId: string, cwd: string): Promise<void> {
const mutation = `mutation($id: ID!) { unresolveReviewThread(input: { threadId: $id }) { thread { isResolved } } }`
try {
await execGhRead(["api", "graphql", "-f", `query=${mutation}`, "-F", `id=${threadId}`], {
cwd,
timeout: GH_MUTATION_TIMEOUT,
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const stderr = (err as Record<string, unknown>).stderr
throw new Error(`Could not unresolve thread: ${msg}${stderr ? `${stderr}` : ""}`)
}
}
@@ -1,4 +1,4 @@
import type { PRState, ReviewDecision } from "./types"
import type { PRState, ReviewDecision } from "../types"
// Raw shapes returned by `gh pr view --json`
@@ -14,8 +14,10 @@ export interface GhComment {
line?: number
url?: string
createdAt?: string
diffHunk?: string
}
export interface GhThread {
id?: string
isResolved?: boolean
comments?: { nodes?: GhComment[] }
}
@@ -1,4 +1,4 @@
import type { CheckStatus, PRComment, PRReviewer, ReviewerState } from "./types"
import type { CheckStatus, PRComment, PRReviewer, ReviewerState } from "../types"
import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types"
export function parsePRResult(json: string): PRResult | null {
@@ -73,6 +73,7 @@ export function parseComments(threads: GhThread[]): PRComment[] {
if (!first) continue
items.push({
id: first.id,
threadId: thread.id ?? first.id,
author: first.author?.login ?? "unknown",
avatar: first.author?.avatarUrl,
body: first.body ?? "",
@@ -81,6 +82,7 @@ export function parseComments(threads: GhThread[]): PRComment[] {
url: first.url,
resolved: thread.isResolved ?? false,
createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined,
diffHunk: first.diffHunk,
})
}
return items
@@ -0,0 +1,2 @@
// Timeouts for gh CLI and GraphQL calls in PR actions
export const GH_MUTATION_TIMEOUT = 15_000 // 15 seconds — gh api graphql mutations
@@ -61,6 +61,7 @@ export interface PRCheck {
export interface PRComment {
id: string
threadId: string
author: string
avatar?: string
body: string
@@ -69,6 +70,7 @@ export interface PRComment {
url?: string
resolved: boolean
createdAt?: number
diffHunk?: string
}
export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented"
@@ -394,6 +396,14 @@ interface PRErrorOutMessage {
error: "gh_missing" | "gh_auth" | "fetch_failed"
}
interface CommentActionResultMessage {
type: "agentManager.resolveCommentResult" | "agentManager.unresolveCommentResult"
worktreeId: string
threadId: string
success: boolean
error?: string
}
interface ActionOutMessage {
type: "action"
action: string
@@ -434,6 +444,7 @@ export type AgentManagerOutMessage =
| DiffBranchesMessage
| PRStatusOutMessage
| PRErrorOutMessage
| CommentActionResultMessage
| ActionOutMessage
| RunStatusMessage
| TerminalCreatedMessage
@@ -758,6 +769,12 @@ interface OpenPRIn {
url?: string
}
interface CommentActionIn {
type: "agentManager.resolveComment" | "agentManager.unresolveComment"
worktreeId: string
threadId: string
}
interface OpenSessionsIn {
type: "agentManager.openSessions"
sessionIDs: string[]
@@ -1050,6 +1067,7 @@ export type AgentManagerInMessage =
| SetDiffBaseBranchIn
| RefreshPRIn
| OpenPRIn
| CommentActionIn
| OpenSessionsIn
| VisibleSessionIn
| OpenFileIn
@@ -1,6 +1,6 @@
import * as vscode from "vscode"
import type { KiloConnectionService } from "../services/cli-backend"
import { appendOutput, getWorkspaceRoot, openWorkspaceRelativeFile } from "../review-utils"
import { appendOutput, getWorkspaceRoot, openRelativeFile } from "../review-utils"
import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings"
import { buildWebviewHtml, getWebviewFontSize } from "../utils"
import { watchFontSizeConfig } from "../kilo-provider/font-size"
@@ -13,6 +13,7 @@ type CommentHandler = (comments: unknown[], autoSend: boolean) => void
export interface DiffViewerProviderOptions {
sessionIdProvider?: () => string | undefined
sessionDirectoryProvider?: (sessionId: string) => string | undefined
}
/**
@@ -31,6 +32,7 @@ export class DiffViewerProvider implements vscode.Disposable {
private fontConfigDisposable: vscode.Disposable | undefined
private baseBranchOverride: string | undefined
private readonly sessionIdProvider: () => string | undefined
private readonly sessionDirectoryProvider: (sessionId: string) => string | undefined
private readonly output: vscode.OutputChannel
constructor(
@@ -40,6 +42,7 @@ export class DiffViewerProvider implements vscode.Disposable {
opts: DiffViewerProviderOptions = {},
) {
this.sessionIdProvider = opts.sessionIdProvider ?? (() => undefined)
this.sessionDirectoryProvider = opts.sessionDirectoryProvider ?? (() => undefined)
this.output = vscode.window.createOutputChannel("Kilo Diff Panel")
}
@@ -54,7 +57,11 @@ export class DiffViewerProvider implements vscode.Disposable {
this.panel.reveal(this.panel.viewColumn ?? vscode.ViewColumn.One)
this.controller.setContext(this.ctx)
const nextId = this.catalog.defaultSourceId(this.ctx)
if (nextId && nextId !== this.controller.currentId) this.swap(nextId)
if (nextId && nextId !== this.controller.currentId) {
this.swap(nextId)
return
}
void this.controller.reactivate()
return
}
@@ -70,12 +77,15 @@ export class DiffViewerProvider implements vscode.Disposable {
* the source picker hidden — the view becomes a static "diff of this turn"
* rather than the switchable workspace/session viewer.
*/
openFromCommand(arg?: { sessionId?: string; turnId?: string; initialSourceId?: string }): void {
openFromCommand(arg?: { sessionId?: string; turnId?: string; initialSourceId?: string; directory?: string }): void {
const sessionId = arg?.sessionId ?? this.sessionIdProvider()
const explicit = !!arg && "directory" in arg
const dir = explicit ? arg.directory : sessionId ? this.sessionDirectoryProvider(sessionId) : undefined
const turnInitialSourceId = arg?.turnId && sessionId ? turnSourceId(sessionId, arg.turnId) : undefined
this.openPanel({
workspaceRoot: getWorkspaceRoot(),
sessionId,
dir,
initialSourceId: turnInitialSourceId ?? arg?.initialSourceId,
hidePicker: !!turnInitialSourceId,
})
@@ -182,14 +192,18 @@ export class DiffViewerProvider implements vscode.Disposable {
},
openFile: (msg) => {
if (typeof msg.filePath !== "string") return
openWorkspaceRelativeFile(msg.filePath, typeof msg.line === "number" ? msg.line : undefined)
openRelativeFile(
this.ctx?.dir ?? this.ctx?.workspaceRoot,
msg.filePath,
typeof msg.line === "number" ? msg.line : undefined,
)
},
}
private async sendBranches(): Promise<void> {
if (!this.panel) return
try {
const result = await this.catalog.listWorkspaceBranches(this.baseBranchOverride)
const result = await this.catalog.listWorkspaceBranches(this.baseBranchOverride, this.ctx?.dir)
if (!result || !this.panel) return
void this.panel.webview.postMessage({
type: "diffViewer.branches",
@@ -212,7 +226,7 @@ export class DiffViewerProvider implements vscode.Disposable {
vscodeLanguage: vscode.env.language,
languageOverride: vscode.workspace.getConfiguration("kilo-code.new").get<string>("language"),
fontSize: getWebviewFontSize(),
workspaceDirectory: getWorkspaceRoot(),
workspaceDirectory: this.ctx?.dir ?? getWorkspaceRoot(),
})
void this.panel.webview.postMessage({ type: "diffViewer.markdownRender", render: getDiffMarkdownRender() })
const initial = this.ctx ? this.catalog.defaultSourceId(this.ctx) : undefined
+2 -1
View File
@@ -273,6 +273,7 @@ export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(diffSourceCatalog)
const diffViewerProvider = new DiffViewerProvider(context.extensionUri, connectionService, diffSourceCatalog, {
sessionIdProvider: () => provider.getCurrentSessionId(),
sessionDirectoryProvider: (sessionId) => provider.getSessionGitDirectory(sessionId),
})
diffViewerProvider.setCommentHandler((comments, autoSend) => {
void provider.appendReviewComments(comments, autoSend)
@@ -468,7 +469,7 @@ export function activate(context: vscode.ExtensionContext) {
}),
vscode.commands.registerCommand(
"kilo-code.new.showChanges",
(arg?: { sessionId?: string; turnId?: string; initialSourceId?: string }) => {
(arg?: { sessionId?: string; turnId?: string; initialSourceId?: string; directory?: string }) => {
diffViewerProvider.openFromCommand(arg)
},
),
@@ -0,0 +1,47 @@
import * as path from "path"
const tools = new Set(["apply_patch", "edit", "generate_image", "multiedit", "write"])
function record(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object") return
return value as Record<string, unknown>
}
function value(input: unknown): string | undefined {
return typeof input === "string" && input.length > 0 ? input : undefined
}
function files(part: Record<string, unknown>): string[] {
const state = record(part.state)
if (part.type !== "tool" || state?.status !== "completed") return []
if (typeof part.tool !== "string" || !tools.has(part.tool)) return []
const meta = record(state.metadata)
if (part.tool === "apply_patch" && Array.isArray(meta?.files)) {
return meta.files.flatMap((item) => {
const file = record(item)
return value(file?.movePath) ?? value(file?.filePath) ?? value(file?.relativePath) ?? []
})
}
if (part.tool === "multiedit" && Array.isArray(meta?.results)) {
return meta.results.flatMap((item) => {
const result = record(item)
const diff = record(result?.filediff)
return value(diff?.file) ?? []
})
}
const diff = record(meta?.filediff)
const input = record(state.input)
return [value(diff?.file) ?? value(meta?.filepath) ?? value(input?.filePath)].filter((file): file is string => !!file)
}
/** Absolute paths written by completed file-mutating tool parts. */
export function editPaths(parts: unknown[], base: string): string[] {
return parts.flatMap((part) => {
const item = record(part)
if (!item) return []
return files(item).map((file) => (path.isAbsolute(file) ? path.normalize(file) : path.resolve(base, file)))
})
}
+4 -5
View File
@@ -1,5 +1,5 @@
import * as path from "path"
import * as vscode from "vscode"
import { resolveInside } from "./diff/shared/path"
import { inspect } from "util"
export function appendOutput(channel: vscode.OutputChannel, prefix: string, ...args: unknown[]): void {
@@ -36,10 +36,9 @@ export function openFileInEditor(
.then(undefined, (err) => console.error(`[Kilo New] ${prefix}: Failed to open file:`, uri.fsPath, err))
}
export function openWorkspaceRelativeFile(relativePath: string, line?: number, column?: number): void {
const root = getWorkspaceRoot()
export function openRelativeFile(root: string | undefined, relativePath: string, line?: number, column?: number): void {
if (!root) return
const resolved = path.resolve(root, relativePath)
if (!resolved.startsWith(root + path.sep) && resolved !== root) return
const resolved = resolveInside(root, relativePath)
if (!resolved) return
openFileInEditor(resolved, line, column, vscode.ViewColumn.Beside, "DiffPanel")
}
@@ -1,4 +1,10 @@
import { describe, expect, it } from "bun:test"
import { describe, expect, it, mock, beforeEach } from "bun:test"
const resolveComment = mock(async (_threadId: string, _cwd: string) => {})
const unresolveComment = mock(async (_threadId: string, _cwd: string) => {})
mock.module("../../src/agent-manager/pr/PRActions", () => ({ resolveComment, unresolveComment }))
import { PRStatusBridge } from "../../src/agent-manager/pr-status-bridge"
import type { AgentManagerOutMessage, PRStatus } from "../../src/agent-manager/types"
@@ -9,6 +15,7 @@ const pr: PRStatus = {
state: "open",
review: null,
checks: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] },
reviewers: [],
additions: 0,
deletions: 0,
files: 0,
@@ -16,7 +23,7 @@ const pr: PRStatus = {
function harness(opts: { hasPersisted?: boolean } = {}) {
const sent: AgentManagerOutMessage[] = []
const worktrees: { id: string; prUrl?: string }[] = []
const worktrees: { id: string; path: string; prUrl?: string }[] = [{ id: "wt1", path: "/repo/wt1" }]
const bridge = PRStatusBridge.create({
getWorktrees: () => worktrees as never,
getWorkspaceRoot: () => "/repo",
@@ -186,3 +193,78 @@ describe("PRStatusBridge.reset", () => {
expect(sent).toHaveLength(1)
})
})
// --- resolveComment / unresolveComment message handling ---
describe("PRStatusBridge.handleMessage resolveComment", () => {
beforeEach(() => {
resolveComment.mockReset()
unresolveComment.mockReset()
})
it("returns true for agentManager.resolveComment", () => {
const { bridge } = harness()
resolveComment.mockResolvedValueOnce(undefined)
expect(bridge.handleMessage({ type: "agentManager.resolveComment", worktreeId: "wt1", threadId: "PRT_1" })).toBe(
true,
)
})
it("returns true for agentManager.unresolveComment", () => {
const { bridge } = harness()
unresolveComment.mockResolvedValueOnce(undefined)
expect(bridge.handleMessage({ type: "agentManager.unresolveComment", worktreeId: "wt1", threadId: "PRT_1" })).toBe(
true,
)
})
it("posts resolveCommentResult with success:true on resolve success", async () => {
const { bridge, sent } = harness()
resolveComment.mockResolvedValueOnce(undefined)
bridge.handleMessage({ type: "agentManager.resolveComment", worktreeId: "wt1", threadId: "PRT_1" })
await Promise.resolve()
const result = sent.find((m) => m.type === "agentManager.resolveCommentResult")
expect(result).toEqual(
expect.objectContaining({
type: "agentManager.resolveCommentResult",
worktreeId: "wt1",
threadId: "PRT_1",
success: true,
}),
)
})
it("posts unresolveCommentResult with success:true on unresolve success", async () => {
const { bridge, sent } = harness()
unresolveComment.mockResolvedValueOnce(undefined)
bridge.handleMessage({ type: "agentManager.unresolveComment", worktreeId: "wt1", threadId: "PRT_1" })
await Promise.resolve()
const result = sent.find((m) => m.type === "agentManager.unresolveCommentResult")
expect(result).toEqual(expect.objectContaining({ success: true }))
})
it("posts resolveCommentResult with success:false on failure", async () => {
const { bridge, sent } = harness()
resolveComment.mockRejectedValueOnce(new Error("gh: Not Found"))
bridge.handleMessage({ type: "agentManager.resolveComment", worktreeId: "wt1", threadId: "PRT_1" })
await Promise.resolve()
const result = sent.find((m) => m.type === "agentManager.resolveCommentResult")
expect(result).toEqual(expect.objectContaining({ success: false }))
})
it("logs and returns early when no cwd found", () => {
const logged: unknown[] = []
const bridge = PRStatusBridge.create({
getWorktrees: () => [] as never,
getWorkspaceRoot: () => undefined,
postToWebview: () => {},
updateWorktreePR: () => {},
hasPersistedPR: () => false,
openExternal: () => {},
log: (...args) => logged.push(args),
})
bridge.handleMessage({ type: "agentManager.resolveComment", worktreeId: "wt-missing", threadId: "PRT_1" })
expect(resolveComment).not.toHaveBeenCalled()
expect(logged.length).toBeGreaterThan(0)
})
})
@@ -5,8 +5,8 @@ import {
formatCheckDuration,
parseComments,
parseReviewers,
} from "../../src/agent-manager/am-pr-utils"
import type { GhThread, GhReviewRequest, GhReview } from "../../src/agent-manager/am-pr-types"
} from "../../src/agent-manager/pr/am-pr-utils"
import type { GhThread, GhReviewRequest, GhReview } from "../../src/agent-manager/pr/am-pr-types"
// --- parsePRResult ---
@@ -220,6 +220,7 @@ describe("parseComments", () => {
it("parses a resolved thread", () => {
const threads: GhThread[] = [
{
id: "PRT_thread1",
isResolved: true,
comments: {
nodes: [
@@ -239,6 +240,7 @@ describe("parseComments", () => {
expect(parseComments(threads)).toEqual([
{
id: "c1",
threadId: "PRT_thread1",
author: "alice",
avatar: "https://avatar",
body: "looks good",
@@ -247,10 +249,30 @@ describe("parseComments", () => {
url: "https://url",
resolved: true,
createdAt: new Date("2024-01-01T00:00:00Z").getTime(),
diffHunk: undefined,
},
])
})
it("uses comment id as threadId fallback when thread has no id", () => {
const threads: GhThread[] = [{ isResolved: false, comments: { nodes: [{ id: "c2", body: "note" }] } }]
const result = parseComments(threads)
expect(result[0]?.threadId).toBe("c2")
})
it("parses diffHunk when present", () => {
const threads: GhThread[] = [
{
id: "PRT_t1",
isResolved: false,
comments: {
nodes: [{ id: "c3", body: "fix this", diffHunk: "@@ -1,3 +1,4 @@\n context\n+new line" }],
},
},
]
expect(parseComments(threads)[0]?.diffHunk).toBe("@@ -1,3 +1,4 @@\n context\n+new line")
})
it("defaults missing author to 'unknown'", () => {
const threads: GhThread[] = [{ isResolved: false, comments: { nodes: [{ id: "c2", body: "note" }] } }]
expect(parseComments(threads)[0]?.author).toBe("unknown")
@@ -259,6 +281,7 @@ describe("parseComments", () => {
it("only uses the first comment of each thread", () => {
const threads: GhThread[] = [
{
id: "PRT_t2",
isResolved: false,
comments: {
nodes: [
@@ -0,0 +1,22 @@
import { describe, expect, it } from "bun:test"
import * as vscode from "vscode"
import { DiffViewerProvider } from "../../src/diff/DiffViewerProvider"
import type { PanelContext } from "../../src/diff/types"
describe("DiffViewerProvider.openFromCommand", () => {
it("uses the invoking provider directory even when it is explicitly unavailable", () => {
const provider = new DiffViewerProvider({} as vscode.Uri, {} as never, {} as never, {
sessionIdProvider: () => "sidebar",
sessionDirectoryProvider: () => "/sidebar/repo",
})
const contexts: PanelContext[] = []
provider.openPanel = (ctx) => contexts.push(ctx)
provider.openFromCommand({ sessionId: "agent-manager", directory: "/agent/repo" })
provider.openFromCommand({ sessionId: "editor-tab", directory: undefined })
provider.openFromCommand()
expect(contexts.map((ctx) => ctx.dir)).toEqual(["/agent/repo", undefined, "/sidebar/repo"])
provider.dispose()
})
})
@@ -14,6 +14,7 @@ type Internals = {
handleLoadMessages: (sessionID: string) => Promise<void>
handleEvent: (event: Event, directory?: string) => void
refreshGitStatus: (directory?: string) => Promise<void>
refreshGitStatusFromParts: (parts: unknown[], sessionID?: string) => Promise<boolean>
initializeConnection: () => Promise<void>
syncWebviewState: () => Promise<void>
flushPendingSessionRefresh: () => Promise<void>
@@ -159,7 +160,7 @@ describe("KiloProvider follow-up sessions", () => {
})
})
it("refreshes Git from the file path in a completed edit tool part", () => {
it("refreshes Git from the file path in a completed edit tool part", async () => {
const service = connection()
const provider = new KiloProvider({} as never, service as never, undefined, {
rootDirectory: () => "/workspace",
@@ -167,11 +168,13 @@ describe("KiloProvider follow-up sessions", () => {
})
const internal = provider as unknown as Internals
const dirs: string[] = []
const refreshed = Promise.withResolvers<void>()
const sessionID = "ses-edit"
internal.currentSession = info({ id: sessionID, projectID: "backend-workspace", directory: "/workspace" })
internal.trackedSessionIds.add(sessionID)
internal.refreshGitStatus = async (directory) => {
if (directory) dirs.push(directory)
refreshed.resolve()
}
internal.handleEvent(
@@ -181,18 +184,22 @@ describe("KiloProvider follow-up sessions", () => {
sessionID,
part: {
type: "tool",
state: { status: "completed" },
metadata: { filepath: "/workspace/frontend/src/app.ts" },
tool: "edit",
state: {
status: "completed",
metadata: { filediff: { file: "/workspace/frontend/src/app.ts" } },
},
},
},
} as Event,
"/workspace",
)
await refreshed.promise
expect(dirs).toEqual(["/workspace/frontend/src"])
})
it("ignores completed tool paths outside the active project", () => {
it("ignores completed tool paths outside the active project", async () => {
const service = connection()
const provider = new KiloProvider({} as never, service as never, undefined, {
rootDirectory: () => "/workspace",
@@ -207,21 +214,21 @@ describe("KiloProvider follow-up sessions", () => {
if (directory) dirs.push(directory)
}
internal.handleEvent(
{
type: "message.part.updated",
properties: {
sessionID,
part: {
type: "tool",
state: { status: "completed" },
metadata: { filepath: "/other-repo/src/app.ts" },
const found = await internal.refreshGitStatusFromParts(
[
{
type: "tool",
tool: "edit",
state: {
status: "completed",
metadata: { filediff: { file: "/other-repo/src/app.ts" } },
},
},
} as Event,
"/workspace",
],
sessionID,
)
expect(found).toBe(false)
expect(dirs).toEqual([])
})
@@ -242,6 +242,7 @@ type ProviderInternals = {
fetchAndSendSandboxDefault: (directory?: string, requestID?: string) => Promise<void>
handleSetSandboxDefault: (enabled: boolean, requestID: string, directory?: string) => Promise<void>
handleToggleSandbox: (input: { sessionID: string; requestID: string }) => Promise<void>
refreshGitStatus: (directory?: string, sessionID?: string) => Promise<void>
handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise<void>
handleDeleteSession: (sid: string) => Promise<void>
}
@@ -811,6 +812,39 @@ describe("KiloProvider revert ordering", () => {
})
describe("KiloProvider.handleLoadMessages / focus mode freshness", () => {
it("recovers the session Git directory from loaded tool history", async () => {
const client = createClient({
messagesData: [
{
...mkMessage("m1", "assistant", 1),
parts: [
{
type: "tool",
tool: "edit",
state: {
status: "completed",
input: { filePath: "/repo/frontend/src/app.ts" },
metadata: { filediff: { file: "/repo/frontend/src/app.ts" } },
},
},
],
},
],
})
const { internal } = makeProvider(client)
const calls: Array<{ directory?: string; sessionID?: string }> = []
const recovered = defer<void>()
internal.refreshGitStatus = async (directory, sessionID) => {
calls.push({ directory, sessionID })
if (directory === "/repo/frontend/src") recovered.resolve()
}
await internal.handleLoadMessages("s1")
await recovered.promise
expect(calls).toContainEqual({ directory: "/repo/frontend/src", sessionID: "s1" })
})
it("stops background processes for the previous session when switching sessions", async () => {
const client = createClient({
sessionData: { id: "s2", directory: "/repo/worktree", time: { created: 1, updated: 1 } },
@@ -35,6 +35,7 @@ function mockConnection(getImpl?: (p: SessionGetParams) => Promise<unknown>, vcs
}
},
list: async () => ({ data: [] }),
status: async () => ({ data: {} }),
},
project: {
current: async (p: { directory: string }) => {
@@ -108,7 +109,10 @@ type ProviderInternals = {
isWebviewReady: boolean
webview: { postMessage: (message: unknown) => Promise<unknown> } | null
startStatsPolling: () => void
refreshGitStatus: (directory?: string) => Promise<void>
contextSessionID: string | undefined
refreshGitStatus: (directory?: string, sessionID?: string) => Promise<void>
refreshGitStatusFromParts: (parts: unknown[], sessionID?: string) => Promise<boolean>
refreshSessionDetails: (sessionID: string, dir: string) => void
handleSendCommand: (
command: string,
args: string,
@@ -161,6 +165,129 @@ describe("KiloProvider route integration", () => {
})
})
it("keeps a session's discovered Git root across focus refreshes", async () => {
await withNestedRepo(async (root) => {
const source = path.join(root, "src")
await fs.mkdir(source)
const parent = path.dirname(root)
const { connection } = mockConnection(undefined, "none")
const provider = new KiloProvider({} as never, connection, undefined, {
rootDirectory: () => parent,
})
const internal = provider as unknown as ProviderInternals
internal.connectionState = "connected"
internal.initConnectionPromise = Promise.resolve()
internal.isWebviewReady = true
internal.startStatsPolling = () => {}
internal.webview = { postMessage: async () => true }
await internal.refreshGitStatus(source, "s1")
const resolved = await fs.realpath(root)
expect(provider.getSessionGitDirectory("s1")).toBe(resolved)
const calls: Array<{ directory?: string; sessionID?: string }> = []
internal.refreshGitStatus = async (directory, sessionID) => {
calls.push({ directory, sessionID })
}
internal.contextSessionID = "s1"
internal.refreshSessionDetails("s1", parent)
expect(calls).toEqual([{ directory: resolved, sessionID: "s1" }])
})
})
it("keeps the session on its owning repo after tools touch a nested repo", async () => {
await withNestedRepo(async (root) => {
const nested = path.join(root, "vendor", "lib")
await fs.mkdir(nested, { recursive: true })
const result = Bun.spawnSync({ cmd: ["git", "init"], cwd: nested, stdout: "pipe", stderr: "pipe" })
if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString())
const { connection } = mockConnection(undefined, "none")
const provider = new KiloProvider({} as never, connection, undefined, {
rootDirectory: () => root,
})
const internal = provider as unknown as ProviderInternals
internal.contextSessionID = "s1"
internal.startStatsPolling = () => {}
expect(
await internal.refreshGitStatusFromParts(
[
{
type: "tool",
tool: "read",
state: { status: "completed", input: { filePath: path.join(nested, "readme.md") } },
},
],
"s1",
),
).toBe(false)
expect(provider.getSessionGitDirectory("s1")).toBeUndefined()
await internal.refreshGitStatusFromParts(
[
{
type: "tool",
tool: "edit",
state: {
status: "completed",
metadata: { filediff: { file: path.join(nested, "src.ts") } },
},
},
],
"s1",
)
expect(provider.getSessionGitDirectory("s1")).toBe(await fs.realpath(root))
})
})
it("caches an inactive child repo without changing the visible Git status", async () => {
await withNestedRepo(async (root) => {
const { connection } = mockConnection(undefined, "none")
const provider = new KiloProvider({} as never, connection, undefined, {
rootDirectory: () => path.dirname(root),
})
const internal = provider as unknown as ProviderInternals
const sent: unknown[] = []
internal.contextSessionID = "parent"
internal.isWebviewReady = true
internal.webview = { postMessage: async (message) => sent.push(message) }
await internal.refreshGitStatus(root, "child")
expect(provider.getSessionGitDirectory("child")).toBe(await fs.realpath(root))
expect(sent).not.toContainEqual({ type: "gitStatus", repo: true })
})
})
it("does no Git work for non-mutating part updates", async () => {
await withNestedRepo(async (root) => {
const { connection, projectCalls } = mockConnection(undefined, "none")
const provider = new KiloProvider({} as never, connection, undefined, {
rootDirectory: () => root,
})
const internal = provider as unknown as ProviderInternals
const parts = [
{ type: "text", text: "chunk" },
{ type: "reasoning", text: "thought" },
{ type: "step-start" },
{ type: "step-finish" },
{ type: "tool", tool: "read", state: { status: "completed", input: { filePath: "README.md" } } },
{ type: "tool", tool: "bash", state: { status: "running" } },
{ type: "tool", tool: "grep", state: { status: "completed" } },
]
for (const part of parts) {
expect(await internal.refreshGitStatusFromParts([part], "s1")).toBe(false)
}
expect(projectCalls).toEqual([])
expect(provider.getSessionGitDirectory("s1")).toBeUndefined()
})
})
it("checks Git capability in the active project directory", async () => {
const { connection, projectCalls } = mockConnection()
const provider = new KiloProvider({} as never, connection, undefined, {
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, spyOn } from "bun:test"
import * as vscode from "vscode"
import { openFileInEditor } from "../../src/review-utils"
import { openFileInEditor, openRelativeFile } from "../../src/review-utils"
const execute = spyOn(vscode.commands, "executeCommand")
@@ -27,3 +27,17 @@ describe("openFileInEditor", () => {
expect(options.selection?.start.character).toBe(2)
})
})
describe("openRelativeFile", () => {
it("resolves diff paths from the selected repository", () => {
openRelativeFile("/repo/app_alpha", "README.md")
expect((execute.mock.calls[0]?.[1] as vscode.Uri).fsPath).toBe("/repo/app_alpha/README.md")
})
it("rejects paths outside the selected repository", () => {
openRelativeFile("/repo/app_alpha", "../app_beta/README.md")
expect(execute).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,38 @@
import { describe, expect, it } from "bun:test"
import { editPaths } from "../../src/kilo-provider/session-edits"
describe("session edit paths", () => {
it("ignores read tools and returns every file from mutating tools", () => {
const parts = [
{
type: "tool",
tool: "read",
state: { status: "completed", input: { filePath: "/workspace/app/vendor/readme.md" } },
},
{
type: "tool",
tool: "edit",
state: { status: "completed", metadata: { filediff: { file: "/workspace/app/src/a.ts" } } },
},
{
type: "tool",
tool: "apply_patch",
state: {
status: "completed",
metadata: {
files: [
{ filePath: "/workspace/app/src/b.ts" },
{ filePath: "/workspace/app/src/old.ts", movePath: "/workspace/app/src/new.ts" },
],
},
},
},
]
expect(editPaths(parts, "/workspace")).toEqual([
"/workspace/app/src/a.ts",
"/workspace/app/src/b.ts",
"/workspace/app/src/new.ts",
])
})
})
@@ -385,6 +385,7 @@ describe("SourceController.reactivate", () => {
it("rebuilds the active source via the build factory and refetches", async () => {
let builds = 0
let fetches = 0
const dirs: Array<string | undefined> = []
const factory = (): DiffSource => {
builds++
return {
@@ -397,7 +398,10 @@ describe("SourceController.reactivate", () => {
}
const posted: unknown[] = []
const controller = new SourceController(
() => factory(),
(_id, ctx) => {
dirs.push(ctx.dir)
return factory()
},
() => [WORKSPACE_DESC],
(m) => posted.push(m),
)
@@ -406,9 +410,11 @@ describe("SourceController.reactivate", () => {
expect(builds).toBe(1)
expect(fetches).toBe(1)
controller.setContext({ workspaceRoot: "/repo", dir: "/repo/app_beta" })
await controller.reactivate()
expect(builds).toBe(2)
expect(fetches).toBe(2)
expect(dirs).toEqual([undefined, "/repo/app_beta"])
controller.stop()
})
@@ -539,7 +539,14 @@ const AgentManagerContent: Component = () => {
const togglePRPanel = () => {
setHistory(false)
if (reviewActive()) closeReviewTab()
const opening = sidePanel() !== SidePanel.PR
setSidePanel((prev) => (prev === SidePanel.PR ? null : SidePanel.PR))
// Trigger an immediate refresh when opening so the panel shows fresh data
// rather than waiting for the next poll cycle
if (opening) {
const sel = selection()
if (sel && sel !== LOCAL) vscode.postMessage({ type: "agentManager.refreshPR", worktreeId: sel })
}
}
const openSelectedPR = () => {
@@ -2632,23 +2639,19 @@ const AgentManagerContent: Component = () => {
/>
</Show>
<Show when={sidePanel() === SidePanel.PR && activePR()}>
{(() => {
const data = activePR()!
return (
<PRPanel
pr={data.pr}
worktree={data.wt}
onClose={() => setSidePanel(null)}
onOpenExternal={() =>
vscode.postMessage({
type: "agentManager.openPR",
worktreeId: data.selected,
url: data.pr.url,
})
}
/>
)
})()}
<PRPanel
pr={activePR()!.pr}
worktree={activePR()!.wt}
worktreeId={activePR()!.selected}
onClose={() => setSidePanel(null)}
onOpenExternal={() =>
vscode.postMessage({
type: "agentManager.openPR",
worktreeId: activePR()!.selected,
url: activePR()!.pr.url,
})
}
/>
</Show>
<SideTerminalPanel
state={terms}
@@ -1,12 +1,126 @@
/** @jsxImportSource solid-js */
import { For, Show, createSignal } from "solid-js"
import { Index, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { Markdown } from "@kilocode/kilo-ui/markdown"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { useVSCode } from "../../src/context/vscode"
import type { PRStatus } from "../../src/types/messages"
import type { PRComment } from "./pr-types"
import { SectionHeading } from "./SectionHeading"
import { CopyButton } from "./CopyButton"
export function PRComments(props: { comments: NonNullable<PRStatus["comments"]> }) {
function DiffHunk(props: { hunk: string }) {
const lines = () => props.hunk.split("\n")
return (
<div class="am-pr-diff-hunk">
<Index each={lines()}>
{(line) => {
const text = line()
const cls = text.startsWith("+")
? "am-pr-diff-line-add"
: text.startsWith("-")
? "am-pr-diff-line-del"
: text.startsWith("@@")
? "am-pr-diff-line-meta"
: "am-pr-diff-line-ctx"
return <div class={`am-pr-diff-line ${cls}`}>{text || " "}</div>
}}
</Index>
</div>
)
}
function CommentCard(props: { comment: PRComment; worktreeId: string }) {
const vscode = useVSCode()
// Track pending action and any error from the result
const [pendingResolved, setPendingResolved] = createSignal<boolean | undefined>(undefined)
const [actionError, setActionError] = createSignal<string | undefined>(undefined)
// Resolved shows pending state if exists, otherwise server state
const resolved = createMemo(() => pendingResolved() ?? props.comment.resolved)
// Clear pending when server state matches (action confirmed by poll)
createMemo(() => {
const pending = pendingResolved()
if (pending !== undefined && pending === props.comment.resolved) {
setPendingResolved(undefined)
setActionError(undefined)
}
})
onMount(() => {
function handler(ev: MessageEvent) {
const msg = ev.data
const isResult =
(msg?.type === "agentManager.resolveCommentResult" || msg?.type === "agentManager.unresolveCommentResult") &&
msg.worktreeId === props.worktreeId &&
msg.threadId === props.comment.threadId
if (!isResult) return
if (!msg.success) {
// Only clear on error - success waits for poll to update props.comment.resolved
setPendingResolved(undefined)
setActionError(
msg.type === "agentManager.resolveCommentResult"
? "Failed to resolve thread."
: "Failed to unresolve thread.",
)
}
}
window.addEventListener("message", handler)
onCleanup(() => window.removeEventListener("message", handler))
})
function toggle() {
setActionError(undefined)
const next = !resolved()
setPendingResolved(next)
vscode.postMessage({
type: next ? "agentManager.resolveComment" : "agentManager.unresolveComment",
worktreeId: props.worktreeId,
threadId: props.comment.threadId,
} as never)
}
return (
<div class="am-pr-panel-comment" classList={{ "am-pr-panel-comment-resolved": resolved() }}>
<Show when={props.comment.diffHunk}>{(hunk) => <DiffHunk hunk={hunk()} />}</Show>
<div class="am-pr-panel-comment-header am-pr-row">
<span class="am-pr-panel-comment-author">{props.comment.author}</span>
<Show when={props.comment.file}>
<span class="am-pr-panel-comment-file">
{props.comment.file}
<Show when={props.comment.line}>{`:${props.comment.line}`}</Show>
</span>
</Show>
<Show when={resolved()}>
<span class="am-pr-panel-comment-resolved-badge">Resolved</span>
</Show>
<CopyButton text={props.comment.body} class="am-pr-copy-btn" />
</div>
<Show when={actionError()}>{(err) => <div class="am-pr-resolve-error">{err()}</div>}</Show>
<div class="am-pr-panel-comment-body">
<Markdown text={props.comment.body} />
</div>
<div class="am-pr-resolve-row">
<Show
when={pendingResolved() === undefined}
fallback={
<div class="am-pr-resolve-loading">
<Spinner class="am-pr-resolve-spinner" />
<span>Loading</span>
</div>
}
>
<button class="am-pr-resolve-btn" onClick={toggle}>
{resolved() ? "Unresolve comment" : "Resolve comment"}
</button>
</Show>
</div>
</div>
)
}
export function PRComments(props: { comments: NonNullable<PRStatus["comments"]>; worktreeId: string }) {
const [open, setOpen] = createSignal(true)
return (
<>
@@ -21,28 +135,9 @@ export function PRComments(props: { comments: NonNullable<PRStatus["comments"]>
/>
<Show when={open()}>
<div class="am-pr-panel-comment-list am-pr-col">
<For each={props.comments.comments}>
{(comment: PRComment) => (
<div class="am-pr-panel-comment" classList={{ "am-pr-panel-comment-resolved": comment.resolved }}>
<div class="am-pr-panel-comment-header am-pr-row">
<span class="am-pr-panel-comment-author">{comment.author}</span>
<Show when={comment.file}>
<span class="am-pr-panel-comment-file">
{comment.file}
<Show when={comment.line}>{`:${comment.line}`}</Show>
</span>
</Show>
<Show when={comment.resolved}>
<span class="am-pr-panel-comment-resolved-badge">Resolved</span>
</Show>
<CopyButton text={comment.body} class="am-pr-copy-btn" />
</div>
<div class="am-pr-panel-comment-body">
<Markdown text={comment.body} />
</div>
</div>
)}
</For>
<Index each={props.comments.comments}>
{(comment) => <CommentCard comment={comment()} worktreeId={props.worktreeId} />}
</Index>
</div>
</Show>
</div>
@@ -1,5 +1,5 @@
/** @jsxImportSource solid-js */
import { Component, Show } from "solid-js"
import { Component, Show, createSignal } from "solid-js"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import type { WorktreeState } from "../../src/types/messages"
@@ -16,11 +16,29 @@ import "./pr-panel.css"
interface PRPanelProps {
pr: PRStatus
worktree?: WorktreeState
worktreeId: string
onClose: () => void
onOpenExternal: () => void
}
export const PRPanel: Component<PRPanelProps> = (props) => {
let bodyRef: HTMLDivElement | undefined
let commentsRef: HTMLDivElement | undefined
const [showScrollTop, setShowScrollTop] = createSignal(false)
function onScroll(e: Event) {
const el = e.target as HTMLDivElement
setShowScrollTop(el.scrollTop > 100)
}
function scrollToTop() {
bodyRef?.scrollTo({ top: 0, behavior: "smooth" })
}
function jumpToComments() {
commentsRef?.scrollIntoView({ behavior: "smooth", block: "start" })
}
return (
<div class="am-pr-panel am-pr-col">
<div class="am-pr-panel-header am-pr-row">
@@ -44,18 +62,31 @@ export const PRPanel: Component<PRPanelProps> = (props) => {
</Tooltip>
</div>
</div>
<div class="am-pr-panel-body">
<PRSummary pr={props.pr} />
<PROverview pr={props.pr} worktree={props.worktree} />
<Show when={(props.pr.reviewers ?? []).length > 0}>
<PRReviewers reviewers={props.pr.reviewers ?? []} />
</Show>
<Show when={props.pr.body}>{(body) => <PRDescription body={body()} />}</Show>
<Show when={props.pr.checks.total > 0}>
<PRChecks checks={props.pr.checks} />
</Show>
<Show when={props.pr.comments?.total ? props.pr.comments : undefined}>
{(comments) => <PRComments comments={comments()} />}
<div class="am-pr-panel-body-wrap">
<div class="am-pr-panel-body" ref={bodyRef} onScroll={onScroll}>
<PRSummary pr={props.pr} onJumpToComments={jumpToComments} />
<PROverview pr={props.pr} worktree={props.worktree} />
<Show when={(props.pr.reviewers ?? []).length > 0}>
<PRReviewers reviewers={props.pr.reviewers ?? []} />
</Show>
<Show when={props.pr.body}>{(body) => <PRDescription body={body()} />}</Show>
<Show when={props.pr.checks.total > 0}>
<PRChecks checks={props.pr.checks} />
</Show>
<Show when={props.pr.comments?.total ? props.pr.comments : undefined}>
{(comments) => (
<div ref={commentsRef}>
<PRComments comments={comments()} worktreeId={props.worktreeId} />
</div>
)}
</Show>
</div>
<Show when={showScrollTop()}>
<Tooltip value="Scroll to top" placement="left">
<button class="am-pr-scroll-top" onClick={scrollToTop}>
</button>
</Tooltip>
</Show>
</div>
</div>
@@ -5,9 +5,10 @@ import type { PRStatus } from "../../src/types/messages"
interface PRSummaryProps {
pr: PRStatus
onJumpToComments?: () => void
}
function summaryRows(pr: PRStatus): Array<{ icon: string; label: string; status: string }> {
function summaryRows(pr: PRStatus): Array<{ icon: string; label: string; status: string; isComments?: boolean }> {
const rows = []
if (pr.checks.total > 0) {
@@ -28,11 +29,18 @@ function summaryRows(pr: PRStatus): Array<{ icon: string; label: string; status:
})
}
if (pr.comments && pr.comments.unresolved > 0) {
if (pr.comments && pr.comments.total > 0) {
const unresolved = pr.comments.unresolved
const total = pr.comments.total
const label =
unresolved > 0
? `${unresolved} unresolved comment${unresolved > 1 ? "s" : ""}`
: `${total} comment${total > 1 ? "s" : ""}`
rows.push({
icon: "comment",
label: `${pr.comments.unresolved} unresolved comment${pr.comments.unresolved > 1 ? "s" : ""}`,
status: "warning",
label,
status: unresolved > 0 ? "warning" : "success",
isComments: true,
})
}
@@ -59,12 +67,28 @@ export function PRSummary(props: PRSummaryProps) {
</span>
</div>
<div class="am-pr-summary-rows am-pr-col">
{rows().map((row) => (
<div class="am-pr-summary-row am-pr-row" data-status={row.status}>
<Icon name={row.icon} size="small" class="am-pr-summary-icon" />
<span class="am-pr-summary-label">{row.label}</span>
</div>
))}
{rows().map((row) => {
const isClickable = !!(row.isComments && props.onJumpToComments)
const rowProps = {
class: "am-pr-summary-row am-pr-row",
classList: { "am-pr-summary-row-link": isClickable },
"data-status": row.status,
}
const content = (
<>
<Icon name={row.icon} size="small" class="am-pr-summary-icon" />
<span class="am-pr-summary-label">{row.label}</span>
{row.isComments && props.onJumpToComments && <span class="am-pr-summary-jump">Jump to comments </span>}
</>
)
return isClickable ? (
<button {...rowProps} onClick={props.onJumpToComments}>
{content}
</button>
) : (
<div {...rowProps}>{content}</div>
)
})}
</div>
</div>
</Show>
@@ -67,10 +67,39 @@
flex-shrink: 0;
}
.am-pr-panel-body {
.am-pr-panel-body-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
.am-pr-panel-body {
height: 100%;
overflow-y: auto;
padding: 8px 0;
overflow-anchor: none;
}
.am-pr-scroll-top {
position: absolute;
bottom: 16px;
right: 16px;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid var(--vscode-foreground);
background: var(--vscode-editor-background);
color: var(--vscode-foreground);
cursor: pointer;
font-size: var(--kilo-font-size-14);
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
}
.am-pr-scroll-top:hover {
opacity: 1;
}
.am-pr-panel-section {
@@ -255,6 +284,87 @@
margin-left: auto;
}
/* Diff hunk preview inside comment cards */
.am-pr-diff-hunk {
font-family: var(--font-mono, monospace);
font-size: var(--kilo-font-size-11);
border-radius: 3px;
overflow: hidden;
margin-bottom: 6px;
border: 1px solid var(--vscode-panel-border);
}
.am-pr-diff-line {
padding: 1px 6px;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
}
.am-pr-diff-line-add {
background: color-mix(in lab, var(--syntax-diff-add, #318430) 15%, transparent);
color: var(--syntax-diff-add, #318430);
}
.am-pr-diff-line-del {
background: color-mix(in lab, var(--syntax-diff-delete, #da3319) 15%, transparent);
color: var(--syntax-diff-delete, #da3319);
}
.am-pr-diff-line-meta {
color: var(--text-weaker);
}
.am-pr-diff-line-ctx {
color: var(--vscode-foreground);
}
/* Resolve button */
.am-pr-resolve-row {
display: flex;
justify-content: flex-start;
margin-top: 15px;
}
.am-pr-resolve-btn {
background: none;
border: 1px solid var(--vscode-panel-border);
border-radius: 3px;
color: var(--text-weak);
cursor: pointer;
font-size: var(--kilo-font-size-14);
padding: 4px 16px;
}
.am-pr-resolve-btn:hover {
color: var(--vscode-foreground);
border-color: var(--vscode-foreground);
}
.am-pr-resolve-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.am-pr-resolve-error {
font-size: var(--kilo-font-size-11);
color: var(--vscode-testing-iconFailed, #f87171);
padding: 2px 0 4px;
}
.am-pr-resolve-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 16px;
font-size: var(--kilo-font-size-14);
color: var(--text-weak);
}
.am-pr-resolve-spinner {
width: 14px;
height: 14px;
}
.am-pr-panel-comment-body [data-component="markdown"] {
font-size: var(--kilo-font-size-12);
color: var(--vscode-foreground);
@@ -455,3 +565,39 @@
.am-pr-summary-label {
color: var(--vscode-foreground);
}
.am-pr-summary-jump {
margin-left: auto;
font-size: var(--kilo-font-size-12);
color: var(--text-weaker);
}
.am-pr-summary-row-link {
cursor: pointer;
border-radius: 3px;
padding-left: 2px;
padding-right: 2px;
}
button.am-pr-summary-row-link {
all: unset;
display: flex;
width: 100%;
gap: 7px;
padding: 2px;
font-size: var(--kilo-font-size-12);
box-sizing: border-box;
cursor: pointer;
}
button.am-pr-summary-row-link:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
}
.am-pr-summary-row-link:hover {
background: var(--vscode-list-hoverBackground);
}
.am-pr-summary-row-link:hover .am-pr-summary-jump {
color: var(--vscode-foreground);
}
@@ -15,6 +15,7 @@ export interface PRCheck {
export interface PRComment {
id: string
threadId: string
author: string
avatar?: string
body: string
@@ -23,6 +24,7 @@ export interface PRComment {
url?: string
resolved: boolean
createdAt?: number
diffHunk?: string
}
export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented"
@@ -991,6 +991,12 @@ export interface OpenPRMessage {
url?: string
}
export interface CommentActionMessage {
type: "agentManager.resolveComment" | "agentManager.unresolveComment"
worktreeId: string
threadId: string
}
export interface ApplyWorktreeDiffMessage {
type: "agentManager.applyWorktreeDiff"
worktreeId: string
@@ -1532,6 +1538,7 @@ export type WebviewMessage =
| SetDiffBaseBranchMessage
| RefreshPRMessage
| OpenPRMessage
| CommentActionMessage
// legacy-migration start
| RequestMigrationDataMessage
| StartMigrationMessage
+57 -8
View File
@@ -29,9 +29,9 @@ if (argv.includes("--help") || argv.includes("-h")) {
"",
"Options:",
" --ci Enable JUnit XML output to .artifacts/unit/junit.xml",
" --concurrency <N> Max parallel processes (default: min(4, CPU count))",
" --concurrency <N> Max parallel processes (default: min(4, CPU count), env: KILO_TEST_CONCURRENCY)",
" --timeout <ms> Per-test timeout passed to bun test (default: 60000)",
" --file-timeout <ms> Per-file process timeout (default: 300000)",
" --file-timeout <ms> Per-file process timeout (default: 300000, env: KILO_TEST_FILE_TIMEOUT)",
" --retries <N> Extra attempts for failing files (default: 1)",
" --profile <name> Run a curated test profile (env: KILO_TEST_PROFILE)",
" --shard <N/M> Run one balanced file shard (env: KILO_TEST_SHARD)",
@@ -73,9 +73,39 @@ const dots = !verbose && (ci || argv.includes("--dots"))
// Cap concurrency at 4 even on bigger runners: the bottleneck is shared
// resources (ports, global filesystem like ~/.local/share/kilo), not CPU.
// Eight parallel processes was triggering port/FS races, not going faster.
const concurrency = opt("concurrency", Math.min(4, os.cpus().length))
// kilocode_change start - allow CI to lower concurrency via env. On the 4-vCPU
// Windows runner, the default (min(4, cpus)=4) oversubscribes: 4 heavy real-server
// test files share 4 vCPUs (~1 each) and blow their per-test timeouts.
// `KILO_TEST_CONCURRENCY` lets the workflow throttle Windows without affecting the
// local default. An explicit `--concurrency` flag wins.
const concurrencyEnv = (() => {
const raw = process.env.KILO_TEST_CONCURRENCY?.trim()
if (!raw) return undefined
const value = Number(raw)
if (!Number.isSafeInteger(value) || value < 1) {
console.error(`Invalid KILO_TEST_CONCURRENCY "${raw}"; expected a positive integer`)
process.exit(2)
}
return value
})()
const concurrency = opt("concurrency", concurrencyEnv ?? Math.min(4, os.cpus().length))
// kilocode_change end
const timeout = opt("timeout", 60000)
const deadline = opt("file-timeout", 300000)
// kilocode_change start - allow CI to raise the per-file kill deadline via env. On Windows,
// heavy real-server files (e.g. config-overlay) legitimately run ~270s serially, only ~30s
// under the 300s default; raising it there prevents a slow-but-healthy run from being killed.
const fileTimeoutEnv = (() => {
const raw = process.env.KILO_TEST_FILE_TIMEOUT?.trim()
if (!raw) return undefined
const value = Number(raw)
if (!Number.isSafeInteger(value) || value < 1) {
console.error(`Invalid KILO_TEST_FILE_TIMEOUT "${raw}"; expected a positive integer (ms)`)
process.exit(2)
}
return value
})()
const deadline = opt("file-timeout", fileTimeoutEnv ?? 300000)
// kilocode_change end
const retries = opt("retries", 1)
const flag = text("profile")
const env = process.env.KILO_TEST_PROFILE?.trim() || undefined
@@ -156,7 +186,28 @@ if (shard && shard.total > candidates.length) {
console.error(`Test shard count ${shard.total} exceeds selected file count ${candidates.length}`)
process.exit(2)
}
const weight = (file: string) => Bun.file(path.join(root, "test", file)).size
// kilocode_change start - shard by estimated DURATION, not file size. File size is a poor
// proxy: run-process.test.ts is ~7 KB but ~230s, while config-overlay is the single slowest
// file — under size-weighting both landed in the same shard, stacking the two heaviest files.
// DURATION_HINTS are max observed per-file durations (ms) from real Windows CI runs; the LPT
// splitter places the highest-weight files first, so hinted heavy files get spread across
// distinct shards. Unhinted files fall back to size (a fine proxy among the fast majority);
// hint values (tens of thousands of ms) dominate byte sizes, so heavy files always sort first.
// Refresh these from observed CI durations when the suite changes materially.
const DURATION_HINTS: Record<string, number> = {
"kilocode/server/config-overlay.test.ts": 270_000,
"cli/run/run-process.test.ts": 233_000,
"snapshot/snapshot.test.ts": 165_000,
"session/prompt.test.ts": 128_000,
"tool/shell.test.ts": 95_000,
"kilocode/background-process.test.ts": 94_000,
"provider/provider.test.ts": 90_000,
"kilocode/indexing-startup.test.ts": 88_000,
"kilocode/daemon.test.ts": 65_000,
"tool/task.test.ts": 64_000,
}
const weight = (file: string) => DURATION_HINTS[file] ?? Bun.file(path.join(root, "test", file)).size
// kilocode_change end
const files = shard ? TestShard.split(candidates, weight, shard.total)[shard.index - 1] : candidates
if (files.length === 0) {
@@ -189,9 +240,7 @@ const xmldir = ci ? path.join(os.tmpdir(), `opencode-junit-${process.pid}`) : ""
if (ci) await fs.mkdir(xmldir, { recursive: true })
// kilocode_change start
const supplied = process.env[TestCli.ENV]
const built = supplied
? { binary: supplied, dir: undefined }
: { binary: await TestCli.build(root), dir: undefined }
const built = supplied ? { binary: supplied, dir: undefined } : { binary: await TestCli.build(root), dir: undefined }
async function cleanBinary() {
if (!built.dir) return