mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
Merge branch 'main' into feat/worktree-for-cli
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep the Changes chip and Git changes visible across tab switches in multi-repository workspaces.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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}` : ""}`)
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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[] }
|
||||
}
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user