From 039d2336791ec32a14e3399fa1ef328110d56053 Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 3 Aug 2026 11:23:50 -0700 Subject: [PATCH 01/54] feat(pr-viewer): View PR with mock data in AM --- .../src/agent-manager/PRStatusPoller.ts | 112 +++++---- .../kilo-vscode/src/agent-manager/types.ts | 8 + .../agent-manager/AgentManagerApp.tsx | 31 ++- .../webview-ui/agent-manager/PRPanel.tsx | 210 +++++++++++++++++ .../webview-ui/agent-manager/TabBar.tsx | 19 +- .../agent-manager/agent-manager.css | 221 ++++++++++++++++++ .../src/types/messages/agent-manager.ts | 8 + packages/ui/src/components/icon.tsx | 1 + 8 files changed, 566 insertions(+), 44 deletions(-) create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/PRPanel.tsx diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index 155bd410bba..c45cd992beb 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -1,6 +1,6 @@ import type { ExecFileOptionsWithStringEncoding } from "child_process" import type { Worktree } from "./WorktreeStateManager" -import type { PRStatus, PRCheck, PRComment, CheckStatus, AggregateCheckStatus, PRState, ReviewDecision } from "./types" +import type { PRStatus, PRCheck, PRComment, PRReviewer, CheckStatus, AggregateCheckStatus, PRState, ReviewDecision } from "./types" import { execWithShellEnv } from "./shell-env" import { classifyPRError } from "./git-import" import type { Semaphore } from "./semaphore" @@ -244,11 +244,12 @@ export class PRStatusPoller { const status: PRStatus = { number: pr.number, title: pr.title, + body: pr.body, url: pr.url, state: pr.state, review: pr.review, checks, - ...(comments && { comments }), + ...(comments && { comments, reviewers: comments.reviewers }), additions: pr.additions, deletions: pr.deletions, files: pr.files, @@ -283,7 +284,7 @@ export class PRStatusPoller { } private static readonly PR_JSON_FIELDS = - "number,title,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid" + "number,title,body,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid" /** Return a cached PR lookup if still fresh, otherwise fetch and cache. * Keyed by branch name so multiple worktrees on the same branch share @@ -420,10 +421,9 @@ export class PRStatusPoller { private async fetchComments( prNumber: number, cwd: string, - ): Promise<{ total: number; unresolved: number; items: PRComment[] }> { + ): Promise<{ total: number; unresolved: number; items: PRComment[]; reviewers: PRReviewer[] }> { try { const repo = await this.getRepoInfo(cwd) - const query = `query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { @@ -443,52 +443,27 @@ export class PRStatusPoller { } } } + reviewRequests(first: 20) { + nodes { requestedReviewer { ... on User { login avatarUrl } } } + } + reviews(first: 20, states: [APPROVED, CHANGES_REQUESTED, COMMENTED]) { + nodes { author { login avatarUrl } state } + } } } }` - const { stdout } = await this.shell( "gh", - [ - "api", - "graphql", - "-f", - `query=${query}`, - "-F", - `owner=${repo.owner}`, - "-F", - `repo=${repo.name}`, - "-F", - `number=${prNumber}`, - ], + ["api", "graphql", "-f", `query=${query}`, "-F", `owner=${repo.owner}`, "-F", `repo=${repo.name}`, "-F", `number=${prNumber}`], { cwd, timeout: 15_000 }, ) - const result = JSON.parse(stdout) - const threads = result?.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [] - - const items: PRComment[] = [] - for (const thread of threads) { - const first = thread.comments?.nodes?.[0] - if (!first) continue - items.push({ - id: first.id, - author: first.author?.login ?? "unknown", - avatar: first.author?.avatarUrl, - body: first.body ?? "", - file: first.path, - line: first.line, - url: first.url, - resolved: thread.isResolved ?? false, - createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined, - }) - } - - const total = items.length - const unresolved = items.filter((c) => !c.resolved).length - return { total, unresolved, items } + const pr = JSON.parse(stdout)?.data?.repository?.pullRequest + const items = parseComments(pr?.reviewThreads?.nodes ?? []) + const reviewers = parseReviewers(pr?.reviewRequests?.nodes ?? [], pr?.reviews?.nodes ?? []) + return { total: items.length, unresolved: items.filter((c) => !c.resolved).length, items, reviewers } } catch (err) { this.options.log("Failed to fetch PR comments:", err) - return { total: 0, unresolved: 0, items: [] } + return { total: 0, unresolved: 0, items: [], reviewers: [] } } } } @@ -496,6 +471,7 @@ export class PRStatusPoller { interface PRResult { number: number title: string + body: string url: string state: PRState review: ReviewDecision | null @@ -510,6 +486,7 @@ function parsePRResult(json: string): PRResult | null { return { number: data.number, title: data.title ?? "", + body: data.body ?? "", url: data.url ?? "", state: parsePRState(data.isDraft, data.state), review: parseReviewDecision(data.reviewDecision), @@ -564,6 +541,57 @@ function formatCheckDuration(startedAt?: string, completedAt?: string): string | return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s` } +function parseComments(threads: Record[]): PRComment[] { + const items: PRComment[] = [] + for (const thread of threads) { + const nodes = (thread.comments as { nodes?: unknown[] } | undefined)?.nodes + const first = nodes?.[0] as Record | undefined + if (!first) continue + const author = first.author as Record | undefined + items.push({ + id: first.id as string, + author: (author?.login as string) ?? "unknown", + avatar: author?.avatarUrl as string | undefined, + body: (first.body as string) ?? "", + file: first.path as string | undefined, + line: first.line as number | undefined, + url: first.url as string | undefined, + resolved: (thread.isResolved as boolean) ?? false, + createdAt: first.createdAt ? new Date(first.createdAt as string).getTime() : undefined, + }) + } + return items +} + +function parseReviewers( + requests: Record[], + reviews: Record[], +): PRReviewer[] { + const map = new Map() + for (const node of requests) { + const user = node.requestedReviewer as Record | undefined + if (!user?.login) continue + map.set(user.login as string, { login: user.login as string, avatar: user.avatarUrl as string | undefined, state: "pending" }) + } + for (const node of reviews) { + const author = node.author as Record | undefined + const login = author?.login as string | undefined + if (!login) continue + const state = REVIEWER_STATE[node.state as string] ?? "pending" + // Keep most significant: approved/changes_requested > commented > pending + if (!map.has(login) || state !== "commented") { + map.set(login, { login, avatar: author?.avatarUrl as string | undefined, state }) + } + } + return [...map.values()] +} + +const REVIEWER_STATE: Record = { + APPROVED: "approved", + CHANGES_REQUESTED: "changes_requested", + COMMENTED: "commented", +} + /** Run async thunks with bounded concurrency, returning settled results. */ async function settled(thunks: (() => Promise)[], concurrency: number): Promise[]> { const results: PromiseSettledResult[] = new Array(thunks.length) diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 3a2b555e355..44e2c3eee68 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -71,9 +71,16 @@ export interface PRComment { createdAt?: number } +export interface PRReviewer { + login: string + avatar?: string + state: "approved" | "changes_requested" | "pending" | "commented" +} + export interface PRStatus { number: number title: string + body?: string url: string state: PRState review: ReviewDecision | null @@ -90,6 +97,7 @@ export interface PRStatus { unresolved: number items: PRComment[] } + reviewers?: PRReviewer[] additions: number deletions: number files: number diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 939a8e505be..b10f83e6d6f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -141,6 +141,7 @@ import { import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering" import { useTabScroll } from "./tab-scroll" import { DiffPanel } from "./DiffPanel" +import { PRPanel } from "./PRPanel" import { createRevertFile } from "./revert-file" import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView" import { createApplyToLocal } from "./apply-to-local" @@ -308,6 +309,7 @@ const AgentManagerContent: Component = () => { const [history, setHistory] = createSignal(false) const [sidePanel, setSidePanel] = createSignal(null) const diffOpen = () => sidePanel() === "diff" + const prOpen = () => sidePanel() === "pr" const diffs = createWorktreeDiffs(vscode) const diffDatas = diffs.diffDatas const diffLoading = diffs.diffLoading @@ -553,7 +555,13 @@ const AgentManagerContent: Component = () => { const sel = selection() if (!sel || sel === LOCAL || !prStatuses()[sel]) return metrics.track("open_pull_request", "keyboard_shortcut") - vscode.postMessage({ type: "agentManager.openPR", worktreeId: sel }) + togglePRPanel() + } + + const togglePRPanel = () => { + setHistory(false) + setReviewActive(false) + setSidePanel((prev) => (prev === "pr" ? null : "pr")) } const runWorktree = (id: string, destination: TerminalDestination) => { @@ -2448,6 +2456,13 @@ const AgentManagerContent: Component = () => { reviewActive={reviewActive} onToggleDiff={toggleDiffPanel} onToggleReview={metrics.click("fullscreen_review", "tab_toolbar", toggleReviewTab)} + prStatus={() => { + const sel = selection() + if (!sel || sel === LOCAL) return undefined + return prStatuses()[sel] + }} + prOpen={prOpen} + onTogglePR={togglePRPanel} terminalDestination={sideCtl.destination} terminalDestinationActive={() => sidePanel() === "terminal"} terminalKeybind={() => kb().showTerminal ?? ""} @@ -2686,6 +2701,20 @@ const AgentManagerContent: Component = () => { activeTerminalId={terms.activeId()} /> + + {(() => { + const sel = selection() + const pr = sel && sel !== LOCAL ? prStatuses()[sel] : undefined + if (!pr) return null + return ( + setSidePanel(null)} + onOpenExternal={() => vscode.postMessage({ type: "agentManager.openPR", worktreeId: sel as string })} + /> + ) + })()} + void + onOpenExternal: () => void +} + +const STATE_LABEL: Record = { + open: "Open", + draft: "Draft", + merged: "Merged", + closed: "Closed", +} + +const REVIEW_LABEL: Partial, string>> = { + approved: "Approved", + changes_requested: "Changes Requested", + pending: "Review Pending", +} + +const CHECK_ICON: Record = { + success: "circle-check", + failure: "circle-x", + cancelled: "circle-x", + skipped: "circle-x", + pending: "play", +} + +const CHECK_LABEL: Record = { + success: "Passed", + failure: "Failed", + pending: "Running", + skipped: "Skipped", + cancelled: "Cancelled", +} + +const REVIEWER_STATE_ICON: Record = { + approved: "circle-check", + changes_requested: "refresh", + commented: "edit", + pending: "clock", +} + +const REVIEWER_STATE_LABEL: Record = { + approved: "Approved", + changes_requested: "Changes requested", + commented: "Commented", + pending: "Awaiting", +} + +export const PRPanel: Component = (props) => { + const accent = () => prAccentColor(props.pr) + const indicator = () => prBadgeIndicator(props.pr) + const pulsing = () => prChecksRunning(props.pr) + + return ( +
+
+
+ + + #{props.pr.number} + + {props.pr.title} +
+
+ + + + + + +
+
+ +
+ {/* State + review */} +
+
+ Status + {STATE_LABEL[props.pr.state]} +
+ + {(review) => ( +
+ Review + {REVIEW_LABEL[review()]} +
+ )} +
+
+ + {/* Description */} + +
+
+
{props.pr.body}
+
+ + + {/* Reviewers */} + 0}> +
+
+
Reviewers
+
+ + {(reviewer) => ( +
+ + + {REVIEWER_STATE_LABEL[reviewer.state]} +
+ )} +
+
+
+ + + {/* Diff stats */} + 0 || props.pr.additions > 0 || props.pr.deletions > 0}> +
+
+
+ Changes + + 0}> + {props.pr.files}f + + 0}> + +{props.pr.additions} + + 0}> + −{props.pr.deletions} + + +
+
+ + + {/* Checks */} + 0}> +
+
+
+ Checks + + {props.pr.checks.passed}/{props.pr.checks.total} passed + +
+
+ + {(check) => ( +
+ + {check.name} + {CHECK_LABEL[check.status]} + + {check.duration} + + + e.preventDefault()}> + + + +
+ )} +
+
+
+ + + {/* Comments */} + 0}> +
+
+
+ Comments + + {props.pr.comments!.total} + 0}> + {" "} + ({props.pr.comments!.unresolved} unresolved) + + +
+
+ +
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx index fc1a36b462b..f3c1ddda1c3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx @@ -13,7 +13,7 @@ import { closestCenter, type DragEvent, } from "@thisbeyond/solid-dnd" -import type { LocalGitStats, RunStatus, WorktreeGitStats } from "../src/types/messages" +import type { LocalGitStats, RunStatus, WorktreeGitStats, PRStatus } from "../src/types/messages" import type { LanguageContextValue } from "../src/context/language" import { LOCAL } from "./navigate" import { ConstrainDragYAxis } from "../src/components/chat/TabDnd" @@ -55,6 +55,9 @@ export interface TabBarProps { reviewActive: () => boolean onToggleDiff: () => void onToggleReview: () => void + prStatus: () => PRStatus | null | undefined + prOpen: () => boolean + onTogglePR: () => void terminalDestination: () => TerminalDestination terminalDestinationActive: () => boolean terminalKeybind: () => string @@ -241,6 +244,20 @@ export const TabBar: Component = (props) => ( ) })()} + + {(pr) => ( + + + + )} + `, warning: ``, reset: ``, + refresh: ``, link: ``, providers: ``, layers: ``, From 0a78fedd28eadcac51661c8c81983d8b16176ce0 Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 3 Aug 2026 13:16:00 -0700 Subject: [PATCH 02/54] feat(pr-viewer): Update styles and add markdown renderer for PR description --- .../src/agent-manager/pr-status-bridge.ts | 4 +- .../kilo-vscode/src/agent-manager/types.ts | 1 + .../agent-manager/AgentManagerApp.tsx | 2 +- .../webview-ui/agent-manager/PRPanel.tsx | 85 +++++++++---- .../agent-manager/agent-manager.css | 119 ++++++++++++++++-- .../src/types/messages/webview-messages.ts | 1 + 6 files changed, 173 insertions(+), 39 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts index cbbb1bffda8..2a216c30796 100644 --- a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts @@ -78,8 +78,8 @@ export class PRStatusBridge { return true } if (m.type === "agentManager.openPR") { - const wt = this.host.getWorktrees().find((w: Worktree) => w.id === m.worktreeId) - if (wt?.prUrl) this.host.openExternal(wt.prUrl) + const url = (m.url as string | undefined) ?? this.host.getWorktrees().find((w: Worktree) => w.id === m.worktreeId)?.prUrl + if (url) this.host.openExternal(url) return true } return false diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 44e2c3eee68..cb2f761ed10 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -739,6 +739,7 @@ interface OpenPRIn { type: "agentManager.openPR" projectId?: string worktreeId: string + url?: string } interface OpenSessionsIn { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index b10f83e6d6f..d00471b8bd6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -2710,7 +2710,7 @@ const AgentManagerContent: Component = () => { setSidePanel(null)} - onOpenExternal={() => vscode.postMessage({ type: "agentManager.openPR", worktreeId: sel as string })} + onOpenExternal={() => vscode.postMessage({ type: "agentManager.openPR", worktreeId: sel as string, url: pr.url })} /> ) })()} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/PRPanel.tsx index 8a5a011ea98..6bb89ddb585 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/PRPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/PRPanel.tsx @@ -3,6 +3,8 @@ import { Component, For, Show } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import { Markdown } from "@kilocode/kilo-ui/markdown" +import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" import type { PRStatus, CheckStatus, PRReviewer } from "../src/types/messages" import { prAccentColor, prBadgeIndicator, prChecksRunning } from "./WorktreeItem" @@ -45,7 +47,7 @@ const REVIEWER_STATE_ICON: Record = { approved: "circle-check", changes_requested: "refresh", commented: "edit", - pending: "clock", + pending: "dash", } const REVIEWER_STATE_LABEL: Record = { @@ -55,7 +57,13 @@ const REVIEWER_STATE_LABEL: Record = { pending: "Awaiting", } -export const PRPanel: Component = (props) => { +export const PRPanel: Component = (props) => ( + + + +) + +const PRPanelInner: Component = (props) => { const accent = () => prAccentColor(props.pr) const indicator = () => prBadgeIndicator(props.pr) const pulsing = () => prChecksRunning(props.pr) @@ -94,6 +102,7 @@ export const PRPanel: Component = (props) => {
{/* State + review */}
+
Overview
Status {STATE_LABEL[props.pr.state]} @@ -108,19 +117,11 @@ export const PRPanel: Component = (props) => {
- {/* Description */} - -
-
-
{props.pr.body}
-
- - {/* Reviewers */} 0}>
-
Reviewers
+
Reviewers
{(reviewer) => ( @@ -135,13 +136,24 @@ export const PRPanel: Component = (props) => {
+ {/* Description */} + +
+
+
Description
+
+ +
+
+ + {/* Diff stats */} 0 || props.pr.additions > 0 || props.pr.deletions > 0}>
-
- Changes - +
+ File Changes + 0}> {props.pr.files}f @@ -160,9 +172,9 @@ export const PRPanel: Component = (props) => { 0}>
-
- Checks - +
+ Checks + {props.pr.checks.passed}/{props.pr.checks.total} passed
@@ -192,15 +204,36 @@ export const PRPanel: Component = (props) => { 0}>
-
- Comments - - {props.pr.comments!.total} - 0}> - {" "} - ({props.pr.comments!.unresolved} unresolved) - - +
+ Comments + 0}> + + {props.pr.comments!.unresolved} unresolved + + +
+
+ + {(comment) => ( +
+
+ {comment.author} + + + {comment.file} + {`:${comment.line}`} + + + + Resolved + +
+
+ +
+
+ )} +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 9f9403b046b..e4c19fe49d7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -4944,7 +4944,7 @@ body.vscode-high-contrast-light { .am-pr-panel-title { font-size: var(--kilo-font-size-14); - color: var(--text-base); + color: var(--vscode-foreground); font-weight: 500; overflow: hidden; text-overflow: ellipsis; @@ -4968,6 +4968,26 @@ body.vscode-high-contrast-light { padding: 4px 12px; } +.am-pr-panel-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + font-size: var(--font-size-small); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-weak); + padding-bottom: 6px; +} + +.am-pr-panel-section-count { + font-size: var(--kilo-font-size-11); + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: var(--text-weak); +} + .am-pr-panel-divider { height: 1px; background: var(--vscode-panel-border); @@ -4984,14 +5004,17 @@ body.vscode-high-contrast-light { } .am-pr-panel-label { - font-size: var(--kilo-font-size-12); - color: var(--text-weak); + font-size: var(--kilo-font-size-11); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-weaker); flex-shrink: 0; } .am-pr-panel-value { font-size: var(--kilo-font-size-12); - color: var(--text-base); + color: var(--vscode-foreground); text-align: right; } @@ -5014,14 +5037,90 @@ body.vscode-high-contrast-light { color: #fbbf24; } +/* PR comment list */ +.am-pr-panel-comment-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 6px; +} + +.am-pr-panel-comment { + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + padding: 8px; + opacity: 1; +} + +.am-pr-panel-comment-resolved { + opacity: 0.5; +} + +.am-pr-panel-comment-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; + flex-wrap: wrap; +} + +.am-pr-panel-comment-author { + font-size: var(--kilo-font-size-12); + font-weight: 600; + color: var(--vscode-foreground); +} + +.am-pr-panel-comment-file { + font-size: var(--kilo-font-size-11); + color: var(--text-weak); + font-family: var(--font-mono, monospace); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 180px; +} + +.am-pr-panel-comment-resolved-badge { + font-size: var(--kilo-font-size-10); + color: var(--text-weaker); + border: 1px solid var(--vscode-panel-border); + border-radius: 3px; + padding: 1px 4px; + margin-left: auto; +} + +.am-pr-panel-comment-body [data-component="markdown"] { + font-size: var(--kilo-font-size-12); + color: var(--vscode-foreground); +} + .am-pr-panel-description { - font-size: var(--kilo-font-size-14); - color: var(--text-base); - white-space: pre-wrap; line-height: 1.5; padding: 2px 0 4px; } +.am-pr-panel-description [data-component="markdown"], +.am-pr-panel-comment-body [data-component="markdown"] { + font-size: var(--kilo-font-size-13); + color: var(--vscode-foreground); +} + +.am-pr-panel-description [data-component="markdown"] h1, +.am-pr-panel-description [data-component="markdown"] h2, +.am-pr-panel-description [data-component="markdown"] h3, +.am-pr-panel-description [data-component="markdown"] h4, +.am-pr-panel-description [data-component="markdown"] h5, +.am-pr-panel-description [data-component="markdown"] h6 { + font-weight: 600; + color: var(--vscode-foreground); + margin-top: 0.75em; + margin-bottom: 0.25em; +} + +.am-pr-panel-description [data-component="markdown"] h1 { font-size: var(--kilo-font-size-16); } +.am-pr-panel-description [data-component="markdown"] h2 { font-size: var(--kilo-font-size-14); } +.am-pr-panel-description [data-component="markdown"] h3 { font-size: var(--kilo-font-size-13); } + /* PR Reviewers */ .am-pr-panel-reviewers { display: flex; @@ -5042,13 +5141,13 @@ body.vscode-high-contrast-light { } .am-pr-panel-reviewer[data-state="approved"] .am-pr-reviewer-icon { color: #34d399; } -.am-pr-panel-reviewer[data-state="changes_requested"] .am-pr-reviewer-icon { color: #f87171; } +.am-pr-panel-reviewer[data-state="changes_requested"] .am-pr-reviewer-icon { color: #fbbf24; } .am-pr-panel-reviewer[data-state="commented"] .am-pr-reviewer-icon { color: var(--text-weak); } .am-pr-panel-reviewer[data-state="pending"] .am-pr-reviewer-icon { color: var(--text-weaker); } .am-pr-reviewer-login { flex: 1; - color: var(--text-base); + color: var(--vscode-foreground); } .am-pr-reviewer-state { @@ -5083,7 +5182,7 @@ body.vscode-high-contrast-light { .am-pr-check-name { flex: 1; - color: var(--text-base); + color: var(--vscode-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 4f11b99d7c2..db195e4ad4c 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -973,6 +973,7 @@ export interface OpenPRMessage { type: "agentManager.openPR" projectId?: string worktreeId: string + url?: string } export interface ApplyWorktreeDiffMessage { From eb74496b3b55683e0ead9326c42aa74493dcee6c Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 3 Aug 2026 13:27:26 -0700 Subject: [PATCH 03/54] feat(pr-viewer): Update styles section headers --- .../agent-manager/AgentManagerApp.tsx | 24 ++- .../webview-ui/agent-manager/PRPanel.tsx | 203 +++++++++++------- .../agent-manager/agent-manager.css | 49 ++++- packages/ui/src/components/icon.tsx | 1 + 4 files changed, 186 insertions(+), 91 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index d00471b8bd6..25c22878f9b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -310,6 +310,13 @@ const AgentManagerContent: Component = () => { const [sidePanel, setSidePanel] = createSignal(null) const diffOpen = () => sidePanel() === "diff" const prOpen = () => sidePanel() === "pr" + const activePR = createMemo(() => { + const selected = selection() + if (!selected || selected === LOCAL) return undefined + const pr = prStatuses()[selected] + if (!pr) return undefined + return { pr, selected, wt: worktrees().find((w) => w.id === selected) } + }) const diffs = createWorktreeDiffs(vscode) const diffDatas = diffs.diffDatas const diffLoading = diffs.diffLoading @@ -2456,11 +2463,7 @@ const AgentManagerContent: Component = () => { reviewActive={reviewActive} onToggleDiff={toggleDiffPanel} onToggleReview={metrics.click("fullscreen_review", "tab_toolbar", toggleReviewTab)} - prStatus={() => { - const sel = selection() - if (!sel || sel === LOCAL) return undefined - return prStatuses()[sel] - }} + prStatus={() => activePR()?.pr} prOpen={prOpen} onTogglePR={togglePRPanel} terminalDestination={sideCtl.destination} @@ -2701,16 +2704,15 @@ const AgentManagerContent: Component = () => { activeTerminalId={terms.activeId()} /> - + {(() => { - const sel = selection() - const pr = sel && sel !== LOCAL ? prStatuses()[sel] : undefined - if (!pr) return null + const data = activePR()! return ( setSidePanel(null)} - onOpenExternal={() => vscode.postMessage({ type: "agentManager.openPR", worktreeId: sel as string, url: pr.url })} + onOpenExternal={() => vscode.postMessage({ type: "agentManager.openPR", worktreeId: data.selected, url: data.pr.url })} /> ) })()} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/PRPanel.tsx index 6bb89ddb585..e97cfca42b7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/PRPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/PRPanel.tsx @@ -1,15 +1,16 @@ /** @jsxImportSource solid-js */ -import { Component, For, Show } from "solid-js" +import { Component, For, Show, createSignal } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { Markdown } from "@kilocode/kilo-ui/markdown" import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" -import type { PRStatus, CheckStatus, PRReviewer } from "../src/types/messages" +import type { PRStatus, CheckStatus, PRReviewer, WorktreeState } from "../src/types/messages" import { prAccentColor, prBadgeIndicator, prChecksRunning } from "./WorktreeItem" interface PRPanelProps { pr: PRStatus + worktree?: WorktreeState onClose: () => void onOpenExternal: () => void } @@ -29,9 +30,9 @@ const REVIEW_LABEL: Partial, string>> = { const CHECK_ICON: Record = { success: "circle-check", - failure: "circle-x", - cancelled: "circle-x", - skipped: "circle-x", + failure: "circle-x-outline", + cancelled: "circle-x-outline", + skipped: "circle-x-outline", pending: "play", } @@ -57,6 +58,26 @@ const REVIEWER_STATE_LABEL: Record = { pending: "Awaiting", } +function SectionHeading(props: { + title: string + open: boolean + onToggle: () => void + count?: string + countClass?: string +}) { + return ( + + ) +} + export const PRPanel: Component = (props) => ( @@ -68,6 +89,11 @@ const PRPanelInner: Component = (props) => { const indicator = () => prBadgeIndicator(props.pr) const pulsing = () => prChecksRunning(props.pr) + const [reviewersOpen, setReviewersOpen] = createSignal(true) + const [descOpen, setDescOpen] = createSignal(true) + const [checksOpen, setChecksOpen] = createSignal(true) + const [commentsOpen, setCommentsOpen] = createSignal(true) + return (
@@ -100,9 +126,20 @@ const PRPanelInner: Component = (props) => {
- {/* State + review */} + {/* Overview: branch→base + status */}
-
Overview
+ + {(wt) => ( +
+ Branch + + {wt().branch} + + {wt().parentBranch} + +
+ )} +
Status {STATE_LABEL[props.pr.state]} @@ -121,18 +158,20 @@ const PRPanelInner: Component = (props) => { 0}>
-
Reviewers
-
- - {(reviewer) => ( -
- - - {REVIEWER_STATE_LABEL[reviewer.state]} -
- )} -
-
+ setReviewersOpen((v) => !v)} /> + +
+ + {(reviewer) => ( +
+ + + {REVIEWER_STATE_LABEL[reviewer.state]} +
+ )} +
+
+
@@ -140,14 +179,16 @@ const PRPanelInner: Component = (props) => {
-
Description
-
- -
+ setDescOpen((v) => !v)} /> + +
+ +
+
- {/* Diff stats */} + {/* File Changes */} 0 || props.pr.additions > 0 || props.pr.deletions > 0}>
@@ -172,31 +213,34 @@ const PRPanelInner: Component = (props) => { 0}>
-
- Checks - - {props.pr.checks.passed}/{props.pr.checks.total} passed - -
-
- - {(check) => ( -
- - {check.name} - {CHECK_LABEL[check.status]} - - {check.duration} - - - e.preventDefault()}> - - - -
- )} -
-
+ setChecksOpen((v) => !v)} + count={`${props.pr.checks.passed}/${props.pr.checks.total} passed`} + countClass={`am-pr-checks-count-${props.pr.checks.status}`} + /> + +
+ + {(check) => ( +
+ + {check.name} + {CHECK_LABEL[check.status]} + + {check.duration} + + + e.preventDefault()}> + + + +
+ )} +
+
+
@@ -204,37 +248,38 @@ const PRPanelInner: Component = (props) => { 0}>
-
- Comments - 0}> - - {props.pr.comments!.unresolved} unresolved - - -
-
- - {(comment) => ( -
-
- {comment.author} - - - {comment.file} - {`:${comment.line}`} - - - - Resolved - + setCommentsOpen((v) => !v)} + count={props.pr.comments!.unresolved > 0 ? `${props.pr.comments!.unresolved} unresolved` : undefined} + countClass="am-pr-panel-unresolved" + /> + +
+ + {(comment) => ( +
+
+ {comment.author} + + + {comment.file} + {`:${comment.line}`} + + + + Resolved + +
+
+ +
-
- -
-
- )} - -
+ )} + +
+
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index e4c19fe49d7..21305369f5b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -4980,6 +4980,54 @@ body.vscode-high-contrast-light { padding-bottom: 6px; } +.am-pr-panel-section-toggle { + width: 100%; + background: none; + border: none; + cursor: pointer; + padding: 0; + text-align: left; + border-radius: 3px; +} + +.am-pr-panel-section-toggle:hover { + color: var(--vscode-foreground); +} + +.am-pr-panel-section-heading-left { + display: flex; + align-items: center; + gap: 4px; +} + +.am-pr-section-chevron { + flex-shrink: 0; + opacity: 0.6; +} + +/* Branch → base row */ +.am-pr-panel-branch { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.am-pr-branch-name { + font-family: var(--font-mono, monospace); + font-size: var(--kilo-font-size-11); +} + +.am-pr-branch-arrow { + opacity: 0.5; + flex-shrink: 0; +} + +/* Check count color by status */ +.am-pr-checks-count-success { color: #34d399; } +.am-pr-checks-count-failure { color: #f87171; } +.am-pr-checks-count-pending { color: #fbbf24; } + .am-pr-panel-section-count { font-size: var(--kilo-font-size-11); font-weight: 400; @@ -5032,7 +5080,6 @@ body.vscode-high-contrast-light { align-items: center; gap: 6px; } - .am-pr-panel-unresolved { color: #fbbf24; } diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index bdf68e12228..e70de73a831 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -18,6 +18,7 @@ const icons = { "chevron-grabber-vertical": ``, "chevron-double-right": ``, "circle-x": ``, + "circle-x-outline": ``, close: ``, "close-small": ``, checklist: ``, From 291ac0887e4874dd4904e582118ebb3aa22edb87 Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 3 Aug 2026 13:38:21 -0700 Subject: [PATCH 04/54] fix(pr-view): Move toggle pr panel function --- .../webview-ui/agent-manager/AgentManagerApp.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 25c22878f9b..a49531d7994 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -359,7 +359,6 @@ const AgentManagerContent: Component = () => { // Per-worktree git stats (diff additions/deletions, commits missing from origin) const worktreeStats = () => registry.active().worktreeStats() - // Per-worktree PR status data const prStatuses = () => registry.active().prStatuses() const runStatuses = () => registry.active().runStatuses() @@ -558,6 +557,12 @@ const AgentManagerContent: Component = () => { } const openWindow = metrics.click("open_worktree_window", "tab_toolbar", openWorktreeDirectory) + const togglePRPanel = () => { + setHistory(false) + setReviewActive(false) + setSidePanel((prev) => (prev === "pr" ? null : "pr")) + } + const openSelectedPR = () => { const sel = selection() if (!sel || sel === LOCAL || !prStatuses()[sel]) return @@ -565,12 +570,6 @@ const AgentManagerContent: Component = () => { togglePRPanel() } - const togglePRPanel = () => { - setHistory(false) - setReviewActive(false) - setSidePanel((prev) => (prev === "pr" ? null : "pr")) - } - const runWorktree = (id: string, destination: TerminalDestination) => { const state = runStatuses()[id]?.state ?? "idle" if (state === "running" || state === "stopping") { From 8663e5baf6a7a5f5ff60ad925cae3bc5702854f7 Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 3 Aug 2026 14:09:02 -0700 Subject: [PATCH 05/54] fix(pr-view): Organizing files and components, and improving types --- .../src/agent-manager/PRStatusPoller.ts | 28 +- .../kilo-vscode/src/agent-manager/types.ts | 6 +- .../agent-manager/AgentManagerApp.tsx | 2 +- .../webview-ui/agent-manager/PRPanel.tsx | 288 ------------- .../webview-ui/agent-manager/TabBar.tsx | 2 +- .../webview-ui/agent-manager/WorktreeItem.tsx | 23 +- .../agent-manager/agent-manager.css | 390 +----------------- .../webview-ui/agent-manager/pr/PRBadge.tsx | 30 ++ .../webview-ui/agent-manager/pr/PRChecks.tsx | 59 +++ .../agent-manager/pr/PRComments.tsx | 48 +++ .../agent-manager/pr/PRDescription.tsx | 21 + .../agent-manager/pr/PRFileChanges.tsx | 20 + .../agent-manager/pr/PROverview.tsx | 49 +++ .../webview-ui/agent-manager/pr/PRPanel.tsx | 61 +++ .../agent-manager/pr/PRReviewers.tsx | 44 ++ .../agent-manager/pr/SectionHeading.tsx | 23 ++ .../webview-ui/agent-manager/pr/pr-panel.css | 343 +++++++++++++++ .../webview-ui/agent-manager/pr/pr-types.ts | 32 ++ .../src/stories/agent-manager.stories.tsx | 2 +- .../src/stories/section-header.stories.tsx | 14 +- .../src/types/messages/agent-manager.ts | 39 +- 21 files changed, 793 insertions(+), 731 deletions(-) delete mode 100644 packages/kilo-vscode/webview-ui/agent-manager/PRPanel.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/pr/PRDescription.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/pr/PRFileChanges.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/pr/PROverview.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/pr/PRReviewers.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/pr/SectionHeading.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index c45cd992beb..01875607f00 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -249,7 +249,9 @@ export class PRStatusPoller { state: pr.state, review: pr.review, checks, - ...(comments && { comments, reviewers: comments.reviewers }), + ...(comments && { + comments: { total: comments.total, unresolved: comments.unresolved, comments: comments.comments, reviewers: comments.reviewers }, + }), additions: pr.additions, deletions: pr.deletions, files: pr.files, @@ -367,7 +369,7 @@ export class PRStatusPoller { passed: number failed: number pending: number - items: PRCheck[] + checks: PRCheck[] }> { try { const { stdout } = await this.shell( @@ -383,24 +385,24 @@ export class PRStatusPoller { completedAt?: string }> - const items: PRCheck[] = data.map((c) => ({ + const checks: PRCheck[] = data.map((c) => ({ name: c.name, status: mapCheckStatus(c.state), url: c.link, duration: formatCheckDuration(c.startedAt, c.completedAt), })) - const total = items.length - const passed = items.filter((c) => c.status === "success").length - const failed = items.filter((c) => c.status === "failure").length - const pending = items.filter((c) => c.status === "pending").length + const total = checks.length + const passed = checks.filter((c) => c.status === "success").length + const failed = checks.filter((c) => c.status === "failure").length + const pending = checks.filter((c) => c.status === "pending").length const status: AggregateCheckStatus = total === 0 ? "none" : failed > 0 ? "failure" : pending > 0 ? "pending" : "success" - return { status, total, passed, failed, pending, items } + return { status, total, passed, failed, pending, checks } } catch { - return { status: "none", total: 0, passed: 0, failed: 0, pending: 0, items: [] } + return { status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] } } } @@ -421,7 +423,7 @@ export class PRStatusPoller { private async fetchComments( prNumber: number, cwd: string, - ): Promise<{ total: number; unresolved: number; items: PRComment[]; reviewers: PRReviewer[] }> { + ): Promise<{ total: number; unresolved: number; comments: PRComment[]; reviewers: PRReviewer[] }> { try { const repo = await this.getRepoInfo(cwd) const query = `query($owner: String!, $repo: String!, $number: Int!) { @@ -458,12 +460,12 @@ export class PRStatusPoller { { cwd, timeout: 15_000 }, ) const pr = JSON.parse(stdout)?.data?.repository?.pullRequest - const items = parseComments(pr?.reviewThreads?.nodes ?? []) + const comments = parseComments(pr?.reviewThreads?.nodes ?? []) const reviewers = parseReviewers(pr?.reviewRequests?.nodes ?? [], pr?.reviews?.nodes ?? []) - return { total: items.length, unresolved: items.filter((c) => !c.resolved).length, items, reviewers } + return { total: comments.length, unresolved: comments.filter((c) => !c.resolved).length, comments, reviewers } } catch (err) { this.options.log("Failed to fetch PR comments:", err) - return { total: 0, unresolved: 0, items: [], reviewers: [] } + return { total: 0, unresolved: 0, comments: [], reviewers: [] } } } } diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index cb2f761ed10..38bf209d547 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -90,14 +90,14 @@ export interface PRStatus { passed: number failed: number pending: number - items: PRCheck[] + checks: PRCheck[] } comments?: { total: number unresolved: number - items: PRComment[] + comments: PRComment[] + reviewers: PRReviewer[] } - reviewers?: PRReviewer[] additions: number deletions: number files: number diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index a49531d7994..ea77a6c64b8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -141,7 +141,7 @@ import { import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering" import { useTabScroll } from "./tab-scroll" import { DiffPanel } from "./DiffPanel" -import { PRPanel } from "./PRPanel" +import { PRPanel } from "./pr/PRPanel" import { createRevertFile } from "./revert-file" import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView" import { createApplyToLocal } from "./apply-to-local" diff --git a/packages/kilo-vscode/webview-ui/agent-manager/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/PRPanel.tsx deleted file mode 100644 index e97cfca42b7..00000000000 --- a/packages/kilo-vscode/webview-ui/agent-manager/PRPanel.tsx +++ /dev/null @@ -1,288 +0,0 @@ -/** @jsxImportSource solid-js */ -import { Component, For, Show, createSignal } from "solid-js" -import { Icon } from "@kilocode/kilo-ui/icon" -import { IconButton } from "@kilocode/kilo-ui/icon-button" -import { Tooltip } from "@kilocode/kilo-ui/tooltip" -import { Markdown } from "@kilocode/kilo-ui/markdown" -import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" -import type { PRStatus, CheckStatus, PRReviewer, WorktreeState } from "../src/types/messages" -import { prAccentColor, prBadgeIndicator, prChecksRunning } from "./WorktreeItem" - -interface PRPanelProps { - pr: PRStatus - worktree?: WorktreeState - onClose: () => void - onOpenExternal: () => void -} - -const STATE_LABEL: Record = { - open: "Open", - draft: "Draft", - merged: "Merged", - closed: "Closed", -} - -const REVIEW_LABEL: Partial, string>> = { - approved: "Approved", - changes_requested: "Changes Requested", - pending: "Review Pending", -} - -const CHECK_ICON: Record = { - success: "circle-check", - failure: "circle-x-outline", - cancelled: "circle-x-outline", - skipped: "circle-x-outline", - pending: "play", -} - -const CHECK_LABEL: Record = { - success: "Passed", - failure: "Failed", - pending: "Running", - skipped: "Skipped", - cancelled: "Cancelled", -} - -const REVIEWER_STATE_ICON: Record = { - approved: "circle-check", - changes_requested: "refresh", - commented: "edit", - pending: "dash", -} - -const REVIEWER_STATE_LABEL: Record = { - approved: "Approved", - changes_requested: "Changes requested", - commented: "Commented", - pending: "Awaiting", -} - -function SectionHeading(props: { - title: string - open: boolean - onToggle: () => void - count?: string - countClass?: string -}) { - return ( - - ) -} - -export const PRPanel: Component = (props) => ( - - - -) - -const PRPanelInner: Component = (props) => { - const accent = () => prAccentColor(props.pr) - const indicator = () => prBadgeIndicator(props.pr) - const pulsing = () => prChecksRunning(props.pr) - - const [reviewersOpen, setReviewersOpen] = createSignal(true) - const [descOpen, setDescOpen] = createSignal(true) - const [checksOpen, setChecksOpen] = createSignal(true) - const [commentsOpen, setCommentsOpen] = createSignal(true) - - return ( -
-
-
- - - #{props.pr.number} - - {props.pr.title} -
-
- - - - - - -
-
- -
- {/* Overview: branch→base + status */} -
- - {(wt) => ( -
- Branch - - {wt().branch} - - {wt().parentBranch} - -
- )} -
-
- Status - {STATE_LABEL[props.pr.state]} -
- - {(review) => ( -
- Review - {REVIEW_LABEL[review()]} -
- )} -
-
- - {/* Reviewers */} - 0}> -
-
- setReviewersOpen((v) => !v)} /> - -
- - {(reviewer) => ( -
- - - {REVIEWER_STATE_LABEL[reviewer.state]} -
- )} -
-
-
-
- - - {/* Description */} - -
-
- setDescOpen((v) => !v)} /> - -
- -
-
-
- - - {/* File Changes */} - 0 || props.pr.additions > 0 || props.pr.deletions > 0}> -
-
-
- File Changes - - 0}> - {props.pr.files}f - - 0}> - +{props.pr.additions} - - 0}> - −{props.pr.deletions} - - -
-
- - - {/* Checks */} - 0}> -
-
- setChecksOpen((v) => !v)} - count={`${props.pr.checks.passed}/${props.pr.checks.total} passed`} - countClass={`am-pr-checks-count-${props.pr.checks.status}`} - /> - -
- - {(check) => ( -
- - {check.name} - {CHECK_LABEL[check.status]} - - {check.duration} - - - e.preventDefault()}> - - - -
- )} -
-
-
-
- - - {/* Comments */} - 0}> -
-
- setCommentsOpen((v) => !v)} - count={props.pr.comments!.unresolved > 0 ? `${props.pr.comments!.unresolved} unresolved` : undefined} - countClass="am-pr-panel-unresolved" - /> - -
- - {(comment) => ( -
-
- {comment.author} - - - {comment.file} - {`:${comment.line}`} - - - - Resolved - -
-
- -
-
- )} -
-
-
-
- -
-
- ) -} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx index f3c1ddda1c3..ab0a888f30e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx @@ -55,7 +55,7 @@ export interface TabBarProps { reviewActive: () => boolean onToggleDiff: () => void onToggleReview: () => void - prStatus: () => PRStatus | null | undefined + prStatus: () => PRStatus | undefined prOpen: () => boolean onTogglePR: () => void terminalDestination: () => TerminalDestination diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx index a7fc15a6cf4..3af617b8599 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx @@ -10,7 +10,8 @@ import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import { HoverCard } from "@kilocode/kilo-ui/hover-card" import { ContextMenu } from "@kilocode/kilo-ui/context-menu" import { Button } from "@kilocode/kilo-ui/button" -import type { WorktreeState, WorktreeGitStats, PRStatus, SectionState, RunStatus } from "../src/types/messages" +import type { WorktreeState, WorktreeGitStats, SectionState, RunStatus } from "../src/types/messages" +import type { PRStatus } from "../src/types/messages" import { colorCss } from "./section-colors" import { useLanguage } from "../src/context/language" import { formatRelativeDate } from "../src/utils/date" @@ -57,7 +58,7 @@ interface WorktreeItemProps { /** Keybinding string for the open-in-vscode action. */ openKeybind: string /** PR status for this worktree's branch, or null if no PR. */ - pr?: PRStatus | null + pr?: PRStatus runStatus?: RunStatus /** Callback when the PR badge is clicked. */ onOpenPR?: () => void @@ -94,12 +95,13 @@ const hasStats = (s: WorktreeGitStats | undefined): s is WorktreeGitStats => * and review results are conveyed by a separate status icon (see prBadgeIndicator) * so a failing check is not mistaken for a closed PR. */ -export function prAccentColor(pr: PRStatus): string { - if (pr.state === "draft") return "var(--text-weaker)" - if (pr.state === "merged") return "#a78bfa" - if (pr.state === "closed") return "#f87171" - if (pr.checks.status === "pending") return "#fbbf24" - return "#34d399" +/** Derive the badge accent key used as `data-accent` for CSS color styling. */ +export function prBadgeAccent(pr: PRStatus): "open" | "pending" | "draft" | "merged" | "closed" { + if (pr.state === "draft") return "draft" + if (pr.state === "merged") return "merged" + if (pr.state === "closed") return "closed" + if (pr.checks.status === "pending") return "pending" + return "open" } /** True while an open PR's checks are still running — drives the pulsing amber badge. */ @@ -349,13 +351,12 @@ export const WorktreeItem: Component = (props) => { } > {(pr) => { - const accent = () => prAccentColor(pr()) const indicator = () => prBadgeIndicator(pr()) return ( }> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 21305369f5b..4f2958ab18b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -846,7 +846,18 @@ button.am-section-toggle:hover .am-section-label { } /* PR pill badge — second row inside am-wt-row2. - Uses --pr-accent custom property (set inline) to derive all colors. */ + Uses data-accent attribute to derive all colors via CSS variables. */ +.am-pr-badge[data-accent="open"], +.am-pr-panel-badge[data-accent="open"] { --pr-accent: var(--vscode-testing-iconPassed, #34d399); } +.am-pr-badge[data-accent="pending"], +.am-pr-panel-badge[data-accent="pending"] { --pr-accent: var(--vscode-charts-yellow, #fbbf24); } +.am-pr-badge[data-accent="draft"], +.am-pr-panel-badge[data-accent="draft"] { --pr-accent: var(--text-weaker); } +.am-pr-badge[data-accent="merged"], +.am-pr-panel-badge[data-accent="merged"] { --pr-accent: var(--vscode-charts-purple, #a78bfa); } +.am-pr-badge[data-accent="closed"], +.am-pr-panel-badge[data-accent="closed"] { --pr-accent: var(--vscode-testing-iconFailed, #f87171); } + .am-pr-badge { display: inline-flex; align-items: center; @@ -881,17 +892,17 @@ button.am-section-toggle:hover .am-section-label { /* Status icon overlaid on the badge — colored by CI/review result, independent of the badge's PR-state accent color. */ .am-pr-badge-status[data-status="failure"] { - color: #ef4444; + color: var(--vscode-testing-iconFailed, #f87171); } .am-pr-badge-status[data-status="changes"] { - color: #fbbf24; + color: var(--vscode-charts-yellow, #fbbf24); } .am-pr-badge-status[data-status="approved"] { - color: #34d399; + color: var(--vscode-testing-iconPassed, #34d399); } /* Checks still running — pulse the whole amber badge. */ -.am-pr-badge[data-pending] { +.am-pr-badge.am-pr-badge-pending { animation: am-pr-pulse 1.5s ease-in-out infinite; } @keyframes am-pr-pulse { @@ -905,7 +916,7 @@ button.am-section-toggle:hover .am-section-label { } @media (prefers-reduced-motion: reduce) { - .am-pr-badge[data-pending] { + .am-pr-badge.am-pr-badge-pending { animation: none; opacity: 0.9; } @@ -4897,370 +4908,3 @@ body.vscode-high-contrast-light { .am-terminal-host [class~="xterm-viewport"] { background: transparent !important; } - -/* PR Panel */ -.am-pr-panel { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; -} - -.am-pr-panel-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 8px; - padding: 10px 12px 8px; - border-bottom: 1px solid var(--vscode-panel-border); - flex-shrink: 0; -} - -.am-pr-panel-title-row { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.am-pr-panel-badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 6px 2px 4px; - border-radius: 4px; - font-size: var(--kilo-font-size-10); - font-variant-numeric: tabular-nums; - background: color-mix(in srgb, var(--pr-accent, #34d399) 15%, transparent); - color: var(--pr-accent, #34d399); - border: 1px solid color-mix(in srgb, var(--pr-accent, #34d399) 40%, transparent); - white-space: nowrap; - flex-shrink: 0; -} - -.am-pr-panel-badge[data-pending] { - animation: am-pr-badge-pulse 1.5s ease-in-out infinite; -} - -.am-pr-panel-title { - font-size: var(--kilo-font-size-14); - color: var(--vscode-foreground); - font-weight: 500; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.am-pr-panel-actions { - display: flex; - align-items: center; - gap: 2px; - flex-shrink: 0; -} - -.am-pr-panel-body { - flex: 1; - overflow-y: auto; - padding: 8px 0; -} - -.am-pr-panel-section { - padding: 4px 12px; -} - -.am-pr-panel-section-heading { - display: flex; - align-items: center; - justify-content: space-between; - font-size: var(--font-size-small); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--text-weak); - padding-bottom: 6px; -} - -.am-pr-panel-section-toggle { - width: 100%; - background: none; - border: none; - cursor: pointer; - padding: 0; - text-align: left; - border-radius: 3px; -} - -.am-pr-panel-section-toggle:hover { - color: var(--vscode-foreground); -} - -.am-pr-panel-section-heading-left { - display: flex; - align-items: center; - gap: 4px; -} - -.am-pr-section-chevron { - flex-shrink: 0; - opacity: 0.6; -} - -/* Branch → base row */ -.am-pr-panel-branch { - display: flex; - align-items: center; - gap: 4px; - flex-wrap: wrap; -} - -.am-pr-branch-name { - font-family: var(--font-mono, monospace); - font-size: var(--kilo-font-size-11); -} - -.am-pr-branch-arrow { - opacity: 0.5; - flex-shrink: 0; -} - -/* Check count color by status */ -.am-pr-checks-count-success { color: #34d399; } -.am-pr-checks-count-failure { color: #f87171; } -.am-pr-checks-count-pending { color: #fbbf24; } - -.am-pr-panel-section-count { - font-size: var(--kilo-font-size-11); - font-weight: 400; - text-transform: none; - letter-spacing: 0; - color: var(--text-weak); -} - -.am-pr-panel-divider { - height: 1px; - background: var(--vscode-panel-border); - margin: 6px 0; -} - -.am-pr-panel-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 3px 0; - min-height: 22px; -} - -.am-pr-panel-label { - font-size: var(--kilo-font-size-11); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-weaker); - flex-shrink: 0; -} - -.am-pr-panel-value { - font-size: var(--kilo-font-size-12); - color: var(--vscode-foreground); - text-align: right; -} - -.am-pr-panel-value[data-pr-state="open"] { color: #34d399; } -.am-pr-panel-value[data-pr-state="merged"] { color: #a78bfa; } -.am-pr-panel-value[data-pr-state="closed"] { color: #f87171; } -.am-pr-panel-value[data-pr-state="draft"] { color: var(--text-weaker); } - -.am-pr-panel-value[data-checks="failure"] { color: #f87171; } -.am-pr-panel-value[data-checks="pending"] { color: #fbbf24; } -.am-pr-panel-value[data-checks="success"] { color: #34d399; } - -.am-pr-panel-diff { - display: flex; - align-items: center; - gap: 6px; -} -.am-pr-panel-unresolved { - color: #fbbf24; -} - -/* PR comment list */ -.am-pr-panel-comment-list { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 6px; -} - -.am-pr-panel-comment { - border: 1px solid var(--vscode-panel-border); - border-radius: 4px; - padding: 8px; - opacity: 1; -} - -.am-pr-panel-comment-resolved { - opacity: 0.5; -} - -.am-pr-panel-comment-header { - display: flex; - align-items: center; - gap: 6px; - margin-bottom: 4px; - flex-wrap: wrap; -} - -.am-pr-panel-comment-author { - font-size: var(--kilo-font-size-12); - font-weight: 600; - color: var(--vscode-foreground); -} - -.am-pr-panel-comment-file { - font-size: var(--kilo-font-size-11); - color: var(--text-weak); - font-family: var(--font-mono, monospace); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 180px; -} - -.am-pr-panel-comment-resolved-badge { - font-size: var(--kilo-font-size-10); - color: var(--text-weaker); - border: 1px solid var(--vscode-panel-border); - border-radius: 3px; - padding: 1px 4px; - margin-left: auto; -} - -.am-pr-panel-comment-body [data-component="markdown"] { - font-size: var(--kilo-font-size-12); - color: var(--vscode-foreground); -} - -.am-pr-panel-description { - line-height: 1.5; - padding: 2px 0 4px; -} - -.am-pr-panel-description [data-component="markdown"], -.am-pr-panel-comment-body [data-component="markdown"] { - font-size: var(--kilo-font-size-13); - color: var(--vscode-foreground); -} - -.am-pr-panel-description [data-component="markdown"] h1, -.am-pr-panel-description [data-component="markdown"] h2, -.am-pr-panel-description [data-component="markdown"] h3, -.am-pr-panel-description [data-component="markdown"] h4, -.am-pr-panel-description [data-component="markdown"] h5, -.am-pr-panel-description [data-component="markdown"] h6 { - font-weight: 600; - color: var(--vscode-foreground); - margin-top: 0.75em; - margin-bottom: 0.25em; -} - -.am-pr-panel-description [data-component="markdown"] h1 { font-size: var(--kilo-font-size-16); } -.am-pr-panel-description [data-component="markdown"] h2 { font-size: var(--kilo-font-size-14); } -.am-pr-panel-description [data-component="markdown"] h3 { font-size: var(--kilo-font-size-13); } - -/* PR Reviewers */ -.am-pr-panel-reviewers { - display: flex; - flex-direction: column; - gap: 2px; -} - -.am-pr-panel-reviewer { - display: flex; - align-items: center; - gap: 6px; - padding: 3px 0; - font-size: var(--kilo-font-size-12); -} - -.am-pr-reviewer-icon { - flex-shrink: 0; -} - -.am-pr-panel-reviewer[data-state="approved"] .am-pr-reviewer-icon { color: #34d399; } -.am-pr-panel-reviewer[data-state="changes_requested"] .am-pr-reviewer-icon { color: #fbbf24; } -.am-pr-panel-reviewer[data-state="commented"] .am-pr-reviewer-icon { color: var(--text-weak); } -.am-pr-panel-reviewer[data-state="pending"] .am-pr-reviewer-icon { color: var(--text-weaker); } - -.am-pr-reviewer-login { - flex: 1; - color: var(--vscode-foreground); -} - -.am-pr-reviewer-state { - color: var(--text-weak); - flex-shrink: 0; -} - -.am-pr-panel-checks { - display: flex; - flex-direction: column; - gap: 2px; - margin-top: 4px; -} - -.am-pr-panel-check-item { - display: flex; - align-items: center; - gap: 6px; - padding: 3px 0; - font-size: var(--kilo-font-size-12); -} - -.am-pr-check-icon { - flex-shrink: 0; -} - -.am-pr-panel-check-item[data-status="success"] .am-pr-check-icon { color: #34d399; } -.am-pr-panel-check-item[data-status="failure"] .am-pr-check-icon { color: #f87171; } -.am-pr-panel-check-item[data-status="cancelled"] .am-pr-check-icon { color: var(--text-weaker); } -.am-pr-panel-check-item[data-status="skipped"] .am-pr-check-icon { color: var(--text-weaker); } -.am-pr-panel-check-item[data-status="pending"] .am-pr-check-icon { color: #fbbf24; } - -.am-pr-check-name { - flex: 1; - color: var(--vscode-foreground); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.am-pr-check-status { - color: var(--text-weak); - flex-shrink: 0; -} - -.am-pr-check-duration { - color: var(--text-weaker); - font-size: var(--kilo-font-size-10); - flex-shrink: 0; -} - -.am-pr-check-link { - color: var(--text-weak); - display: flex; - align-items: center; - flex-shrink: 0; - cursor: pointer; - text-decoration: none; -} - -.am-pr-check-link:hover { - color: var(--text-base); -} - -/* PR toggle button in tab bar */ -.am-pr-toggle-number { - font-size: var(--kilo-font-size-10); - font-variant-numeric: tabular-nums; -} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx new file mode 100644 index 00000000000..b80b9495152 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx @@ -0,0 +1,30 @@ +/** @jsxImportSource solid-js */ +import { Icon } from "@kilocode/kilo-ui/icon" +import type { PRStatus } from "../../src/types/messages" +import { prBadgeAccent, prBadgeIndicator, prChecksRunning } from "../WorktreeItem" + +const INDICATOR_ICON: Record = { + failure: "circle-x", + changes: "warning", + approved: "circle-check", + none: "branch", +} + +export function PRBadge(props: { pr: PRStatus }) { + const indicator = () => prBadgeIndicator(props.pr) + return ( + + + #{props.pr.number} + + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx new file mode 100644 index 00000000000..e76a760b275 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx @@ -0,0 +1,59 @@ +/** @jsxImportSource solid-js */ +import { For, Show, createSignal } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import type { PRStatus } from "../../src/types/messages" +import type { PRCheck, CheckStatus } from "./pr-types" +import { SectionHeading } from "./SectionHeading" + +const CHECK_ICON: Record = { + success: "circle-check", + failure: "circle-x-outline", + cancelled: "circle-x-outline", + skipped: "circle-x-outline", + pending: "play", +} + +const CHECK_LABEL: Record = { + success: "Passed", + failure: "Failed", + pending: "Running", + skipped: "Skipped", + cancelled: "Cancelled", +} + +export function PRChecks(props: { checks: PRStatus["checks"] }) { + const [open, setOpen] = createSignal(true) + return ( + <> +
+
+ setOpen((v) => !v)} + count={`${props.checks.passed}/${props.checks.total} passed`} + countClass={`am-pr-checks-count-${props.checks.status}`} + /> + +
+ + {(check: PRCheck) => ( +
+ + {check.name} + {CHECK_LABEL[check.status]} + {check.duration} + + e.preventDefault()}> + + + +
+ )} +
+
+
+
+ + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx new file mode 100644 index 00000000000..d3f3057e6c0 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx @@ -0,0 +1,48 @@ +/** @jsxImportSource solid-js */ +import { For, Show, createSignal } from "solid-js" +import { Markdown } from "@kilocode/kilo-ui/markdown" +import type { PRStatus } from "../../src/types/messages" +import type { PRComment } from "./pr-types" +import { SectionHeading } from "./SectionHeading" + +export function PRComments(props: { comments: NonNullable }) { + const [open, setOpen] = createSignal(true) + return ( + <> +
+
+ setOpen((v) => !v)} + count={props.comments.unresolved > 0 ? `${props.comments.unresolved} unresolved` : undefined} + countClass="am-pr-panel-unresolved" + /> + +
+ + {(comment: PRComment) => ( +
+
+ {comment.author} + + + {comment.file}{`:${comment.line}`} + + + + Resolved + +
+
+ +
+
+ )} +
+
+
+
+ + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRDescription.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRDescription.tsx new file mode 100644 index 00000000000..524be50e2ec --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRDescription.tsx @@ -0,0 +1,21 @@ +/** @jsxImportSource solid-js */ +import { Show, createSignal } from "solid-js" +import { Markdown } from "@kilocode/kilo-ui/markdown" +import { SectionHeading } from "./SectionHeading" + +export function PRDescription(props: { body: string }) { + const [open, setOpen] = createSignal(true) + return ( + <> +
+
+ setOpen((v) => !v)} /> + +
+ +
+
+
+ + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRFileChanges.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRFileChanges.tsx new file mode 100644 index 00000000000..b4e558d67b0 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRFileChanges.tsx @@ -0,0 +1,20 @@ +/** @jsxImportSource solid-js */ +import { Show } from "solid-js" + +export function PRFileChanges(props: { files: number; additions: number; deletions: number }) { + return ( + <> +
+
+
+ File Changes + + 0}>{props.files}f + 0}>+{props.additions} + 0}>−{props.deletions} + +
+
+ + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PROverview.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PROverview.tsx new file mode 100644 index 00000000000..6c1fc863739 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PROverview.tsx @@ -0,0 +1,49 @@ +/** @jsxImportSource solid-js */ +import { Show } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import type { WorktreeState } from "../../src/types/messages" +import type { PRStatus } from "../../src/types/messages" + +const STATE_LABEL: Record = { + open: "Open", + draft: "Draft", + merged: "Merged", + closed: "Closed", +} + +const REVIEW_LABEL: Partial, string>> = { + approved: "Approved", + changes_requested: "Changes Requested", + pending: "Review Pending", +} + +export function PROverview(props: { pr: PRStatus; worktree?: WorktreeState }) { + return ( +
+ + {(wt) => ( +
+ Branch + + {wt().branch} + + {wt().parentBranch} + +
+ )} +
+
+ Status + {STATE_LABEL[props.pr.state]} +
+ + {(review) => ( +
+ Review + {REVIEW_LABEL[review()]} +
+ )} +
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx new file mode 100644 index 00000000000..e69890b5e9f --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx @@ -0,0 +1,61 @@ +/** @jsxImportSource solid-js */ +import { Component, Show } from "solid-js" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" +import type { WorktreeState } from "../../src/types/messages" +import type { PRStatus } from "../../src/types/messages" +import { PRBadge } from "./PRBadge" +import { PROverview } from "./PROverview" +import { PRReviewers } from "./PRReviewers" +import { PRDescription } from "./PRDescription" +import { PRFileChanges } from "./PRFileChanges" +import { PRChecks } from "./PRChecks" +import { PRComments } from "./PRComments" +import "./pr-panel.css" + +interface PRPanelProps { + pr: PRStatus + worktree?: WorktreeState + onClose: () => void + onOpenExternal: () => void +} + +export const PRPanel: Component = (props) => ( + +
+
+
+ + {props.pr.title} +
+
+ + + + + + +
+
+
+ + + + + + + + 0 || props.pr.additions > 0 || props.pr.deletions > 0}> + + + 0}> + + + 0}> + + +
+
+
+) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRReviewers.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRReviewers.tsx new file mode 100644 index 00000000000..85206562958 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRReviewers.tsx @@ -0,0 +1,44 @@ +/** @jsxImportSource solid-js */ +import { For, Show, createSignal } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import type { PRReviewer } from "./pr-types" +import { SectionHeading } from "./SectionHeading" + +const REVIEWER_ICON: Record = { + approved: "circle-check", + changes_requested: "refresh", + commented: "edit", + pending: "dash", +} + +const REVIEWER_LABEL: Record = { + approved: "Approved", + changes_requested: "Changes requested", + commented: "Commented", + pending: "Awaiting", +} + +export function PRReviewers(props: { reviewers: PRReviewer[] }) { + const [open, setOpen] = createSignal(true) + return ( + <> +
+
+ setOpen((v) => !v)} /> + +
+ + {(reviewer) => ( +
+ + + {REVIEWER_LABEL[reviewer.state]} +
+ )} +
+
+
+
+ + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/SectionHeading.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/SectionHeading.tsx new file mode 100644 index 00000000000..1de9bba802e --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/SectionHeading.tsx @@ -0,0 +1,23 @@ +/** @jsxImportSource solid-js */ +import { Show } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" + +export function SectionHeading(props: { + title: string + open: boolean + onToggle: () => void + count?: string + countClass?: string +}) { + return ( + + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css new file mode 100644 index 00000000000..ba73dd76ae8 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css @@ -0,0 +1,343 @@ +/* PR Panel */ +.am-pr-panel { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +/* Shared flex utilities scoped to the PR panel */ +.am-pr-row { display: flex; align-items: center; } +.am-pr-col { display: flex; flex-direction: column; } + +.am-pr-panel-header { + align-items: flex-start; + justify-content: space-between; + gap: 8px; + padding: 10px 12px 8px; + border-bottom: 1px solid var(--vscode-panel-border); + flex-shrink: 0; +} + +.am-pr-panel-title-row { + gap: 8px; + min-width: 0; +} + +.am-pr-panel-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px 2px 4px; + border-radius: 4px; + font-size: var(--kilo-font-size-10); + font-variant-numeric: tabular-nums; + background: color-mix(in srgb, var(--pr-accent) 15%, transparent); + color: var(--pr-accent); + border: 1px solid color-mix(in srgb, var(--pr-accent) 40%, transparent); + white-space: nowrap; + flex-shrink: 0; +} + +.am-pr-panel-badge.am-pr-badge-pending { + animation: am-pr-badge-pulse 1.5s ease-in-out infinite; +} + +.am-pr-panel-title { + font-size: var(--kilo-font-size-14); + color: var(--vscode-foreground); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.am-pr-panel-actions { + gap: 2px; + flex-shrink: 0; +} + +.am-pr-panel-body { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.am-pr-panel-section { + padding: 4px 12px; +} + +.am-pr-panel-section-heading { + justify-content: space-between; + font-size: var(--font-size-small); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-weak); + padding-bottom: 6px; +} + +.am-pr-panel-section-toggle { + width: 100%; + background: none; + border: none; + cursor: pointer; + padding: 0; + text-align: left; + border-radius: 3px; +} + +.am-pr-panel-section-toggle:hover { + color: var(--vscode-foreground); +} + +.am-pr-panel-section-heading-left { + gap: 4px; +} + +.am-pr-section-chevron { + flex-shrink: 0; + opacity: 0.6; +} + +/* Branch → base row */ +.am-pr-panel-branch { + gap: 4px; + flex-wrap: wrap; +} + +.am-pr-branch-name { + font-family: var(--font-mono, monospace); + font-size: var(--kilo-font-size-11); +} + +.am-pr-branch-arrow { + opacity: 0.5; + flex-shrink: 0; +} + +/* Check count color by status */ +.am-pr-checks-count-success { color: var(--vscode-testing-iconPassed, #34d399); } +.am-pr-checks-count-failure { color: var(--vscode-testing-iconFailed, #f87171); } +.am-pr-checks-count-pending { color: var(--vscode-charts-yellow, #fbbf24); } + +.am-pr-panel-section-count { + font-size: var(--kilo-font-size-11); + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: var(--text-weak); +} + +.am-pr-panel-divider { + height: 1px; + background: var(--vscode-panel-border); + margin: 6px 0; +} + +.am-pr-panel-row { + justify-content: space-between; + gap: 8px; + padding: 3px 0; + min-height: 22px; +} + +.am-pr-panel-label { + font-size: var(--kilo-font-size-11); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-weaker); + flex-shrink: 0; +} + +.am-pr-panel-value { + font-size: var(--kilo-font-size-12); + color: var(--vscode-foreground); + text-align: right; +} + +.am-pr-panel-value[data-pr-state="open"] { color: var(--vscode-testing-iconPassed, #34d399); } +.am-pr-panel-value[data-pr-state="merged"] { color: var(--vscode-charts-purple, #a78bfa); } +.am-pr-panel-value[data-pr-state="closed"] { color: var(--vscode-testing-iconFailed, #f87171); } +.am-pr-panel-value[data-pr-state="draft"] { color: var(--text-weaker); } + +.am-pr-panel-value[data-checks="failure"] { color: var(--vscode-testing-iconFailed, #f87171); } +.am-pr-panel-value[data-checks="pending"] { color: var(--vscode-charts-yellow, #fbbf24); } +.am-pr-panel-value[data-checks="success"] { color: var(--vscode-testing-iconPassed, #34d399); } + +.am-pr-panel-diff { + gap: 6px; +} +.am-pr-panel-unresolved { + color: var(--vscode-charts-yellow, #fbbf24); +} + +/* PR comment list */ +.am-pr-panel-comment-list { + gap: 8px; + margin-top: 6px; +} + +.am-pr-panel-comment { + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + padding: 8px; + opacity: 1; +} + +.am-pr-panel-comment-resolved { + opacity: 0.5; +} + +.am-pr-panel-comment-header { + gap: 6px; + margin-bottom: 4px; + flex-wrap: wrap; +} + +.am-pr-panel-comment-author { + font-size: var(--kilo-font-size-12); + font-weight: 600; + color: var(--vscode-foreground); +} + +.am-pr-panel-comment-file { + font-size: var(--kilo-font-size-11); + color: var(--text-weak); + font-family: var(--font-mono, monospace); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 180px; +} + +.am-pr-panel-comment-resolved-badge { + font-size: var(--kilo-font-size-10); + color: var(--text-weaker); + border: 1px solid var(--vscode-panel-border); + border-radius: 3px; + padding: 1px 4px; + margin-left: auto; +} + +.am-pr-panel-comment-body [data-component="markdown"] { + font-size: var(--kilo-font-size-12); + color: var(--vscode-foreground); +} + +.am-pr-panel-description { + line-height: 1.5; + padding: 2px 0 4px; +} + +.am-pr-panel-description [data-component="markdown"], +.am-pr-panel-comment-body [data-component="markdown"] { + font-size: var(--kilo-font-size-13); + color: var(--vscode-foreground); +} + +.am-pr-panel-description [data-component="markdown"] h1, +.am-pr-panel-description [data-component="markdown"] h2, +.am-pr-panel-description [data-component="markdown"] h3, +.am-pr-panel-description [data-component="markdown"] h4, +.am-pr-panel-description [data-component="markdown"] h5, +.am-pr-panel-description [data-component="markdown"] h6 { + font-weight: 600; + color: var(--vscode-foreground); + margin-top: 0.75em; + margin-bottom: 0.25em; +} + +.am-pr-panel-description [data-component="markdown"] h1 { font-size: var(--kilo-font-size-16); } +.am-pr-panel-description [data-component="markdown"] h2 { font-size: var(--kilo-font-size-14); } +.am-pr-panel-description [data-component="markdown"] h3 { font-size: var(--kilo-font-size-13); } + +/* PR Reviewers */ +.am-pr-panel-reviewers { + gap: 2px; +} + +.am-pr-panel-reviewer { + gap: 6px; + padding: 3px 0; + font-size: var(--kilo-font-size-12); +} + +.am-pr-reviewer-icon { + flex-shrink: 0; +} + +.am-pr-panel-reviewer[data-state="approved"] .am-pr-reviewer-icon { color: var(--vscode-testing-iconPassed, #34d399); } +.am-pr-panel-reviewer[data-state="changes_requested"] .am-pr-reviewer-icon { color: var(--vscode-charts-yellow, #fbbf24); } +.am-pr-panel-reviewer[data-state="commented"] .am-pr-reviewer-icon { color: var(--text-weak); } +.am-pr-panel-reviewer[data-state="pending"] .am-pr-reviewer-icon { color: var(--text-weaker); } + +.am-pr-reviewer-login { + flex: 1; + color: var(--vscode-foreground); +} + +.am-pr-reviewer-state { + color: var(--text-weak); + flex-shrink: 0; +} + +.am-pr-panel-checks { + gap: 2px; + margin-top: 4px; +} + +.am-pr-panel-check-item { + gap: 6px; + padding: 3px 0; + font-size: var(--kilo-font-size-12); +} + +.am-pr-check-icon { + flex-shrink: 0; +} + +.am-pr-panel-check-item[data-status="success"] .am-pr-check-icon { color: var(--vscode-testing-iconPassed, #34d399); } +.am-pr-panel-check-item[data-status="failure"] .am-pr-check-icon { color: var(--vscode-testing-iconFailed, #f87171); } +.am-pr-panel-check-item[data-status="cancelled"] .am-pr-check-icon { color: var(--text-weaker); } +.am-pr-panel-check-item[data-status="skipped"] .am-pr-check-icon { color: var(--text-weaker); } +.am-pr-panel-check-item[data-status="pending"] .am-pr-check-icon { color: var(--vscode-charts-yellow, #fbbf24); } + +.am-pr-check-name { + flex: 1; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.am-pr-check-status { + color: var(--text-weak); + flex-shrink: 0; +} + +.am-pr-check-duration { + color: var(--text-weaker); + font-size: var(--kilo-font-size-10); + flex-shrink: 0; +} + +.am-pr-check-link { + color: var(--text-weak); + display: flex; + align-items: center; + flex-shrink: 0; + cursor: pointer; + text-decoration: none; +} + +.am-pr-check-link:hover { + color: var(--text-base); +} + +/* PR toggle button in tab bar */ +.am-pr-toggle-number { + font-size: var(--kilo-font-size-10); + font-variant-numeric: tabular-nums; +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts new file mode 100644 index 00000000000..8d88e131557 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts @@ -0,0 +1,32 @@ +// PR sub-types — source of truth for all PR-related types used in the PR panel. +// PRStatus lives in src/types/messages/agent-manager.ts for broad consumption. + +export type PRState = "open" | "draft" | "merged" | "closed" +export type ReviewDecision = "approved" | "changes_requested" | "pending" +export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled" +export type AggregateCheckStatus = "success" | "failure" | "pending" | "none" + +export interface PRCheck { + name: string + status: CheckStatus + url?: string + duration?: string +} + +export interface PRComment { + id: string + author: string + avatar?: string + body: string + file?: string + line?: number + url?: string + resolved: boolean + createdAt?: number +} + +export interface PRReviewer { + login: string + avatar?: string + state: "approved" | "changes_requested" | "pending" | "commented" +} diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 9ebef0f8766..629818c1c25 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -604,7 +604,7 @@ const basePR: PRStatus = { url: "https://github.com/org/repo/pull/8594", state: "open", review: null, - checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, items: [] }, + checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, checks: [] }, additions: 978, deletions: 202, files: 12, diff --git a/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx index cf348a4d339..23938282a84 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx @@ -593,7 +593,7 @@ export const WithPRBadges: Story = { additions: 120, deletions: 30, files: 5, - checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, items: [] }, + checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, checks: [] }, }} /> {/* Open + failing checks → green badge, red ✗ (no longer confusable with closed) */} @@ -611,7 +611,7 @@ export const WithPRBadges: Story = { additions: 15, deletions: 8, files: 3, - checks: { status: "failure", total: 5, passed: 3, failed: 2, pending: 0, items: [] }, + checks: { status: "failure", total: 5, passed: 3, failed: 2, pending: 0, checks: [] }, }} /> {/* Open + changes requested → green badge, amber warning */} @@ -629,7 +629,7 @@ export const WithPRBadges: Story = { additions: 60, deletions: 12, files: 4, - checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, items: [] }, + checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, checks: [] }, }} /> {/* Open + checks running → pulsing amber badge (animation disabled in snapshots) */} @@ -647,7 +647,7 @@ export const WithPRBadges: Story = { additions: 200, deletions: 0, files: 8, - checks: { status: "pending", total: 5, passed: 2, failed: 0, pending: 3, items: [] }, + checks: { status: "pending", total: 5, passed: 2, failed: 0, pending: 3, checks: [] }, }} /> {/* Draft → gray badge */} @@ -665,7 +665,7 @@ export const WithPRBadges: Story = { additions: 30, deletions: 5, files: 2, - checks: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, items: [] }, + checks: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] }, }} /> {/* Merged → purple badge, no status icon */} @@ -683,7 +683,7 @@ export const WithPRBadges: Story = { additions: 90, deletions: 20, files: 6, - checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, items: [] }, + checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, checks: [] }, }} /> {/* Closed → red badge, no status icon (distinct from a failing open PR) */} @@ -701,7 +701,7 @@ export const WithPRBadges: Story = { additions: 10, deletions: 4, files: 1, - checks: { status: "failure", total: 5, passed: 1, failed: 4, pending: 0, items: [] }, + checks: { status: "failure", total: 5, passed: 1, failed: 4, pending: 0, checks: [] }, }} />
diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts b/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts index 5bdbe3cfafc..23c0395a07d 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts @@ -45,32 +45,11 @@ export interface SectionState { } // --------------------------------------------------------------------------- -// PR status types (mirrored from extension types.ts) +// PR status types — sub-types live in agent-manager/pr/pr-types.ts // --------------------------------------------------------------------------- -export type PRState = "open" | "draft" | "merged" | "closed" -export type ReviewDecision = "approved" | "changes_requested" | "pending" -export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled" -export type AggregateCheckStatus = "success" | "failure" | "pending" | "none" - -export interface PRCheck { - name: string - status: CheckStatus - url?: string - duration?: string -} - -export interface PRComment { - id: string - author: string - avatar?: string - body: string - file?: string - line?: number - url?: string - resolved: boolean - createdAt?: number -} +import type { PRState, ReviewDecision, AggregateCheckStatus, PRCheck, PRComment, PRReviewer } from "../../../agent-manager/pr/pr-types" +export type { PRState, ReviewDecision, CheckStatus, AggregateCheckStatus, PRCheck, PRComment, PRReviewer } from "../../../agent-manager/pr/pr-types" export interface PRStatus { number: number @@ -85,25 +64,19 @@ export interface PRStatus { passed: number failed: number pending: number - items: PRCheck[] + checks: PRCheck[] } comments?: { total: number unresolved: number - items: PRComment[] + comments: PRComment[] + reviewers: PRReviewer[] } - reviewers?: PRReviewer[] additions: number deletions: number files: number } -export interface PRReviewer { - login: string - avatar?: string - state: "approved" | "changes_requested" | "pending" | "commented" -} - export type RunState = "idle" | "running" | "stopping" export interface RunStatus { From f01d5fdbb1120aa10881ef483cfa00729063e468 Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 3 Aug 2026 14:24:03 -0700 Subject: [PATCH 06/54] feat(pr-view): Improve agent code practices --- .../src/agent-manager/pr-status-bridge.ts | 2 +- .../webview-ui/agent-manager/WorktreeItem.tsx | 19 +++++++-------- .../agent-manager/agent-manager.css | 23 +++++++++--------- .../webview-ui/agent-manager/pr/PRBadge.tsx | 12 +++++++--- .../webview-ui/agent-manager/pr/PRChecks.tsx | 24 +++++++------------ .../webview-ui/agent-manager/pr/PRPanel.tsx | 14 +++++------ 6 files changed, 43 insertions(+), 51 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts index 2a216c30796..c64da3971eb 100644 --- a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts @@ -78,7 +78,7 @@ export class PRStatusBridge { return true } if (m.type === "agentManager.openPR") { - const url = (m.url as string | undefined) ?? this.host.getWorktrees().find((w: Worktree) => w.id === m.worktreeId)?.prUrl + const url = (m.url as string) ?? this.host.getWorktrees().find((w: Worktree) => w.id === m.worktreeId)?.prUrl if (url) this.host.openExternal(url) return true } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx index 3af617b8599..d33b0e0a61d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx @@ -95,15 +95,6 @@ const hasStats = (s: WorktreeGitStats | undefined): s is WorktreeGitStats => * and review results are conveyed by a separate status icon (see prBadgeIndicator) * so a failing check is not mistaken for a closed PR. */ -/** Derive the badge accent key used as `data-accent` for CSS color styling. */ -export function prBadgeAccent(pr: PRStatus): "open" | "pending" | "draft" | "merged" | "closed" { - if (pr.state === "draft") return "draft" - if (pr.state === "merged") return "merged" - if (pr.state === "closed") return "closed" - if (pr.checks.status === "pending") return "pending" - return "open" -} - /** True while an open PR's checks are still running — drives the pulsing amber badge. */ export function prChecksRunning(pr: PRStatus): boolean { return pr.state === "open" && pr.checks.status === "pending" @@ -355,8 +346,14 @@ export const WorktreeItem: Component = (props) => { return ( }> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 4f2958ab18b..79e0fee011a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -845,18 +845,17 @@ button.am-section-toggle:hover .am-section-label { color: #f87171; } -/* PR pill badge — second row inside am-wt-row2. - Uses data-accent attribute to derive all colors via CSS variables. */ -.am-pr-badge[data-accent="open"], -.am-pr-panel-badge[data-accent="open"] { --pr-accent: var(--vscode-testing-iconPassed, #34d399); } -.am-pr-badge[data-accent="pending"], -.am-pr-panel-badge[data-accent="pending"] { --pr-accent: var(--vscode-charts-yellow, #fbbf24); } -.am-pr-badge[data-accent="draft"], -.am-pr-panel-badge[data-accent="draft"] { --pr-accent: var(--text-weaker); } -.am-pr-badge[data-accent="merged"], -.am-pr-panel-badge[data-accent="merged"] { --pr-accent: var(--vscode-charts-purple, #a78bfa); } -.am-pr-badge[data-accent="closed"], -.am-pr-panel-badge[data-accent="closed"] { --pr-accent: var(--vscode-testing-iconFailed, #f87171); } +/* PR badge accent colors via classList */ +.am-pr-accent-open, +.am-pr-panel-badge.am-pr-accent-open { --pr-accent: var(--vscode-testing-iconPassed, #34d399); } +.am-pr-accent-pending, +.am-pr-panel-badge.am-pr-accent-pending { --pr-accent: var(--vscode-charts-yellow, #fbbf24); } +.am-pr-accent-draft, +.am-pr-panel-badge.am-pr-accent-draft { --pr-accent: var(--text-weaker); } +.am-pr-accent-merged, +.am-pr-panel-badge.am-pr-accent-merged { --pr-accent: var(--vscode-charts-purple, #a78bfa); } +.am-pr-accent-closed, +.am-pr-panel-badge.am-pr-accent-closed { --pr-accent: var(--vscode-testing-iconFailed, #f87171); } .am-pr-badge { display: inline-flex; diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx index b80b9495152..9eaec93baef 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource solid-js */ import { Icon } from "@kilocode/kilo-ui/icon" import type { PRStatus } from "../../src/types/messages" -import { prBadgeAccent, prBadgeIndicator, prChecksRunning } from "../WorktreeItem" +import { prBadgeIndicator, prChecksRunning } from "../WorktreeItem" const INDICATOR_ICON: Record = { failure: "circle-x", @@ -15,8 +15,14 @@ export function PRBadge(props: { pr: PRStatus }) { return ( = { - success: "circle-check", - failure: "circle-x-outline", - cancelled: "circle-x-outline", - skipped: "circle-x-outline", - pending: "play", -} - -const CHECK_LABEL: Record = { - success: "Passed", - failure: "Failed", - pending: "Running", - skipped: "Skipped", - cancelled: "Cancelled", +const CHECK: Record = { + success: { icon: "circle-check", label: "Passed" }, + failure: { icon: "circle-x-outline", label: "Failed" }, + cancelled: { icon: "circle-x-outline", label: "Cancelled" }, + skipped: { icon: "circle-x-outline", label: "Skipped" }, + pending: { icon: "play", label: "Running" }, } export function PRChecks(props: { checks: PRStatus["checks"] }) { @@ -39,9 +31,9 @@ export function PRChecks(props: { checks: PRStatus["checks"] }) { {(check: PRCheck) => (
- - + + {(comments) => } - - - 0 || props.pr.additions > 0 || props.pr.deletions > 0}> - + {(body) => } + 0}> - 0}> - + + {(comments) => }
From 6e1d7e64f3f53f6874e91ce7263cfe123d8db263 Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 3 Aug 2026 14:37:31 -0700 Subject: [PATCH 07/54] feat(pr-view): Improve agent code practices, add types and styles --- .../src/agent-manager/PRStatusPoller.ts | 51 +++++++++---------- ...agent-manager-orchestration-domain.test.ts | 4 +- .../webview-ui/agent-manager/pr/PRChecks.tsx | 8 +-- .../webview-ui/agent-manager/pr/PRPanel.tsx | 2 +- .../webview-ui/agent-manager/pr/pr-panel.css | 3 -- 5 files changed, 32 insertions(+), 36 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index 01875607f00..a2a6ddd51d6 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -460,8 +460,8 @@ export class PRStatusPoller { { cwd, timeout: 15_000 }, ) const pr = JSON.parse(stdout)?.data?.repository?.pullRequest - const comments = parseComments(pr?.reviewThreads?.nodes ?? []) - const reviewers = parseReviewers(pr?.reviewRequests?.nodes ?? [], pr?.reviews?.nodes ?? []) + const comments = parseComments((pr?.reviewThreads?.nodes ?? []) as GQLThread[]) + const reviewers = parseReviewers((pr?.reviewRequests?.nodes ?? []) as GQLReviewRequest[], (pr?.reviews?.nodes ?? []) as GQLReview[]) return { total: comments.length, unresolved: comments.filter((c) => !c.resolved).length, comments, reviewers } } catch (err) { this.options.log("Failed to fetch PR comments:", err) @@ -543,46 +543,45 @@ function formatCheckDuration(startedAt?: string, completedAt?: string): string | return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s` } -function parseComments(threads: Record[]): PRComment[] { +interface GQLAuthor { login?: string; avatarUrl?: string } +interface GQLComment { id: string; author?: GQLAuthor; body?: string; path?: string; line?: number; url?: string; createdAt?: string } +interface GQLThread { isResolved?: boolean; comments?: { nodes?: GQLComment[] } } +interface GQLReviewRequest { requestedReviewer?: GQLAuthor } +interface GQLReview { author?: GQLAuthor; state?: string } + +function parseComments(threads: GQLThread[]): PRComment[] { const items: PRComment[] = [] for (const thread of threads) { - const nodes = (thread.comments as { nodes?: unknown[] } | undefined)?.nodes - const first = nodes?.[0] as Record | undefined + const first = thread.comments?.nodes?.[0] if (!first) continue - const author = first.author as Record | undefined items.push({ - id: first.id as string, - author: (author?.login as string) ?? "unknown", - avatar: author?.avatarUrl as string | undefined, - body: (first.body as string) ?? "", - file: first.path as string | undefined, - line: first.line as number | undefined, - url: first.url as string | undefined, - resolved: (thread.isResolved as boolean) ?? false, - createdAt: first.createdAt ? new Date(first.createdAt as string).getTime() : undefined, + id: first.id, + author: first.author?.login ?? "unknown", + avatar: first.author?.avatarUrl, + body: first.body ?? "", + file: first.path, + line: first.line, + url: first.url, + resolved: thread.isResolved ?? false, + createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined, }) } return items } -function parseReviewers( - requests: Record[], - reviews: Record[], -): PRReviewer[] { +function parseReviewers(requests: GQLReviewRequest[], reviews: GQLReview[]): PRReviewer[] { const map = new Map() for (const node of requests) { - const user = node.requestedReviewer as Record | undefined + const user = node.requestedReviewer if (!user?.login) continue - map.set(user.login as string, { login: user.login as string, avatar: user.avatarUrl as string | undefined, state: "pending" }) + map.set(user.login, { login: user.login, avatar: user.avatarUrl, state: "pending" }) } for (const node of reviews) { - const author = node.author as Record | undefined - const login = author?.login as string | undefined + const login = node.author?.login if (!login) continue - const state = REVIEWER_STATE[node.state as string] ?? "pending" - // Keep most significant: approved/changes_requested > commented > pending + const state = REVIEWER_STATE[node.state ?? ""] ?? "pending" if (!map.has(login) || state !== "commented") { - map.set(login, { login, avatar: author?.avatarUrl as string | undefined, state }) + map.set(login, { login, avatar: node.author?.avatarUrl, state }) } } return [...map.values()] diff --git a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts index e7a6e0fb177..68173c61e3a 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts @@ -86,8 +86,8 @@ describe("Agent Manager orchestration domain", () => { url: "https://example.com/pr/42", state: "open", review: "approved", - checks: { status: "success", total: 1, passed: 1, failed: 0, pending: 0, items: [] }, - comments: { total: 2, unresolved: 1, items: [] }, + checks: { status: "success", total: 1, passed: 1, failed: 0, pending: 0, checks: [] }, + comments: { total: 2, unresolved: 1, comments: [], reviewers: [] }, additions: 10, deletions: 2, files: 1, diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx index 1e6b82654f0..235124dc720 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx @@ -6,11 +6,11 @@ import type { PRCheck, CheckStatus } from "./pr-types" import { SectionHeading } from "./SectionHeading" const CHECK: Record = { - success: { icon: "circle-check", label: "Passed" }, - failure: { icon: "circle-x-outline", label: "Failed" }, + success: { icon: "circle-check", label: "Passed" }, + failure: { icon: "circle-x-outline", label: "Failed" }, cancelled: { icon: "circle-x-outline", label: "Cancelled" }, - skipped: { icon: "circle-x-outline", label: "Skipped" }, - pending: { icon: "play", label: "Running" }, + skipped: { icon: "circle-x-outline", label: "Skipped" }, + pending: { icon: "play", label: "Running" }, } export function PRChecks(props: { checks: PRStatus["checks"] }) { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx index a10bd6d8a3b..d3c68a71924 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx @@ -23,7 +23,7 @@ interface PRPanelProps { export const PRPanel: Component = (props) => ( -
+
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css index ba73dd76ae8..f218582cdda 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css @@ -1,12 +1,9 @@ /* PR Panel */ .am-pr-panel { - display: flex; - flex-direction: column; height: 100%; overflow: hidden; } -/* Shared flex utilities scoped to the PR panel */ .am-pr-row { display: flex; align-items: center; } .am-pr-col { display: flex; flex-direction: column; } From ffacdc60dde5e9295edfa9bf37242dac774003a3 Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 3 Aug 2026 15:12:20 -0700 Subject: [PATCH 08/54] feat(pr-view): PR summary --- .../webview-ui/agent-manager/pr/PRPanel.tsx | 75 ++++++++++--------- .../webview-ui/agent-manager/pr/PRSummary.tsx | 67 +++++++++++++++++ .../webview-ui/agent-manager/pr/pr-panel.css | 49 ++++++++++++ 3 files changed, 157 insertions(+), 34 deletions(-) create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx index d3c68a71924..aa92f03e8a9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx @@ -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 { MarkedProvider } from "@kilocode/kilo-ui/context/marked" @@ -12,6 +12,7 @@ import { PRDescription } from "./PRDescription" import { PRFileChanges } from "./PRFileChanges" import { PRChecks } from "./PRChecks" import { PRComments } from "./PRComments" +import { PRSummary } from "./PRSummary" import "./pr-panel.css" interface PRPanelProps { @@ -21,39 +22,45 @@ interface PRPanelProps { onOpenExternal: () => void } -export const PRPanel: Component = (props) => ( - -
-
-
- - {props.pr.title} +export const PRPanel: Component = (props) => { + const [summaryVisible, setSummaryVisible] = createSignal(true) + return ( + +
+
+
+ + {props.pr.title} +
+
+ + + + + + +
-
- - - - - - + + setSummaryVisible(false)} /> + +
+ + + {(comments) => } + + + {(body) => } + + + 0}> + + + + {(comments) => } +
-
- - - {(comments) => } - - - {(body) => } - - - 0}> - - - - {(comments) => } - -
-
-
-) + + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx new file mode 100644 index 00000000000..2eb405c7765 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx @@ -0,0 +1,67 @@ +/** @jsxImportSource solid-js */ +import { Component, Show, createSignal } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import type { PRStatus } from "../../src/types/messages" + +interface PRSummaryProps { + pr: PRStatus + onDismiss: () => void +} + +function summaryRows(pr: PRStatus): Array<{ icon: string; label: string; status: string }> { + const rows = [] + + if (pr.checks.total > 0) { + const { passed, total, status } = pr.checks + rows.push({ + icon: status === "success" ? "circle-check" : status === "failure" ? "circle-x-outline" : "play", + label: status === "success" ? "All checks passing" : `${passed}/${total} checks passed`, + status, + }) + } + + const reviewers = pr.comments?.reviewers + if (reviewers && reviewers.length > 0) { + const approved = reviewers.filter((r) => r.state === "approved").length + const changes = reviewers.filter((r) => r.state === "changes_requested").length + const status = approved === reviewers.length ? "success" : changes > 0 ? "failure" : "pending" + rows.push({ + icon: status === "success" ? "circle-check" : status === "failure" ? "circle-x-outline" : "play", + label: approved === reviewers.length ? "All reviewers approved" : `${approved}/${reviewers.length} approvals`, + status, + }) + } + + if (pr.comments && pr.comments.unresolved > 0) { + rows.push({ + icon: "comment", + label: `${pr.comments.unresolved} unresolved comment${pr.comments.unresolved > 1 ? "s" : ""}`, + status: "warning", + }) + } + + return rows +} + +export function PRSummary(props: PRSummaryProps) { + const rows = () => summaryRows(props.pr) + return ( + 0}> +
+
+ PR Summary + +
+
+ {rows().map((row) => ( +
+ + {row.label} +
+ ))} +
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css index f218582cdda..9507c8c5e0f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css @@ -338,3 +338,52 @@ font-size: var(--kilo-font-size-10); font-variant-numeric: tabular-nums; } + +/* PR Summary banner */ +.am-pr-summary { + margin: 8px 12px; + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; +} + +.am-pr-summary-header { + justify-content: space-between; + padding: 6px 6px 6px 10px; + border-bottom: 1px solid var(--vscode-panel-border); + background: var(--vscode-editor-background); +} + +.am-pr-summary-title { + font-size: var(--font-size-small); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-weak); +} + +.am-pr-summary-rows { + padding: 6px 10px; + gap: 4px; + background: var(--vscode-editor-background); +} + +.am-pr-summary-row { + gap: 7px; + padding: 2px 0; + font-size: var(--kilo-font-size-12); +} + +.am-pr-summary-icon { + flex-shrink: 0; +} + +.am-pr-summary-row[data-status="success"] .am-pr-summary-icon { color: var(--vscode-testing-iconPassed, #34d399); } +.am-pr-summary-row[data-status="failure"] .am-pr-summary-icon { color: var(--vscode-testing-iconFailed, #f87171); } +.am-pr-summary-row[data-status="pending"] .am-pr-summary-icon { color: var(--vscode-charts-yellow, #fbbf24); } +.am-pr-summary-row[data-status="warning"] .am-pr-summary-icon { color: var(--vscode-charts-yellow, #fbbf24); } + +.am-pr-summary-label { + color: var(--vscode-foreground); +} From c7213b2a9be5dc017abef417dc24fa0899da844b Mon Sep 17 00:00:00 2001 From: cmanu Date: Tue, 4 Aug 2026 08:53:34 -0700 Subject: [PATCH 09/54] feat(pr-view): Use PR icon on badges and update summary to remove dismiss --- .../webview-ui/agent-manager/TabBar.tsx | 2 +- .../webview-ui/agent-manager/pr/PRBadge.tsx | 18 ++++++++++-------- .../webview-ui/agent-manager/pr/PRPanel.tsx | 8 +++----- .../webview-ui/agent-manager/pr/PRSummary.tsx | 5 +---- .../webview-ui/agent-manager/pr/pr-panel.css | 7 +++++++ packages/ui/src/components/icon.tsx | 1 + 6 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx index ab0a888f30e..c21ca2f4705 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx @@ -252,7 +252,7 @@ export const TabBar: Component = (props) => ( onClick={props.onTogglePR} title={`PR #${pr().number}`} > - + #{pr().number} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx index 9eaec93baef..cb54b3ee913 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx @@ -1,4 +1,5 @@ /** @jsxImportSource solid-js */ +import { Show } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" import type { PRStatus } from "../../src/types/messages" import { prBadgeIndicator, prChecksRunning } from "../WorktreeItem" @@ -7,7 +8,6 @@ const INDICATOR_ICON: Record = { failure: "circle-x", changes: "warning", approved: "circle-check", - none: "branch", } export function PRBadge(props: { pr: PRStatus }) { @@ -24,13 +24,15 @@ export function PRBadge(props: { pr: PRStatus }) { "am-pr-badge-pending": prChecksRunning(props.pr), }} > - - #{props.pr.number} + + + + ) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx index aa92f03e8a9..dda904744ec 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource solid-js */ -import { Component, Show, createSignal } from "solid-js" +import { Component, Show } from "solid-js" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" @@ -23,7 +23,6 @@ interface PRPanelProps { } export const PRPanel: Component = (props) => { - const [summaryVisible, setSummaryVisible] = createSignal(true) return (
@@ -31,6 +30,7 @@ export const PRPanel: Component = (props) => {
{props.pr.title} + #{props.pr.number}
@@ -41,9 +41,7 @@ export const PRPanel: Component = (props) => {
- - setSummaryVisible(false)} /> - +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx index 2eb405c7765..c04e89a5fe4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx @@ -1,12 +1,10 @@ /** @jsxImportSource solid-js */ -import { Component, Show, createSignal } from "solid-js" +import { Show } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" -import { IconButton } from "@kilocode/kilo-ui/icon-button" import type { PRStatus } from "../../src/types/messages" interface PRSummaryProps { pr: PRStatus - onDismiss: () => void } function summaryRows(pr: PRStatus): Array<{ icon: string; label: string; status: string }> { @@ -51,7 +49,6 @@ export function PRSummary(props: PRSummaryProps) {
PR Summary -
{rows().map((row) => ( diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css index 9507c8c5e0f..897f1874a6e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css @@ -49,6 +49,13 @@ white-space: nowrap; } +.am-pr-panel-number { + font-size: var(--kilo-font-size-12); + color: var(--vscode-descriptionForeground); + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} + .am-pr-panel-actions { gap: 2px; flex-shrink: 0; diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index e70de73a831..e2170c0772e 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -91,6 +91,7 @@ const icons = { download: ``, menu: ``, server: ``, + "pull-request": ``, branch: ``, edit: ``, history: ``, From 93fde67c76cad60fdd021905b5e16a8d53693944 Mon Sep 17 00:00:00 2001 From: cmanu Date: Tue, 4 Aug 2026 08:56:13 -0700 Subject: [PATCH 10/54] feat(pr-view): Use PR icon on worktree item and remove pr number --- .../kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx index d33b0e0a61d..b6fa2d92898 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx @@ -356,7 +356,8 @@ export const WorktreeItem: Component = (props) => { }} onClick={handleOpenPR} > - }> + + @@ -372,7 +373,6 @@ export const WorktreeItem: Component = (props) => { /> - #{pr().number} ) }} From bd67e85902e56eb9cc8ac00045209838c04b046f Mon Sep 17 00:00:00 2001 From: cmanu Date: Tue, 4 Aug 2026 09:08:12 -0700 Subject: [PATCH 11/54] feat(pr-view): Styling and copy/paste on PR comments --- .../agent-manager/agent-manager.css | 2 +- .../agent-manager/pr/CopyButton.tsx | 22 +++++++++++++++++++ .../agent-manager/pr/PRComments.tsx | 2 ++ .../webview-ui/agent-manager/pr/pr-panel.css | 6 ++++- 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/pr/CopyButton.tsx diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 79e0fee011a..ee101e834e9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -861,7 +861,7 @@ button.am-section-toggle:hover .am-section-label { display: inline-flex; align-items: center; gap: 2px; - padding: 2px 7px 2px 4px; + padding: 2px 4px; border-radius: 6px; border: none; background: color-mix(in srgb, var(--pr-accent) 12%, transparent); diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/CopyButton.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/CopyButton.tsx new file mode 100644 index 00000000000..4a856df8d5b --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/CopyButton.tsx @@ -0,0 +1,22 @@ +/** @jsxImportSource solid-js */ +import { createSignal } from "solid-js" +import { IconButton } from "@kilocode/kilo-ui/icon-button" + +export function CopyButton(props: { text: string; label?: string; class?: string }) { + const [copied, setCopied] = createSignal(false) + const copy = () => { + navigator.clipboard.writeText(props.text) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + return ( + + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx index d3f3057e6c0..6745a0522c3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx @@ -4,6 +4,7 @@ import { Markdown } from "@kilocode/kilo-ui/markdown" 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 }) { const [open, setOpen] = createSignal(true) @@ -33,6 +34,7 @@ export function PRComments(props: { comments: NonNullable Resolved +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css index 897f1874a6e..4a1183bb125 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css @@ -25,7 +25,7 @@ display: inline-flex; align-items: center; gap: 4px; - padding: 2px 6px 2px 4px; + padding: 2px 4px; border-radius: 4px; font-size: var(--kilo-font-size-10); font-variant-numeric: tabular-nums; @@ -225,6 +225,10 @@ margin-left: auto; } +.am-pr-copy-btn { + margin-left: auto; +} + .am-pr-panel-comment-body [data-component="markdown"] { font-size: var(--kilo-font-size-12); color: var(--vscode-foreground); From 70c37d66d8d11322ba82e7d17d783945d054f0b3 Mon Sep 17 00:00:00 2001 From: cmanu Date: Tue, 4 Aug 2026 16:05:08 -0700 Subject: [PATCH 12/54] feat(pr-view): show code -X/+Y on code diff and remove PR number --- .../webview-ui/agent-manager/TabBar.tsx | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx index c21ca2f4705..c315cc3a7a1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx @@ -218,6 +218,19 @@ export const TabBar: Component = (props) => ( ) })()} + + {(pr) => ( + + + + )} + = (props) => ( title={props.t("agentManager.diff.toggle")} > - - - 0}> - {stats()!.files}f + + {(pr) => ( + 0 || pr().deletions > 0}> + + 0}> + +{pr().additions} + + 0}> + −{pr().deletions} + + - +{stats()!.additions} - −{stats()!.deletions} - + )} @@ -244,20 +262,6 @@ export const TabBar: Component = (props) => ( ) })()} - - {(pr) => ( - - - - )} - Date: Tue, 4 Aug 2026 16:15:47 -0700 Subject: [PATCH 13/54] feat(pr-view): fix styles to add variables and light theme fix for svg color. Make summary unfixed. --- .../agent-manager/agent-manager.css | 52 +++++++++++-------- .../webview-ui/agent-manager/pr/PRPanel.tsx | 2 +- .../webview-ui/agent-manager/pr/pr-panel.css | 1 - 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index d27fc1dca86..88eccf3932c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -796,7 +796,7 @@ html[data-theme="kilo-vscode"] } .am-run-badge[data-run-state="stopping"] { - --am-run-accent: #fbbf24; + --am-run-accent: var(--vscode-charts-yellow, #fbbf24); } .am-run-badge[data-run-state="success"] { @@ -804,7 +804,7 @@ html[data-theme="kilo-vscode"] } .am-run-badge[data-run-state="error"] { - --am-run-accent: #f87171; + --am-run-accent: var(--vscode-testing-iconFailed, #f87171); } @keyframes am-run-pulse { @@ -854,11 +854,11 @@ html[data-theme="kilo-vscode"] } .am-stat-additions { - color: #34d399; + color: var(--vscode-testing-iconPassed, #34d399); } .am-stat-deletions { - color: #f87171; + color: var(--vscode-testing-iconFailed, #f87171); } /* PR badge accent colors via classList */ @@ -962,35 +962,35 @@ html[data-theme="kilo-vscode"] } .am-worktree-item-active .am-stat-files { - color: rgba(255, 255, 255, 0.7); + color: var(--text-weak); } .am-worktree-item-active .am-stat-additions { - color: #6ee7b7; + color: var(--vscode-testing-iconPassed, #6ee7b7); } .am-worktree-item-active .am-stat-deletions { - color: #fca5a5; + color: var(--vscode-testing-iconFailed, #fca5a5); } .am-worktree-commits { - color: #34d399; + color: var(--vscode-testing-iconPassed, #34d399); } .am-worktree-behind { - color: #f87171; + color: var(--vscode-testing-iconFailed, #f87171); } .am-worktree-item-active .am-worktree-commits { - color: #6ee7b7; + color: var(--vscode-testing-iconPassed, #6ee7b7); } .am-worktree-item-active .am-worktree-behind { - color: #fca5a5; + color: var(--vscode-testing-iconFailed, #fca5a5); } .am-local-item-active .am-worktree-behind { - color: #fca5a5; + color: var(--vscode-testing-iconFailed, #fca5a5); } .am-local-item:hover .am-worktree-stats, @@ -1664,7 +1664,7 @@ body.am-wt-dragging-active * { } .am-sidebar-search-status { - color: #fbbf24; + color: var(--vscode-charts-yellow, #fbbf24); text-transform: uppercase; } @@ -1759,9 +1759,10 @@ body.am-wt-dragging-active * { color: var(--text-base); } -.am-diff-toggle-btn.am-tab-diff-btn-active { - background: var(--surface-interactive-base) !important; - color: var(--text-base) !important; +.am-diff-toggle-btn.am-tab-diff-btn-active, +.am-diff-toggle-btn.am-tab-diff-btn-active:hover { + background: var(--surface-interactive-base); + color: var(--text-on-interactive-base); } .am-diff-toggle-btn.am-diff-toggle-has-changes { @@ -1775,20 +1776,24 @@ body.am-wt-dragging-active * { } .am-diff-toggle-btn.am-tab-diff-btn-active .am-stat-files { - color: rgba(255, 255, 255, 0.7); + color: var(--text-weak); } .am-diff-toggle-btn.am-tab-diff-btn-active .am-stat-additions { - color: #6ee7b7; + color: var(--vscode-testing-iconPassed, #6ee7b7); } .am-diff-toggle-btn.am-tab-diff-btn-active .am-stat-deletions { - color: #fca5a5; + color: var(--vscode-testing-iconFailed, #fca5a5); } .am-tab-diff-btn-active { background: var(--surface-interactive-base) !important; - color: var(--text-base) !important; + color: var(--text-on-interactive-base) !important; +} + +.am-tab-diff-btn-active [data-slot="icon-svg"] { + color: var(--text-on-interactive-base) !important; } .am-detail-content { @@ -2899,9 +2904,10 @@ body.am-wt-dragging-active * { color: var(--text-base); } -.am-nv-pill-active { - background: var(--border-focus, #007fd4) !important; - color: #fff !important; +.am-nv-pill-active, +.am-nv-pill-active:hover { + background: var(--border-focus, #007fd4); + color: var(--text-on-interactive-base); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx index dda904744ec..7bf8aef9f7b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx @@ -41,8 +41,8 @@ export const PRPanel: Component = (props) => {
-
+ {(comments) => } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css index 4a1183bb125..6f1cc8cc260 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css @@ -356,7 +356,6 @@ border: 1px solid var(--vscode-panel-border); border-radius: 6px; overflow: hidden; - flex-shrink: 0; } .am-pr-summary-header { From cc900b277781b787f19d74e6b940acd6795e5470 Mon Sep 17 00:00:00 2001 From: cmanu Date: Wed, 5 Aug 2026 10:16:04 -0700 Subject: [PATCH 14/54] feat(pr-view): Organizing code; Adjust colors for themes; Improve types and icon consistency --- .../src/agent-manager/PRStatusPoller.ts | 133 +----------------- .../src/agent-manager/am-pr-utils.ts | 119 ++++++++++++++++ .../agent-manager/AgentManagerApp.tsx | 40 +++--- .../webview-ui/agent-manager/WorktreeItem.tsx | 2 +- .../agent-manager/agent-manager.css | 2 +- .../webview-ui/agent-manager/pr/PRBadge.tsx | 2 +- .../agent-manager/side-panel-layout.ts | 6 + .../agent-manager/terminal/ambient.ts | 3 +- 8 files changed, 156 insertions(+), 151 deletions(-) create mode 100644 packages/kilo-vscode/src/agent-manager/am-pr-utils.ts diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index a2a6ddd51d6..813c79f25d9 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -1,9 +1,11 @@ import type { ExecFileOptionsWithStringEncoding } from "child_process" import type { Worktree } from "./WorktreeStateManager" -import type { PRStatus, PRCheck, PRComment, PRReviewer, CheckStatus, AggregateCheckStatus, PRState, ReviewDecision } from "./types" +import type { PRStatus, PRCheck, PRComment, PRReviewer, AggregateCheckStatus } from "./types" import { execWithShellEnv } from "./shell-env" 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-utils" interface PRStatusPollerOptions { getWorktrees: () => Worktree[] @@ -387,7 +389,7 @@ export class PRStatusPoller { const checks: PRCheck[] = data.map((c) => ({ name: c.name, - status: mapCheckStatus(c.state), + status: checkStatus(c.state), url: c.link, duration: formatCheckDuration(c.startedAt, c.completedAt), })) @@ -460,8 +462,8 @@ export class PRStatusPoller { { cwd, timeout: 15_000 }, ) const pr = JSON.parse(stdout)?.data?.repository?.pullRequest - const comments = parseComments((pr?.reviewThreads?.nodes ?? []) as GQLThread[]) - const reviewers = parseReviewers((pr?.reviewRequests?.nodes ?? []) as GQLReviewRequest[], (pr?.reviews?.nodes ?? []) as GQLReview[]) + const comments = parseComments((pr?.reviewThreads?.nodes ?? []) as GhThread[]) + const reviewers = parseReviewers((pr?.reviewRequests?.nodes ?? []) as GhReviewRequest[], (pr?.reviews?.nodes ?? []) as GhReview[]) return { total: comments.length, unresolved: comments.filter((c) => !c.resolved).length, comments, reviewers } } catch (err) { this.options.log("Failed to fetch PR comments:", err) @@ -470,129 +472,6 @@ export class PRStatusPoller { } } -interface PRResult { - number: number - title: string - body: string - url: string - state: PRState - review: ReviewDecision | null - additions: number - deletions: number - files: number -} - -function parsePRResult(json: string): PRResult | null { - const data = JSON.parse(json) - if (!data.number) return null - return { - number: data.number, - title: data.title ?? "", - body: data.body ?? "", - url: data.url ?? "", - state: parsePRState(data.isDraft, data.state), - review: parseReviewDecision(data.reviewDecision), - additions: data.additions ?? 0, - deletions: data.deletions ?? 0, - files: data.changedFiles ?? 0, - } -} - -function parsePRState(isDraft: boolean, ghState: string): PRState { - if (isDraft) return "draft" - if (ghState === "MERGED") return "merged" - if (ghState === "CLOSED") return "closed" - return "open" -} - -function parseReviewDecision(decision: string | undefined): ReviewDecision | null { - if (decision === "APPROVED") return "approved" - if (decision === "CHANGES_REQUESTED") return "changes_requested" - if (decision === "REVIEW_REQUIRED") return "pending" - return null -} - -function mapCheckStatus(state: string): CheckStatus { - switch (state.toUpperCase()) { - case "SUCCESS": - return "success" - case "FAILURE": - case "ERROR": - return "failure" - case "PENDING": - case "QUEUED": - case "IN_PROGRESS": - case "REQUESTED": - case "WAITING": - return "pending" - case "SKIPPED": - return "skipped" - case "CANCELLED": - case "TIMED_OUT": - case "STALE": - case "STARTUP_FAILURE": - return "cancelled" - default: - return "pending" - } -} - -function formatCheckDuration(startedAt?: string, completedAt?: string): string | undefined { - if (!startedAt || !completedAt) return undefined - const secs = Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000) - return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s` -} - -interface GQLAuthor { login?: string; avatarUrl?: string } -interface GQLComment { id: string; author?: GQLAuthor; body?: string; path?: string; line?: number; url?: string; createdAt?: string } -interface GQLThread { isResolved?: boolean; comments?: { nodes?: GQLComment[] } } -interface GQLReviewRequest { requestedReviewer?: GQLAuthor } -interface GQLReview { author?: GQLAuthor; state?: string } - -function parseComments(threads: GQLThread[]): PRComment[] { - const items: PRComment[] = [] - for (const thread of threads) { - const first = thread.comments?.nodes?.[0] - if (!first) continue - items.push({ - id: first.id, - author: first.author?.login ?? "unknown", - avatar: first.author?.avatarUrl, - body: first.body ?? "", - file: first.path, - line: first.line, - url: first.url, - resolved: thread.isResolved ?? false, - createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined, - }) - } - return items -} - -function parseReviewers(requests: GQLReviewRequest[], reviews: GQLReview[]): PRReviewer[] { - const map = new Map() - for (const node of requests) { - const user = node.requestedReviewer - if (!user?.login) continue - map.set(user.login, { login: user.login, avatar: user.avatarUrl, state: "pending" }) - } - for (const node of reviews) { - const login = node.author?.login - if (!login) continue - const state = REVIEWER_STATE[node.state ?? ""] ?? "pending" - if (!map.has(login) || state !== "commented") { - map.set(login, { login, avatar: node.author?.avatarUrl, state }) - } - } - return [...map.values()] -} - -const REVIEWER_STATE: Record = { - APPROVED: "approved", - CHANGES_REQUESTED: "changes_requested", - COMMENTED: "commented", -} - /** Run async thunks with bounded concurrency, returning settled results. */ async function settled(thunks: (() => Promise)[], concurrency: number): Promise[]> { const results: PromiseSettledResult[] = new Array(thunks.length) diff --git a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts b/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts new file mode 100644 index 00000000000..976b486db80 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts @@ -0,0 +1,119 @@ +import type { PRState, ReviewDecision, CheckStatus, PRComment, PRReviewer } from "./types" + +// Raw shapes returned by `gh pr view --json` + +export interface GhAuthor { login?: string; avatarUrl?: string } +export interface GhComment { id: string; author?: GhAuthor; body?: string; path?: string; line?: number; url?: string; createdAt?: string } +export interface GhThread { isResolved?: boolean; comments?: { nodes?: GhComment[] } } +export interface GhReviewRequest { requestedReviewer?: GhAuthor } +export interface GhReview { author?: GhAuthor; state?: string } + +export interface PRResult { + number: number + title: string + body: string + url: string + state: PRState + review: ReviewDecision | null + additions: number + deletions: number + files: number +} + +export function parsePRResult(json: string): PRResult | null { + const data = JSON.parse(json) + if (!data.number) return null + const state: PRState = data.isDraft ? "draft" : (data.state?.toLowerCase() as PRState) ?? "open" + const decision = data.reviewDecision as string | undefined + const review: ReviewDecision | null = + decision === "APPROVED" ? "approved" + : decision === "CHANGES_REQUESTED" ? "changes_requested" + : decision === "REVIEW_REQUIRED" ? "pending" + : null + return { + number: data.number, + title: data.title ?? "", + body: data.body ?? "", + url: data.url ?? "", + state, + review, + additions: data.additions ?? 0, + deletions: data.deletions ?? 0, + files: data.changedFiles ?? 0, + } +} + +export function checkStatus(state: string): CheckStatus { + switch (state.toUpperCase()) { + case "SUCCESS": + return "success" + case "FAILURE": + case "ERROR": + return "failure" + case "PENDING": + case "QUEUED": + case "IN_PROGRESS": + case "REQUESTED": + case "WAITING": + return "pending" + case "SKIPPED": + return "skipped" + case "CANCELLED": + case "TIMED_OUT": + case "STALE": + case "STARTUP_FAILURE": + return "cancelled" + default: + return "pending" + } +} + +export function formatCheckDuration(startedAt?: string, completedAt?: string): string | undefined { + if (!startedAt || !completedAt) return undefined + const secs = Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000) + return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s` +} + +const REVIEWER_STATE: Record = { + APPROVED: "approved", + CHANGES_REQUESTED: "changes_requested", + COMMENTED: "commented", +} + +export function parseComments(threads: GhThread[]): PRComment[] { + const items: PRComment[] = [] + for (const thread of threads) { + const first = thread.comments?.nodes?.[0] + if (!first) continue + items.push({ + id: first.id, + author: first.author?.login ?? "unknown", + avatar: first.author?.avatarUrl, + body: first.body ?? "", + file: first.path, + line: first.line, + url: first.url, + resolved: thread.isResolved ?? false, + createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined, + }) + } + return items +} + +export function parseReviewers(requests: GhReviewRequest[], reviews: GhReview[]): PRReviewer[] { + const map = new Map() + for (const node of requests) { + const user = node.requestedReviewer + if (!user?.login) continue + map.set(user.login, { login: user.login, avatar: user.avatarUrl, state: "pending" }) + } + for (const node of reviews) { + const login = node.author?.login + if (!login) continue + const state = REVIEWER_STATE[node.state ?? ""] ?? "pending" + if (!map.has(login) || state !== "commented") { + map.set(login, { login, avatar: node.author?.avatarUrl, state }) + } + } + return [...map.values()] +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 09b36c3439d..842a33b2540 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -175,7 +175,7 @@ import { createMarkdownRender } from "./review-preferences" import { createSidebarCollapse } from "./sidebar-collapse" import { SidebarToggleButton } from "./SidebarToggleButton" import { setTabWidths } from "./tab-widths" -import { clampPanelWidth, maxPanelWidth, minPanelWidth } from "./side-panel-layout" +import { clampPanelWidth, maxPanelWidth, minPanelWidth, SidePanel } from "./side-panel-layout" import { buildShortcutCategories } from "./shortcuts" import { tracker } from "./telemetry" import { createChatFocus, hasQuestionOption } from "./focus" @@ -195,7 +195,7 @@ interface SetupState { /** Sidebar selection: LOCAL for local repo, worktree ID for a worktree, or null for an unassigned session. */ type SidebarSelection = typeof LOCAL | string | null -type SidePanel = "diff" | "pr" | "terminal" | null +export type SidePanelState = SidePanel | null const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) // Fallback keybindings before extension sends resolved ones const MAX_JUMP_INDEX = 9 @@ -309,9 +309,9 @@ const AgentManagerContent: Component = () => { let pendingSideWidth: number | undefined const [history, setHistory] = createSignal(false) - const [sidePanel, setSidePanel] = createSignal(null) - const diffOpen = () => sidePanel() === "diff" - const prOpen = () => sidePanel() === "pr" + const [sidePanel, setSidePanel] = createSignal(null) + const diffOpen = () => sidePanel() === SidePanel.Diff + const prOpen = () => sidePanel() === SidePanel.PR const activePR = createMemo(() => { const selected = selection() if (!selected || selected === LOCAL) return undefined @@ -338,7 +338,7 @@ const AgentManagerContent: Component = () => { const showSideTerminal = () => { setHistory(false) setReviewActive(false) - setSidePanel("terminal") + setSidePanel(SidePanel.Terminal) } const [reviewOpenByContext, setReviewOpenByContext] = createSignal>({}) @@ -423,7 +423,7 @@ const AgentManagerContent: Component = () => { const rememberPromptFocus = (focused: boolean) => { if (focused) focusMemory.set(focusKey(), "prompt") } - const terminalVisible = () => sidePanel() === "terminal" && !history() && !reviewActive() + const terminalVisible = () => sidePanel() === SidePanel.Terminal && !history() && !reviewActive() const focusOnDraftChange = () => { const key = focusKey() const owner = focusMemory.get(key) @@ -551,7 +551,7 @@ const AgentManagerContent: Component = () => { const togglePRPanel = () => { setHistory(false) setReviewActive(false) - setSidePanel((prev) => (prev === "pr" ? null : "pr")) + setSidePanel((prev) => (prev === SidePanel.PR ? null : SidePanel.PR)) } const openSelectedPR = () => { @@ -1187,8 +1187,8 @@ const AgentManagerContent: Component = () => { } else if (msg.action === "toggleDiff") { if (reviewActive()) { closeReviewTab() - setSidePanel("diff") - } else setSidePanel((prev) => (prev === "diff" ? null : "diff")) + setSidePanel(SidePanel.Diff) + } else setSidePanel((prev) => (prev === SidePanel.Diff ? null : SidePanel.Diff)) } else if (msg.action === "newTab") handleNewTabForCurrentSelection() else if (msg.action === "closeTab") closeActiveTab() else if (msg.action === "newWorktree") showNewWorktreeDialog() @@ -1350,7 +1350,7 @@ const AgentManagerContent: Component = () => { onSideCreated: (contextKey, terminalId, focus) => { // Focus only when the user is still looking at this panel — // a slow create landing after a mode switch must not steal it. - if (focus && sidePanel() === "terminal" && !history() && !reviewActive() && terms.sideKey() === contextKey) { + if (focus && sidePanel() === SidePanel.Terminal && !history() && !reviewActive() && terms.sideKey() === contextKey) { terms.requestFocus(terminalId) } }, @@ -1419,7 +1419,7 @@ const AgentManagerContent: Component = () => { if (!isActivePayload(ev.projectId)) return // Close diff/review panels — nothing to show during setup. // Terminal panels keep live setup output, so they stay open. - if (sidePanel() === "diff") setSidePanel(null) + if (sidePanel() === SidePanel.Diff) setSidePanel(null) setReviewActive(false) setSetup({ active: true, message: ev.message, branch: ev.branch, worktreeId: ev.worktreeId }) } @@ -2090,7 +2090,7 @@ const AgentManagerContent: Component = () => { const sideCtl = createSideTerminal({ handlers: termHandlers, - visible: () => sidePanel() === "terminal" && !history() && !reviewActive(), + visible: () => sidePanel() === SidePanel.Terminal && !history() && !reviewActive(), focusedId: () => terms.sideFocusedId(), hide: () => { cancelAmbientSetup() @@ -2217,7 +2217,7 @@ const AgentManagerContent: Component = () => { // A focused side terminal owns Cmd+W while its panel is visible — // closing a chat tab out from under the user's cursor would be // surprising. Only that terminal dies; the panel keeps the rest. - if (sidePanel() === "terminal" && terms.sideFocusedId()) { + if (sidePanel() === SidePanel.Terminal && terms.sideFocusedId()) { if (sideCtl.close()) return } if (termHandlers.closeActive()) { @@ -2269,10 +2269,10 @@ const AgentManagerContent: Component = () => { }) if (reviewActive()) { closeReviewTab() - setSidePanel("diff") + setSidePanel(SidePanel.Diff) return } - setSidePanel((prev) => (prev === "diff" ? null : "diff")) + setSidePanel((prev) => (prev === SidePanel.Diff ? null : SidePanel.Diff)) } const renderTabById = (id: string) => @@ -2470,7 +2470,7 @@ const AgentManagerContent: Component = () => { prOpen={prOpen} onTogglePR={togglePRPanel} terminalDestination={sideCtl.destination} - terminalDestinationActive={() => sidePanel() === "terminal"} + terminalDestinationActive={() => sidePanel() === SidePanel.Terminal} terminalKeybind={() => kb().showTerminal ?? ""} onTerminalDestinationOpen={() => { cancelAmbientSetup() @@ -2658,7 +2658,7 @@ const AgentManagerContent: Component = () => { />
- + { activeTerminalId={terms.activeId()} /> - + {(() => { const data = activePR()! return ( @@ -2709,7 +2709,7 @@ const AgentManagerContent: Component = () => { sidePanel() === "terminal"} + visible={() => sidePanel() === SidePanel.Terminal} onSelect={(id) => termHandlers.selectSide(id)} onClose={(id) => { cancelAmbientSetup() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx index b6fa2d92898..69378459144 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx @@ -359,7 +359,7 @@ export const WorktreeItem: Component = (props) => { - + diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 88eccf3932c..75953b058b5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -869,7 +869,7 @@ html[data-theme="kilo-vscode"] .am-pr-accent-draft, .am-pr-panel-badge.am-pr-accent-draft { --pr-accent: var(--text-weaker); } .am-pr-accent-merged, -.am-pr-panel-badge.am-pr-accent-merged { --pr-accent: var(--vscode-charts-purple, #a78bfa); } +.am-pr-panel-badge.am-pr-accent-merged { --pr-accent: var(--vscode-charts-purple, var(--vscode-foreground)); } .am-pr-accent-closed, .am-pr-panel-badge.am-pr-accent-closed { --pr-accent: var(--vscode-testing-iconFailed, #f87171); } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx index cb54b3ee913..d77213de726 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx @@ -5,7 +5,7 @@ import type { PRStatus } from "../../src/types/messages" import { prBadgeIndicator, prChecksRunning } from "../WorktreeItem" const INDICATOR_ICON: Record = { - failure: "circle-x", + failure: "circle-x-outline", changes: "warning", approved: "circle-check", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts b/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts index 8cfaafe4f9e..ed5cd0097a1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts @@ -2,6 +2,12 @@ export const MIN_PANEL_WIDTH = 360 const DEFAULT_PANEL_WIDTH_RATIO = 0.5 const MAX_PANEL_WIDTH_RATIO = 0.8 +export enum SidePanel { + Diff = "diff", + PR = "pr", + Terminal = "terminal", +} + function viewportWidth(viewport: number): number { return Number.isFinite(viewport) && viewport > 0 ? viewport : MIN_PANEL_WIDTH } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/ambient.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/ambient.ts index aac693f50e7..c674c80d79f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/ambient.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/ambient.ts @@ -13,11 +13,12 @@ import { createEffect, createSignal, type Accessor } from "solid-js" import type { ScriptTerminalStatus, TerminalStateControls } from "./state" import type { TerminalTabStateWithContext } from "./state" +import type { SidePanelState } from "../AgentManagerApp" interface AmbientSetupDeps { terms: TerminalStateControls selection: Accessor - sidePanel: Accessor + sidePanel: Accessor setSidePanel(panel: null): void } From 907a5f65863241e27244716c4e2197a24f4ba7bc Mon Sep 17 00:00:00 2001 From: cmanu Date: Thu, 6 Aug 2026 10:55:19 -0700 Subject: [PATCH 15/54] feat(pr-view): Handle gh auth errors in the ui --- .../src/agent-manager/PRStatusPoller.ts | 4 +++- .../src/agent-manager/pr-status-bridge.ts | 15 +++++++++++++++ packages/kilo-vscode/src/agent-manager/types.ts | 6 ++++++ .../webview-ui/agent-manager/AgentManagerApp.tsx | 6 ++++++ .../webview-ui/agent-manager/i18n/en.ts | 4 ++++ .../src/types/messages/extension-messages.ts | 6 ++++++ 6 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index 813c79f25d9..a4fa6ac7aa0 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -18,6 +18,7 @@ interface PRStatusPollerOptions { } const GH_PROBE_TTL = 300_000 // 5 minutes — gh installation state rarely changes at runtime +const GH_PROBE_FAILURE_TTL = 30_000 // 30 seconds — retry faster after a failed probe const MAX_BACKOFF = 120_000 // 2 minutes — cap for exponential backoff on repeated errors const BACKOFF_MULTIPLIER = 2 const PR_LOOKUP_TTL = 10_000 // 10 seconds — short TTL; only the active worktree polls so this stays cheap @@ -164,7 +165,8 @@ export class PRStatusPoller { private async probeGh(): Promise { const now = Date.now() - if (this.ghAvailable !== undefined && now - this.ghProbeTime < GH_PROBE_TTL) { + const ttl = this.ghAvailable === false ? GH_PROBE_FAILURE_TTL : GH_PROBE_TTL + if (this.ghAvailable !== undefined && now - this.ghProbeTime < ttl) { return this.ghAvailable } try { diff --git a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts index c64da3971eb..6eb742fbd4e 100644 --- a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts @@ -31,6 +31,7 @@ export class PRStatusBridge { readonly poller: PRStatusPoller private readonly cache = new Map() private readonly host: PRBridgeHost + private lastErrorNotified: "gh_missing" | "gh_auth" | "fetch_failed" | undefined constructor(host: PRBridgeHost) { this.host = host @@ -61,6 +62,8 @@ export class PRStatusBridge { /** Replay cached PR statuses to a freshly-connected webview. */ replay(): void { this.cache.forEach((msg) => this.host.postToWebview(msg)) + if (this.lastErrorNotified === "gh_auth" || this.lastErrorNotified === "gh_missing") + this.host.postToWebview({ type: "agentManager.prError", error: this.lastErrorNotified } as AgentManagerOutMessage) } snapshot(): Map { @@ -93,6 +96,13 @@ export class PRStatusBridge { reset(): void { this.poller.stop() this.cache.clear() + this.lastErrorNotified = undefined + } + + notifyError(err: "gh_missing" | "gh_auth" | "fetch_failed"): void { + if (this.lastErrorNotified === err) return + this.lastErrorNotified = err + this.host.postToWebview({ type: "agentManager.prError", error: err } as AgentManagerOutMessage) } } @@ -115,6 +125,11 @@ function bridgePollerOpts(bridge: PRStatusBridge, host: PRBridgeHost) { pr: null, error: err, } as AgentManagerOutMessage) + // Always forward auth/missing errors so the webview can show a toast, + // regardless of whether prior data exists. Deduplicate per error type + // so multiple failing worktrees don't produce multiple toasts. + if (err === "gh_auth" || err === "gh_missing") + bridge.notifyError(err) return } const msg = { type: "agentManager.prStatus", worktreeId: id, pr, error: err } as AgentManagerOutMessage diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 186ac19f2ec..39f2844ab6d 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -385,6 +385,11 @@ interface PRStatusOutMessage { error?: "gh_missing" | "gh_auth" | "fetch_failed" } +interface PRErrorOutMessage { + type: "agentManager.prError" + error: "gh_missing" | "gh_auth" | "fetch_failed" +} + interface ActionOutMessage { type: "action" action: string @@ -424,6 +429,7 @@ export type AgentManagerOutMessage = | RevertWorktreeFileResultMessage | DiffBranchesMessage | PRStatusOutMessage + | PRErrorOutMessage | ActionOutMessage | RunStatusMessage | TerminalCreatedMessage diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 842a33b2540..2c822a35e38 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -37,6 +37,7 @@ import type { RunStatus, PRStatus, AgentManagerPRStatusMessage, + AgentManagerPRErrorMessage, AgentManagerProjectsMessage, AgentProjectSnapshot, ManagedSessionState, @@ -1566,6 +1567,11 @@ const AgentManagerContent: Component = () => { managedSession: focusManagedSession, }) + if (msg.type === "agentManager.prError") { + const ev = msg as AgentManagerPRErrorMessage + showToast({ variant: "error", title: t(`agentManager.pr.error.${ev.error}.title`), description: t(`agentManager.pr.error.${ev.error}.description`) }) + } + if (projectLive.apply(msg)) return }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index 56954f2c7ac..382bddd0451 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -214,4 +214,8 @@ export const dict = { "agentManager.import.noBranchesFound": "No branches found.", "agentManager.import.noBranchesHint": "Paste a PR URL above or create a new worktree.", "agentManager.import.failed": "Import failed", + "agentManager.pr.error.gh_auth.title": "GitHub authentication required", + "agentManager.pr.error.gh_auth.description": "Run 'gh auth login' in your terminal to restore PR status.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI not installed", + "agentManager.pr.error.gh_missing.description": "Install the GitHub CLI to enable PR status.", } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index d0fb0339252..a3e3b16eea8 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -1034,6 +1034,11 @@ export interface AgentManagerPRStatusMessage { error?: "gh_missing" | "gh_auth" | "fetch_failed" } +export interface AgentManagerPRErrorMessage { + type: "agentManager.prError" + error: "gh_missing" | "gh_auth" | "fetch_failed" +} + // Sidebar: Live worktree diff stats (extension → webview) export interface WorktreeStatsLoadedMessage { type: "worktreeStatsLoaded" @@ -1397,6 +1402,7 @@ export type ExtensionMessage = | AgentManagerWorktreeStatsMessage | AgentManagerLocalStatsMessage | AgentManagerPRStatusMessage + | AgentManagerPRErrorMessage | AgentManagerTerminalCreatedMessage | AgentManagerTerminalRestartedMessage | AgentManagerTerminalFontChangedMessage From 7415a47248684eb0693270c496028ddf684d7b9d Mon Sep 17 00:00:00 2001 From: cmanu Date: Thu, 6 Aug 2026 14:33:52 -0700 Subject: [PATCH 16/54] fix(pr-view): merge 'main' causes line count linter error so need to move code into new files --- .../agent-manager/AgentManagerApp.tsx | 113 ++-------------- .../agent-manager/default-base-branch.tsx | 121 ++++++++++++++++++ .../webview-ui/src/context/session-merge.ts | 41 ++++++ .../webview-ui/src/context/session.tsx | 40 +----- 4 files changed, 178 insertions(+), 137 deletions(-) create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/default-base-branch.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/context/session-merge.ts diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 7d6aa4195be..2e6b55063d5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -21,7 +21,6 @@ import type { AgentManagerKeybindingsMessage, AgentManagerMultiVersionProgressMessage, AgentManagerSendInitialMessage, - AgentManagerBranchesMessage, AgentManagerWorktreeDiffMessage, AgentManagerWorktreeDiffFileMessage, AgentManagerWorktreeDiffLoadingMessage, @@ -44,7 +43,6 @@ import type { SectionState, SessionInfo, SessionCreatedMessage, - BranchInfo, TerminalDestination, TerminalFont, } from "../src/types/messages" @@ -154,8 +152,10 @@ import type { ReviewComment } from "../diff-viewer/review-comments" import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review-annotations" import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" import { createSidebarSearch, type SidebarSearchItem } from "./sidebar-search" -import { BranchSelect } from "../src/components/shared/BranchSelect" import { randomColor } from "./section-colors" +import { createMarkdownRender } from "./review-preferences" +import { createChangeDefaultBaseBranch } from "./default-base-branch" +import { createSidebarCollapse } from "./sidebar-collapse" import { createNewTaskDrafts } from "./new-task-drafts" import { buildTopLevelItems, @@ -174,8 +174,6 @@ import { DiffScopeControls } from "../diff-viewer/DiffScopeControls" import { scopeCapabilities } from "./diff-scope-state" import { createDiffReviewScope } from "./diff-review-scope" import { initialMessage, seedInitialVariant } from "./initial-message" -import { createMarkdownRender } from "./review-preferences" -import { createSidebarCollapse } from "./sidebar-collapse" import { SidebarToggleButton } from "./SidebarToggleButton" import { setTabWidths } from "./tab-widths" import { clampPanelWidth, maxPanelWidth, minPanelWidth, SidePanel } from "./side-panel-layout" @@ -1793,101 +1791,16 @@ const AgentManagerContent: Component = () => { } const setupScript = metrics.click("configure_setup_script", "worktree_settings", handleConfigureSetupScript) - const handleChangeDefaultBaseBranch = () => { - const [search, setSearch] = createSignal("") - const [branches, setBranches] = createSignal([]) - const [loading, setLoading] = createSignal(true) - const [highlighted, setHighlighted] = createSignal(-1) - - const unsub = vscode.onMessage((msg) => { - if (msg.type === "agentManager.branches") { - const ev = msg as AgentManagerBranchesMessage - setBranches(ev.branches) - if (ev.defaultBranch) setRepoDetectedBranch(ev.defaultBranch) - setLoading(false) - } - }) - - vscode.postMessage({ type: "agentManager.requestBranches" }) - - const filtered = createMemo(() => { - const s = search().toLowerCase() - if (!s) return branches() - return branches().filter((b) => b.name.toLowerCase().includes(s)) - }) - - const selectBranch = (name: string | undefined) => { - vscode.postMessage({ type: "agentManager.setDefaultBaseBranch", branch: name }) - setDefaultBaseBranch(name) - dialog.close() - } - - const handleKeyDown = (e: KeyboardEvent) => { - const items = filtered() - // offset by 1 for auto-detect option (-1 = auto-detect) - const total = items.length + 1 - if (e.key === "ArrowDown") { - e.preventDefault() - e.stopPropagation() - setHighlighted((prev) => Math.min(prev + 1, total - 2)) - } else if (e.key === "ArrowUp") { - e.preventDefault() - e.stopPropagation() - setHighlighted((prev) => Math.max(prev - 1, -1)) - } else if (e.key === "Enter") { - e.preventDefault() - e.stopPropagation() - const idx = highlighted() - if (idx === -1) { - selectBranch(undefined) - } else { - const branch = items[idx] - if (branch) selectBranch(branch.name) - } - } else if (e.key === "Escape") { - e.preventDefault() - e.stopPropagation() - dialog.close() - } - } - - dialog.show(() => { - onCleanup(unsub) - return ( - -
- { - setSearch(v) - setHighlighted(-1) - }} - onSelect={(b) => selectBranch(b.name)} - onSearchKeyDown={handleKeyDown} - selected={defaultBaseBranch()} - highlighted={highlighted()} - onHighlight={setHighlighted} - searchPlaceholder={t("agentManager.dialog.searchBranches")} - emptyLabel={t("agentManager.import.noMatchingBranches")} - loadingLabel={t("agentManager.import.loadingBranches")} - defaultLabel={t("agentManager.dialog.branchBadge.default")} - remoteLabel={t("agentManager.dialog.branchBadge.remote")} - defaultName={defaultBaseBranch()} - autoOption={{ - label: t("agentManager.worktree.defaultBaseBranchAuto"), - hint: repoDetectedBranch(), - active: !hasConfiguredBranch(), - highlighted: highlighted() === -1, - onSelect: () => selectBranch(undefined), - }} - /> -
-
- ) - }) - } + const handleChangeDefaultBaseBranch = createChangeDefaultBaseBranch({ + vscode, + dialog, + t, + defaultBaseBranch, + setDefaultBaseBranch, + setRepoDetectedBranch, + repoDetectedBranch, + hasConfiguredBranch, + }) const handleShowKeyboardShortcuts = () => { const categories = buildShortcutCategories(kb(), t) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/default-base-branch.tsx b/packages/kilo-vscode/webview-ui/agent-manager/default-base-branch.tsx new file mode 100644 index 00000000000..53c9b77443d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/default-base-branch.tsx @@ -0,0 +1,121 @@ +/** @jsxImportSource solid-js */ + +import { createSignal, createMemo, onCleanup } from "solid-js" +import type { AgentManagerBranchesMessage, BranchInfo } from "../src/types/messages" +import { Dialog } from "@kilocode/kilo-ui/dialog" +import { useDialog } from "@kilocode/kilo-ui/context/dialog" +import { BranchSelect } from "../src/components/shared/BranchSelect" +import type { useVSCode } from "../src/context/vscode" +import type { useLanguage } from "../src/context/language" +import type { Accessor, Setter } from "solid-js" + +interface Deps { + vscode: ReturnType + dialog: ReturnType + t: ReturnType["t"] + defaultBaseBranch: Accessor + setDefaultBaseBranch: (v: Parameters>[0]) => void + setRepoDetectedBranch: Setter + repoDetectedBranch: Accessor + hasConfiguredBranch: Accessor +} + +export function createChangeDefaultBaseBranch(deps: Deps) { + const { vscode, dialog, t, defaultBaseBranch, setDefaultBaseBranch, setRepoDetectedBranch, repoDetectedBranch, hasConfiguredBranch } = deps + + return () => { + const [search, setSearch] = createSignal("") + const [branches, setBranches] = createSignal([]) + const [loading, setLoading] = createSignal(true) + const [highlighted, setHighlighted] = createSignal(-1) + + const unsub = vscode.onMessage((msg) => { + if (msg.type === "agentManager.branches") { + const ev = msg as AgentManagerBranchesMessage + setBranches(ev.branches) + if (ev.defaultBranch) setRepoDetectedBranch(ev.defaultBranch) + setLoading(false) + } + }) + + vscode.postMessage({ type: "agentManager.requestBranches" }) + + const filtered = createMemo(() => { + const s = search().toLowerCase() + if (!s) return branches() + return branches().filter((b) => b.name.toLowerCase().includes(s)) + }) + + const selectBranch = (name: string | undefined) => { + vscode.postMessage({ type: "agentManager.setDefaultBaseBranch", branch: name }) + setDefaultBaseBranch(name) + dialog.close() + } + + const handleKeyDown = (e: KeyboardEvent) => { + const items = filtered() + // offset by 1 for auto-detect option (-1 = auto-detect) + const total = items.length + 1 + if (e.key === "ArrowDown") { + e.preventDefault() + e.stopPropagation() + setHighlighted((prev) => Math.min(prev + 1, total - 2)) + } else if (e.key === "ArrowUp") { + e.preventDefault() + e.stopPropagation() + setHighlighted((prev) => Math.max(prev - 1, -1)) + } else if (e.key === "Enter") { + e.preventDefault() + e.stopPropagation() + const idx = highlighted() + if (idx === -1) { + selectBranch(undefined) + } else { + const branch = items[idx] + if (branch) selectBranch(branch.name) + } + } else if (e.key === "Escape") { + e.preventDefault() + e.stopPropagation() + dialog.close() + } + } + + dialog.show(() => { + onCleanup(unsub) + return ( + +
+ { + setSearch(v) + setHighlighted(-1) + }} + onSelect={(b) => selectBranch(b.name)} + onSearchKeyDown={handleKeyDown} + selected={defaultBaseBranch()} + highlighted={highlighted()} + onHighlight={setHighlighted} + searchPlaceholder={t("agentManager.dialog.searchBranches")} + emptyLabel={t("agentManager.import.noMatchingBranches")} + loadingLabel={t("agentManager.import.loadingBranches")} + defaultLabel={t("agentManager.dialog.branchBadge.default")} + remoteLabel={t("agentManager.dialog.branchBadge.remote")} + defaultName={defaultBaseBranch()} + autoOption={{ + label: t("agentManager.worktree.defaultBaseBranchAuto"), + hint: repoDetectedBranch(), + active: !hasConfiguredBranch(), + highlighted: highlighted() === -1, + onSelect: () => selectBranch(undefined), + }} + /> +
+
+ ) + }) + } +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session-merge.ts b/packages/kilo-vscode/webview-ui/src/context/session-merge.ts new file mode 100644 index 00000000000..ac9264cf238 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/session-merge.ts @@ -0,0 +1,41 @@ +import type { Message, MessageLoadMode, Part } from "../types/messages" +import { sameParts } from "./session-parts" + +export function mergeMessages(current: Message[], incoming: Message[], mode: Exclude) { + if (mode === "reconcile") { + // Tail reconcile: incoming is the authoritative newest-N snapshot. + // Local state may already hold some of those IDs and may also hold + // newer optimistic entries created after the fetch was taken. Merge + // by id (server wins on collision) then sort by createdAt so new + // server messages land in the right position and optimistic tail + // entries stay at the end. + const byId = new Map() + for (const msg of current) byId.set(msg.id, msg) + for (const msg of incoming) byId.set(msg.id, msg) + return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + } + const seen = new Set() + const source = mode === "prepend" ? [...incoming, ...current] : incoming + return source.filter((msg) => { + if (seen.has(msg.id)) return false + seen.add(msg.id) + return true + }) +} + +// Cheap tail check: same ids in the same order and no visible streamed-part +// correction to apply. It skips store churn when SSE already matches the +// snapshot, but lets reconcile heal part removals and finalized text. +export function sameReconcileShape( + current: Message[], + incoming: Message[], + getParts: (messageID: string) => Part[] | undefined, +): boolean { + if (current.length !== incoming.length) return false + for (const [i, n] of incoming.entries()) { + const c = current[i]! + if (c.id !== n.id) return false + if (!sameParts(getParts(c.id) ?? c.parts, n.parts)) return false + } + return true +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 839b0dc3f50..302d1c8727f 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -72,7 +72,8 @@ import { getAgentModel } from "./session-model-store" import { resolveMessagePrefs } from "./session-preferences" import { errorIDs } from "./session-errors" import { PartStash } from "./part-stash" -import { mergeParts, sameParts } from "./session-parts" +import { mergeParts } from "./session-parts" +import { mergeMessages, sameReconcileShape } from "./session-merge" import { state as todoState } from "./todo-revert" import { getAgentVariant, @@ -1423,28 +1424,6 @@ export const SessionProvider: ParentComponent = (props) => { setPages(sessionID, { ...(pages[sessionID] ?? emptyPageState), ...patch }) } - function mergeMessages(current: Message[], incoming: Message[], mode: Exclude) { - if (mode === "reconcile") { - // Tail reconcile: incoming is the authoritative newest-N snapshot. - // Local state may already hold some of those IDs and may also hold - // newer optimistic entries created after the fetch was taken. Merge - // by id (server wins on collision) then sort by createdAt so new - // server messages land in the right position and optimistic tail - // entries stay at the end. - const byId = new Map() - for (const msg of current) byId.set(msg.id, msg) - for (const msg of incoming) byId.set(msg.id, msg) - return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) - } - const seen = new Set() - const source = mode === "prepend" ? [...incoming, ...current] : incoming - return source.filter((msg) => { - if (seen.has(msg.id)) return false - seen.add(msg.id) - return true - }) - } - function recoverPrefs(sessionID: string, messages: Message[], names = agentNames()) { const prefs = resolveMessagePrefs(messages, names) if (prefs.agent && !store.agentSelections[sessionID]) { @@ -1469,19 +1448,6 @@ export const SessionProvider: ParentComponent = (props) => { return [...messages, ...orphans] } - // Cheap tail check: same ids in the same order and no visible streamed-part - // correction to apply. It skips store churn when SSE already matches the - // snapshot, but lets reconcile heal part removals and finalized text. - function sameReconcileShape(current: Message[], incoming: Message[]): boolean { - if (current.length !== incoming.length) return false - for (const [i, n] of incoming.entries()) { - const c = current[i]! - if (c.id !== n.id) return false - if (!sameParts(store.parts[c.id] ?? c.parts, n.parts)) return false - } - return true - } - function setTools(sessionID: string, tools: ToolPart[]) { setStore("toolParts", sessionID, reconcileSessionToolParts(tools)) } @@ -1530,7 +1496,7 @@ export const SessionProvider: ParentComponent = (props) => { // Reconcile fast-path: if the tail matches local state shape-wise, every // message+part-count already agrees with the server. Skip the reactive // store churn entirely — virtualizer and rendering stay untouched. - if (mode === "reconcile" && sameReconcileShape(store.messages[sessionID] ?? [], messages)) { + if (mode === "reconcile" && sameReconcileShape(store.messages[sessionID] ?? [], messages, (id) => store.parts[id])) { const parts = messageParts(messages) for (const msg of messages) { if (store.parts[msg.id]) delete parts[msg.id] From 0979a576e2d78d4cc2990adb2e1af42d04a04c9c Mon Sep 17 00:00:00 2001 From: cmanu Date: Thu, 6 Aug 2026 14:53:24 -0700 Subject: [PATCH 17/54] feat(pr-view): organize types --- .../src/agent-manager/PRStatusPoller.ts | 2 +- .../src/agent-manager/am-pr-types.ts | 21 +++++++++++++++ .../src/agent-manager/am-pr-utils.ts | 27 +++---------------- 3 files changed, 26 insertions(+), 24 deletions(-) create mode 100644 packages/kilo-vscode/src/agent-manager/am-pr-types.ts diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index 077552ccf38..6ce5e6b748d 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -6,7 +6,7 @@ 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-utils" +import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types" interface PRStatusPollerOptions { getWorktrees: () => Worktree[] diff --git a/packages/kilo-vscode/src/agent-manager/am-pr-types.ts b/packages/kilo-vscode/src/agent-manager/am-pr-types.ts new file mode 100644 index 00000000000..59f4604c96b --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/am-pr-types.ts @@ -0,0 +1,21 @@ +import type { PRState, ReviewDecision } from "./types" + +// Raw shapes returned by `gh pr view --json` + +export interface GhAuthor { login?: string; avatarUrl?: string } +export interface GhComment { id: string; author?: GhAuthor; body?: string; path?: string; line?: number; url?: string; createdAt?: string } +export interface GhThread { isResolved?: boolean; comments?: { nodes?: GhComment[] } } +export interface GhReviewRequest { requestedReviewer?: GhAuthor } +export interface GhReview { author?: GhAuthor; state?: string } + +export interface PRResult { + number: number + title: string + body: string + url: string + state: PRState + review: ReviewDecision | null + additions: number + deletions: number + files: number +} diff --git a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts b/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts index 976b486db80..8a45349bf61 100644 --- a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts +++ b/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts @@ -1,31 +1,12 @@ -import type { PRState, ReviewDecision, CheckStatus, PRComment, PRReviewer } from "./types" - -// Raw shapes returned by `gh pr view --json` - -export interface GhAuthor { login?: string; avatarUrl?: string } -export interface GhComment { id: string; author?: GhAuthor; body?: string; path?: string; line?: number; url?: string; createdAt?: string } -export interface GhThread { isResolved?: boolean; comments?: { nodes?: GhComment[] } } -export interface GhReviewRequest { requestedReviewer?: GhAuthor } -export interface GhReview { author?: GhAuthor; state?: string } - -export interface PRResult { - number: number - title: string - body: string - url: string - state: PRState - review: ReviewDecision | null - additions: number - deletions: number - files: number -} +import type { CheckStatus, PRComment, PRReviewer } from "./types" +import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types" export function parsePRResult(json: string): PRResult | null { const data = JSON.parse(json) if (!data.number) return null - const state: PRState = data.isDraft ? "draft" : (data.state?.toLowerCase() as PRState) ?? "open" + const state = data.isDraft ? "draft" : (data.state?.toLowerCase() ?? "open") const decision = data.reviewDecision as string | undefined - const review: ReviewDecision | null = + const review = decision === "APPROVED" ? "approved" : decision === "CHANGES_REQUESTED" ? "changes_requested" : decision === "REVIEW_REQUIRED" ? "pending" From 0c22c49d438841917f8a1829970f9e3c18338c5a Mon Sep 17 00:00:00 2001 From: cmanu Date: Thu, 6 Aug 2026 15:21:12 -0700 Subject: [PATCH 18/54] feat(pr-view): add unit tests for status bridge and pr utils --- .../tests/unit/am-pr-status-bridge.test.ts | 186 +++++++++++++++ .../tests/unit/am-pr-utils.test.ts | 214 ++++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts create mode 100644 packages/kilo-vscode/tests/unit/am-pr-utils.test.ts diff --git a/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts b/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts new file mode 100644 index 00000000000..4752844023e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "bun:test" +import { PRStatusBridge } from "../../src/agent-manager/pr-status-bridge" +import type { AgentManagerOutMessage, PRStatus } from "../../src/agent-manager/types" + +const pr: PRStatus = { + number: 1, + title: "my PR", + url: "https://github.com/x/y/pull/1", + state: "open", + review: null, + checks: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] }, + additions: 0, + deletions: 0, + files: 0, +} + +function harness(opts: { hasPersisted?: boolean } = {}) { + const sent: AgentManagerOutMessage[] = [] + const worktrees: { id: string; prUrl?: string }[] = [] + const bridge = PRStatusBridge.create({ + getWorktrees: () => worktrees as never, + getWorkspaceRoot: () => "/repo", + postToWebview: (msg) => sent.push(msg), + updateWorktreePR: () => {}, + hasPersistedPR: () => opts.hasPersisted ?? false, + openExternal: () => {}, + log: () => {}, + }) + const onStatus = (bridge.poller as unknown as { options: { onStatus: (...a: unknown[]) => void } }).options.onStatus + return { bridge, sent, onStatus } +} + +// --- error deduplication --- + +describe("PRStatusBridge.notifyError", () => { + it("sends the first error notification", () => { + const { bridge, sent } = harness() + bridge.notifyError("gh_missing") + expect(sent).toHaveLength(1) + expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prError", error: "gh_missing" })) + }) + + it("deduplicates the same error type", () => { + const { bridge, sent } = harness() + bridge.notifyError("gh_auth") + bridge.notifyError("gh_auth") + expect(sent).toHaveLength(1) + }) + + it("sends again when error type changes", () => { + const { bridge, sent } = harness() + bridge.notifyError("gh_missing") + bridge.notifyError("gh_auth") + expect(sent).toHaveLength(2) + }) +}) + +// --- onStatus cache suppression --- + +describe("PRStatusBridge onStatus", () => { + it("forwards a successful status to the webview", () => { + const { sent, onStatus } = harness() + onStatus("wt1", pr) + expect(sent).toHaveLength(1) + expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prStatus", worktreeId: "wt1", pr })) + }) + + it("forwards pr:null error when no cache entry and no persisted PR", () => { + const { sent, onStatus } = harness() + onStatus("wt1", null, "fetch_failed") + expect(sent).toHaveLength(1) + expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prStatus", worktreeId: "wt1", pr: null })) + }) + + it("suppresses pr:null error when cache entry exists", () => { + const { sent, onStatus } = harness() + onStatus("wt1", pr) + sent.length = 0 + onStatus("wt1", null, "fetch_failed") + expect(sent).toHaveLength(0) + }) + + it("suppresses pr:null error when persisted PR exists", () => { + const { sent, onStatus } = harness({ hasPersisted: true }) + onStatus("wt1", null, "fetch_failed") + expect(sent).toHaveLength(0) + }) + + it("forwards gh_auth error even when cache entry exists", () => { + const { sent, onStatus } = harness() + onStatus("wt1", pr) + sent.length = 0 + onStatus("wt1", null, "gh_auth") + const errorMsg = sent.find((m) => m.type === "agentManager.prError") + expect(errorMsg).toEqual(expect.objectContaining({ error: "gh_auth" })) + }) + + it("forwards gh_missing error even when cache entry exists", () => { + const { sent, onStatus } = harness() + onStatus("wt1", pr) + sent.length = 0 + onStatus("wt1", null, "gh_missing") + const errorMsg = sent.find((m) => m.type === "agentManager.prError") + expect(errorMsg).toEqual(expect.objectContaining({ error: "gh_missing" })) + }) +}) + +// --- replay --- + +describe("PRStatusBridge.replay", () => { + it("replays cached status messages", () => { + const { bridge, sent, onStatus } = harness() + onStatus("wt1", pr) + sent.length = 0 + bridge.replay() + expect(sent).toHaveLength(1) + expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prStatus", worktreeId: "wt1" })) + }) + + it("replays the last auth error on reconnect", () => { + const { bridge, sent, onStatus } = harness() + onStatus("wt1", null, "gh_auth") + sent.length = 0 + bridge.replay() + expect(sent.some((m) => m.type === "agentManager.prError" && (m as never as { error: string }).error === "gh_auth")).toBe(true) + }) + + it("does not replay fetch_failed errors", () => { + const { bridge, sent, onStatus } = harness() + onStatus("wt1", null, "fetch_failed") + sent.length = 0 + bridge.replay() + expect(sent).toHaveLength(0) + }) +}) + +// --- snapshot --- + +describe("PRStatusBridge.snapshot", () => { + it("returns only entries with a non-null pr", () => { + const { bridge, onStatus } = harness() + onStatus("wt1", pr) + onStatus("wt2", pr) + expect(bridge.snapshot().size).toBe(2) + }) + + it("excludes entries where pr was null", () => { + const { bridge, onStatus } = harness({ hasPersisted: true }) + onStatus("wt1", null, "fetch_failed") + expect(bridge.snapshot().size).toBe(0) + }) +}) + +// --- remove / reset --- + +describe("PRStatusBridge.remove", () => { + it("removes a cached entry so it is no longer replayed", () => { + const { bridge, sent, onStatus } = harness() + onStatus("wt1", pr) + bridge.remove("wt1") + sent.length = 0 + bridge.replay() + expect(sent).toHaveLength(0) + }) +}) + +describe("PRStatusBridge.reset", () => { + it("clears cache and error state so replay sends nothing", () => { + const { bridge, sent, onStatus } = harness() + onStatus("wt1", pr) + bridge.notifyError("gh_auth") + bridge.reset() + sent.length = 0 + bridge.replay() + expect(sent).toHaveLength(0) + }) + + it("allows the same error to be sent again after reset", () => { + const { bridge, sent } = harness() + bridge.notifyError("gh_auth") + bridge.reset() + sent.length = 0 + bridge.notifyError("gh_auth") + expect(sent).toHaveLength(1) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts b/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts new file mode 100644 index 00000000000..e16ab9c259d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "bun:test" +import { + parsePRResult, + checkStatus, + formatCheckDuration, + parseComments, + parseReviewers, +} from "../../src/agent-manager/am-pr-utils" +import type { GhThread, GhReviewRequest, GhReview } from "../../src/agent-manager/am-pr-types" + +// --- parsePRResult --- + +describe("parsePRResult", () => { + it("returns null when number is missing", () => { + expect(parsePRResult(JSON.stringify({ title: "foo" }))).toBeNull() + }) + + it("parses an open PR", () => { + const raw = { number: 42, title: "my PR", body: "desc", url: "https://github.com/x/y/pull/42", state: "OPEN", isDraft: false, reviewDecision: null, additions: 10, deletions: 3, changedFiles: 2 } + expect(parsePRResult(JSON.stringify(raw))).toEqual({ + number: 42, + title: "my PR", + body: "desc", + url: "https://github.com/x/y/pull/42", + state: "open", + review: null, + additions: 10, + deletions: 3, + files: 2, + }) + }) + + it("maps isDraft to draft state regardless of gh state field", () => { + const raw = { number: 1, title: "", body: "", url: "", state: "OPEN", isDraft: true, reviewDecision: null, additions: 0, deletions: 0, changedFiles: 0 } + expect(parsePRResult(JSON.stringify(raw))?.state).toBe("draft") + }) + + it("maps MERGED state", () => { + const raw = { number: 1, title: "", body: "", url: "", state: "MERGED", isDraft: false, reviewDecision: null, additions: 0, deletions: 0, changedFiles: 0 } + expect(parsePRResult(JSON.stringify(raw))?.state).toBe("merged") + }) + + it("maps CLOSED state", () => { + const raw = { number: 1, title: "", body: "", url: "", state: "CLOSED", isDraft: false, reviewDecision: null, additions: 0, deletions: 0, changedFiles: 0 } + expect(parsePRResult(JSON.stringify(raw))?.state).toBe("closed") + }) + + it("maps APPROVED review decision", () => { + const raw = { number: 1, title: "", body: "", url: "", state: "OPEN", isDraft: false, reviewDecision: "APPROVED", additions: 0, deletions: 0, changedFiles: 0 } + expect(parsePRResult(JSON.stringify(raw))?.review).toBe("approved") + }) + + it("maps CHANGES_REQUESTED review decision", () => { + const raw = { number: 1, title: "", body: "", url: "", state: "OPEN", isDraft: false, reviewDecision: "CHANGES_REQUESTED", additions: 0, deletions: 0, changedFiles: 0 } + expect(parsePRResult(JSON.stringify(raw))?.review).toBe("changes_requested") + }) + + it("maps REVIEW_REQUIRED review decision to pending", () => { + const raw = { number: 1, title: "", body: "", url: "", state: "OPEN", isDraft: false, reviewDecision: "REVIEW_REQUIRED", additions: 0, deletions: 0, changedFiles: 0 } + expect(parsePRResult(JSON.stringify(raw))?.review).toBe("pending") + }) + + it("returns null review for unknown decision", () => { + const raw = { number: 1, title: "", body: "", url: "", state: "OPEN", isDraft: false, reviewDecision: "SOMETHING_ELSE", additions: 0, deletions: 0, changedFiles: 0 } + expect(parsePRResult(JSON.stringify(raw))?.review).toBeNull() + }) + + it("defaults missing fields to empty strings and zeros", () => { + const result = parsePRResult(JSON.stringify({ number: 5 })) + expect(result).toEqual(expect.objectContaining({ title: "", body: "", url: "", additions: 0, deletions: 0, files: 0 })) + }) +}) + +// --- checkStatus --- + +describe("checkStatus", () => { + it("maps SUCCESS", () => expect(checkStatus("SUCCESS")).toBe("success")) + it("maps FAILURE", () => expect(checkStatus("FAILURE")).toBe("failure")) + it("maps ERROR to failure", () => expect(checkStatus("ERROR")).toBe("failure")) + it("maps PENDING", () => expect(checkStatus("PENDING")).toBe("pending")) + it("maps QUEUED to pending", () => expect(checkStatus("QUEUED")).toBe("pending")) + it("maps IN_PROGRESS to pending", () => expect(checkStatus("IN_PROGRESS")).toBe("pending")) + it("maps REQUESTED to pending", () => expect(checkStatus("REQUESTED")).toBe("pending")) + it("maps WAITING to pending", () => expect(checkStatus("WAITING")).toBe("pending")) + it("maps SKIPPED", () => expect(checkStatus("SKIPPED")).toBe("skipped")) + it("maps CANCELLED", () => expect(checkStatus("CANCELLED")).toBe("cancelled")) + it("maps TIMED_OUT to cancelled", () => expect(checkStatus("TIMED_OUT")).toBe("cancelled")) + it("maps STALE to cancelled", () => expect(checkStatus("STALE")).toBe("cancelled")) + it("maps STARTUP_FAILURE to cancelled", () => expect(checkStatus("STARTUP_FAILURE")).toBe("cancelled")) + it("maps unknown state to pending", () => expect(checkStatus("WHATEVER")).toBe("pending")) + it("is case-insensitive", () => expect(checkStatus("success")).toBe("success")) +}) + +// --- formatCheckDuration --- + +describe("formatCheckDuration", () => { + it("returns undefined when startedAt is missing", () => { + expect(formatCheckDuration(undefined, "2024-01-01T00:01:00Z")).toBeUndefined() + }) + + it("returns undefined when completedAt is missing", () => { + expect(formatCheckDuration("2024-01-01T00:00:00Z", undefined)).toBeUndefined() + }) + + it("formats sub-minute durations in seconds", () => { + expect(formatCheckDuration("2024-01-01T00:00:00Z", "2024-01-01T00:00:45Z")).toBe("45s") + }) + + it("formats durations over a minute as m/s", () => { + expect(formatCheckDuration("2024-01-01T00:00:00Z", "2024-01-01T00:02:30Z")).toBe("2m 30s") + }) + + it("formats exactly 60 seconds as 1m 0s", () => { + expect(formatCheckDuration("2024-01-01T00:00:00Z", "2024-01-01T00:01:00Z")).toBe("1m 0s") + }) +}) + +// --- parseComments --- + +describe("parseComments", () => { + it("returns empty array for empty threads", () => { + expect(parseComments([])).toEqual([]) + }) + + it("skips threads with no comments", () => { + const threads: GhThread[] = [{ isResolved: false, comments: { nodes: [] } }] + expect(parseComments(threads)).toHaveLength(0) + }) + + it("parses a resolved thread", () => { + const threads: GhThread[] = [{ + isResolved: true, + comments: { nodes: [{ id: "c1", author: { login: "alice", avatarUrl: "https://avatar" }, body: "looks good", path: "src/foo.ts", line: 10, url: "https://url", createdAt: "2024-01-01T00:00:00Z" }] }, + }] + expect(parseComments(threads)).toEqual([{ + id: "c1", + author: "alice", + avatar: "https://avatar", + body: "looks good", + file: "src/foo.ts", + line: 10, + url: "https://url", + resolved: true, + createdAt: new Date("2024-01-01T00:00:00Z").getTime(), + }]) + }) + + it("defaults missing author to 'unknown'", () => { + const threads: GhThread[] = [{ isResolved: false, comments: { nodes: [{ id: "c2", body: "note" }] } }] + expect(parseComments(threads)[0]?.author).toBe("unknown") + }) + + it("only uses the first comment of each thread", () => { + const threads: GhThread[] = [{ + isResolved: false, + comments: { nodes: [ + { id: "first", body: "first comment" }, + { id: "second", body: "second comment" }, + ]}, + }] + const result = parseComments(threads) + expect(result).toHaveLength(1) + expect(result[0]?.id).toBe("first") + }) +}) + +// --- parseReviewers --- + +describe("parseReviewers", () => { + it("returns empty array with no requests or reviews", () => { + expect(parseReviewers([], [])).toEqual([]) + }) + + it("adds pending reviewer from request", () => { + const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice", avatarUrl: "https://avatar" } }] + expect(parseReviewers(requests, [])).toEqual([{ login: "alice", avatar: "https://avatar", state: "pending" }]) + }) + + it("skips review requests without a login", () => { + const requests: GhReviewRequest[] = [{ requestedReviewer: {} }] + expect(parseReviewers(requests, [])).toHaveLength(0) + }) + + it("adds reviewer from review when not in requests", () => { + const reviews: GhReview[] = [{ author: { login: "bob" }, state: "APPROVED" }] + expect(parseReviewers([], reviews)).toEqual([{ login: "bob", avatar: undefined, state: "approved" }]) + }) + + it("upgrades pending request to approved when review arrives", () => { + const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice" } }] + const reviews: GhReview[] = [{ author: { login: "alice" }, state: "APPROVED" }] + expect(parseReviewers(requests, reviews)).toEqual([{ login: "alice", avatar: undefined, state: "approved" }]) + }) + + it("does not downgrade approved to commented", () => { + const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice" } }] + const reviews: GhReview[] = [ + { author: { login: "alice" }, state: "APPROVED" }, + { author: { login: "alice" }, state: "COMMENTED" }, + ] + expect(parseReviewers(requests, reviews)[0]?.state).toBe("approved") + }) + + it("does upgrade pending to changes_requested", () => { + const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice" } }] + const reviews: GhReview[] = [{ author: { login: "alice" }, state: "CHANGES_REQUESTED" }] + expect(parseReviewers(requests, reviews)[0]?.state).toBe("changes_requested") + }) + + it("skips reviews without a login", () => { + const reviews: GhReview[] = [{ author: {}, state: "APPROVED" }] + expect(parseReviewers([], reviews)).toHaveLength(0) + }) +}) From ead46f43415a121bacc28780bc47fe43b1b736ba Mon Sep 17 00:00:00 2001 From: niefangxin_yfmx Date: Fri, 7 Aug 2026 16:57:47 +0800 Subject: [PATCH 19/54] fix(ui): make Mermaid Copy PNG/SVG put images on the clipboard Copy PNG called fetch() on a data URL, which VS Code webview CSP rejects, so the copy silently did nothing. Decode the data URL locally instead. Copy SVG used clipboard.writeText() with SVG markup, so paste targets got source text instead of an image. Write SVG/PNG/text representations when supported, and fall back to PNG on hosts that reject multi-type items. --- .changeset/mermaid-copy-clipboard-images.md | 5 +++ .../src/kilocode/markdown-mermaid-actions.tsx | 13 ++++-- .../markdown-mermaid-data-url.test.ts | 24 ++++++++++ .../src/kilocode/markdown-mermaid-data-url.ts | 12 +++++ packages/ui/src/kilocode/markdown-mermaid.ts | 44 ++++++++++++++++--- 5 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 .changeset/mermaid-copy-clipboard-images.md create mode 100644 packages/ui/src/kilocode/markdown-mermaid-data-url.test.ts create mode 100644 packages/ui/src/kilocode/markdown-mermaid-data-url.ts diff --git a/.changeset/mermaid-copy-clipboard-images.md b/.changeset/mermaid-copy-clipboard-images.md new file mode 100644 index 00000000000..c9c0a405f9c --- /dev/null +++ b/.changeset/mermaid-copy-clipboard-images.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix Mermaid Copy PNG/SVG in VS Code webviews so they put images on the clipboard instead of failing or copying SVG markup. diff --git a/packages/ui/src/kilocode/markdown-mermaid-actions.tsx b/packages/ui/src/kilocode/markdown-mermaid-actions.tsx index 4f5756dbe1b..26fe5e639df 100644 --- a/packages/ui/src/kilocode/markdown-mermaid-actions.tsx +++ b/packages/ui/src/kilocode/markdown-mermaid-actions.tsx @@ -49,10 +49,15 @@ function Item(props: { label: string; onSelect: () => void }) { export function MermaidActions(props: Props) { const [copied, setCopied] = createSignal(false) const copy = (run: () => Promise) => { - void run().then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 1500) - }) + void run() + .then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }) + .catch((err) => { + // Avoid unhandledrejection; Copy PNG used to fail silently under webview CSP. + console.warn("Mermaid copy failed", err) + }) } return ( diff --git a/packages/ui/src/kilocode/markdown-mermaid-data-url.test.ts b/packages/ui/src/kilocode/markdown-mermaid-data-url.test.ts new file mode 100644 index 00000000000..592a53213bc --- /dev/null +++ b/packages/ui/src/kilocode/markdown-mermaid-data-url.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { dataUrlToBlob } from "./markdown-mermaid-data-url" + +describe("dataUrlToBlob", () => { + test("decodes base64 payload and preserves mime type", async () => { + const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]) + const dataUrl = `data:image/png;base64,${Buffer.from(bytes).toString("base64")}` + const blob = dataUrlToBlob(dataUrl) + + expect(blob.type).toBe("image/png") + expect(blob.size).toBe(bytes.length) + expect(new Uint8Array(await blob.arrayBuffer())).toEqual(bytes) + }) + + test("defaults mime when the data URL omits it", async () => { + const blob = dataUrlToBlob(`data:;base64,${Buffer.from("hi").toString("base64")}`) + expect(blob.type).toBe("application/octet-stream") + expect(await blob.text()).toBe("hi") + }) + + test("rejects data URLs without a comma separator", () => { + expect(() => dataUrlToBlob("data:image/png;base64")).toThrow("Unable to export Mermaid diagram.") + }) +}) diff --git a/packages/ui/src/kilocode/markdown-mermaid-data-url.ts b/packages/ui/src/kilocode/markdown-mermaid-data-url.ts new file mode 100644 index 00000000000..d9eda408f28 --- /dev/null +++ b/packages/ui/src/kilocode/markdown-mermaid-data-url.ts @@ -0,0 +1,12 @@ +// VS Code webview CSP blocks fetch(data:...); decode locally for clipboard/export helpers. +export function dataUrlToBlob(dataUrl: string): Blob { + const comma = dataUrl.indexOf(",") + if (comma < 0) throw new Error("Unable to export Mermaid diagram.") + const header = dataUrl.slice(0, comma) + const data = dataUrl.slice(comma + 1) + const mime = /^data:([^;,]*)/.exec(header)?.[1] || "application/octet-stream" + const binary = atob(data) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return new Blob([bytes], { type: mime }) +} diff --git a/packages/ui/src/kilocode/markdown-mermaid.ts b/packages/ui/src/kilocode/markdown-mermaid.ts index 33f3e091783..0edeef600d5 100644 --- a/packages/ui/src/kilocode/markdown-mermaid.ts +++ b/packages/ui/src/kilocode/markdown-mermaid.ts @@ -1,6 +1,7 @@ import DOMPurify from "dompurify" import { fnv1a } from "../context/marked" import { mountMermaidActions } from "./markdown-mermaid-actions" +import { dataUrlToBlob } from "./markdown-mermaid-data-url" // DOMPurify >= 3.1.7 dropped foreignObject from the default HTML integration // points, which caused the inner
/ /

labels Mermaid renders @@ -303,14 +304,43 @@ async function copyText(text: string) { await navigator.clipboard.writeText(text) } -async function copyPng(svg: SVGSVGElement) { - const url = await png(svg) - const blob = await (await fetch(url)).blob() +function supportsClipboardType(type: string) { + return typeof ClipboardItem !== "undefined" && "supports" in ClipboardItem && ClipboardItem.supports(type) +} + +async function writeClipboard(items: Record) { if (typeof ClipboardItem === "undefined") { - await navigator.clipboard.writeText(serialize(svg)) - return + throw new Error("ClipboardItem is unavailable.") } - await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]) + await navigator.clipboard.write([new ClipboardItem(items)]) +} + +async function writePngClipboard(svg: SVGSVGElement) { + await writeClipboard({ "image/png": dataUrlToBlob(await png(svg)) }) +} + +async function copySvg(svg: SVGSVGElement) { + const markup = serialize(svg) + // Prefer a rich ClipboardItem so paste targets can pick SVG, PNG, or text. + // Previous writeText(serialize(svg)) always pasted markup instead of an image. + const pngBlob = dataUrlToBlob(await png(svg)) + const items: Record = { + "image/png": pngBlob, + "text/plain": new Blob([markup], { type: "text/plain" }), + } + if (supportsClipboardType("image/svg+xml")) { + items["image/svg+xml"] = new Blob([markup], { type: "image/svg+xml" }) + } + try { + await writeClipboard(items) + } catch { + // Some hosts reject multi-type items; keep an image on the clipboard. + await writeClipboard({ "image/png": pngBlob }) + } +} + +async function copyPng(svg: SVGSVGElement) { + await writePngClipboard(svg) } function renderActions(el: HTMLDivElement, pre: HTMLPreElement, source: string, labels: MermaidLabels) { @@ -329,7 +359,7 @@ function renderActions(el: HTMLDivElement, pre: HTMLPreElement, source: string, mountMermaidActions(el, { labels, onCopySource: () => copyText(sourceText), - onCopySvg: () => copyText(sourceSvg()), + onCopySvg: () => copySvg(svg), onCopyPng: () => copyPng(svg), onDownloadSvg: () => save(sourceSvgUrl(), "mermaid-diagram.svg"), onDownloadPng: async () => save(await png(svg), "mermaid-diagram.png"), From e87633400bf1b7be8a54fb48bb7af4a161468eda Mon Sep 17 00:00:00 2001 From: cmanu Date: Fri, 7 Aug 2026 08:53:14 -0700 Subject: [PATCH 20/54] feat(pr-view): Re-add pr number on worktree item --- packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx index 69378459144..2b07b00269c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx @@ -357,6 +357,7 @@ export const WorktreeItem: Component = (props) => { onClick={handleOpenPR} > + #{pr().number} From d0585faaeb8fc5e0a889e81f4d7f46526a441ca0 Mon Sep 17 00:00:00 2001 From: cmanu Date: Fri, 7 Aug 2026 09:43:52 -0700 Subject: [PATCH 21/54] fix(pr-view): unit tests --- packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 7d0b8b1c132..0486cc70822 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -38,6 +38,7 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/MultiModelSelector.tsx"), path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"), + path.join(ROOT, "webview-ui/agent-manager/pr/PRBadge.tsx"), path.join(ROOT, "webview-ui/agent-manager/SectionHeader.tsx"), path.join(ROOT, "webview-ui/agent-manager/SidebarSectionHeader.tsx"), path.join(ROOT, "webview-ui/agent-manager/SidebarSearchMenu.tsx"), From d6e4182606c57a82361d427f25e36eb1de6fb13e Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Fri, 7 Aug 2026 16:46:55 +0000 Subject: [PATCH 22/54] chore: update kilo-vscode visual regression baselines --- .../pr-badge-approved-checks-failing-chromium-linux.png | 4 ++-- .../agentmanager/pr-badge-checks-failing-chromium-linux.png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png index 4720473ebb2..25035a43351 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2349f8c0d7049811702e5b8cbeb5fc2f402bdda8630d1a5210a970a13284197 -size 3694 +oid sha256:33ee6eb7070237487d1931f32ac77a2549e18e664b993895e9a9d6619527d0aa +size 4063 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png index 4720473ebb2..25035a43351 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2349f8c0d7049811702e5b8cbeb5fc2f402bdda8630d1a5210a970a13284197 -size 3694 +oid sha256:33ee6eb7070237487d1931f32ac77a2549e18e664b993895e9a9d6619527d0aa +size 4063 From 1a0cc860e821001d7023577971105680062ddf05 Mon Sep 17 00:00:00 2001 From: cmanu Date: Fri, 7 Aug 2026 10:05:58 -0700 Subject: [PATCH 23/54] fix(pr-view): update reviewers summary and types --- packages/kilo-vscode/src/agent-manager/am-pr-utils.ts | 4 ++-- packages/kilo-vscode/src/agent-manager/types.ts | 4 +++- .../webview-ui/agent-manager/pr/PRReviewers.tsx | 6 +++--- .../webview-ui/agent-manager/pr/PRSummary.tsx | 9 +++------ .../kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts | 4 +++- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts b/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts index 8a45349bf61..25291c2a5db 100644 --- a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts +++ b/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts @@ -1,4 +1,4 @@ -import type { CheckStatus, PRComment, PRReviewer } 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 { @@ -55,7 +55,7 @@ export function formatCheckDuration(startedAt?: string, completedAt?: string): s return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s` } -const REVIEWER_STATE: Record = { +const REVIEWER_STATE: Record = { APPROVED: "approved", CHANGES_REQUESTED: "changes_requested", COMMENTED: "commented", diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 8e503e7db9f..836b50471a7 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -71,10 +71,12 @@ export interface PRComment { createdAt?: number } +export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented" + export interface PRReviewer { login: string avatar?: string - state: "approved" | "changes_requested" | "pending" | "commented" + state: ReviewerState } export interface PRStatus { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRReviewers.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRReviewers.tsx index 85206562958..071da1f119d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRReviewers.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRReviewers.tsx @@ -1,17 +1,17 @@ /** @jsxImportSource solid-js */ import { For, Show, createSignal } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" -import type { PRReviewer } from "./pr-types" +import type { PRReviewer, ReviewerState } from "./pr-types" import { SectionHeading } from "./SectionHeading" -const REVIEWER_ICON: Record = { +const REVIEWER_ICON: Record = { approved: "circle-check", changes_requested: "refresh", commented: "edit", pending: "dash", } -const REVIEWER_LABEL: Record = { +const REVIEWER_LABEL: Record = { approved: "Approved", changes_requested: "Changes requested", commented: "Commented", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx index c04e89a5fe4..bd7dbf36d0e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx @@ -19,14 +19,11 @@ function summaryRows(pr: PRStatus): Array<{ icon: string; label: string; status: }) } - const reviewers = pr.comments?.reviewers - if (reviewers && reviewers.length > 0) { - const approved = reviewers.filter((r) => r.state === "approved").length - const changes = reviewers.filter((r) => r.state === "changes_requested").length - const status = approved === reviewers.length ? "success" : changes > 0 ? "failure" : "pending" + if (pr.review) { + const status = pr.review === "approved" ? "success" : pr.review === "changes_requested" ? "failure" : "pending" rows.push({ icon: status === "success" ? "circle-check" : status === "failure" ? "circle-x-outline" : "play", - label: approved === reviewers.length ? "All reviewers approved" : `${approved}/${reviewers.length} approvals`, + label: status === "success" ? "Approved" : status === "failure" ? "Changes requested" : "Review pending", status, }) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts index 8d88e131557..d5d0a89d6c6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts @@ -25,8 +25,10 @@ export interface PRComment { createdAt?: number } +export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented" + export interface PRReviewer { login: string avatar?: string - state: "approved" | "changes_requested" | "pending" | "commented" + state: ReviewerState } From c875fb02be3962a8a3195da70cb4d94fd6acce34 Mon Sep 17 00:00:00 2001 From: cmanu Date: Fri, 7 Aug 2026 10:19:54 -0700 Subject: [PATCH 24/54] fix(pr-view): split reviewers out of comments; fix pr summary to show review decision; show reviewers for all pr statuses --- .../src/agent-manager/PRStatusPoller.ts | 47 ++++++++++++++----- .../kilo-vscode/src/agent-manager/types.ts | 2 +- .../webview-ui/agent-manager/pr/PRPanel.tsx | 4 +- .../src/stories/agent-manager.stories.tsx | 1 + .../src/stories/section-header.stories.tsx | 7 +++ .../src/types/messages/agent-manager.ts | 2 +- 6 files changed, 46 insertions(+), 17 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index 6ce5e6b748d..5a2e1e51ce0 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -248,8 +248,9 @@ export class PRStatusPoller { return } - const [checks, comments] = await Promise.all([ + const [checks, reviewers, comments] = await Promise.all([ this.fetchChecks(pr.number, wt.path), + this.fetchReviewers(pr.number, wt.path), this.activeWorktreeId === worktreeId ? this.fetchComments(pr.number, wt.path) : undefined, ]) if (this.stale(generation)) return @@ -262,15 +263,16 @@ export class PRStatusPoller { state: pr.state, review: pr.review, checks, + reviewers, ...(comments && { - comments: { total: comments.total, unresolved: comments.unresolved, comments: comments.comments, reviewers: comments.reviewers }, + comments: { total: comments.total, unresolved: comments.unresolved, comments: comments.comments }, }), additions: pr.additions, deletions: pr.deletions, files: pr.files, } - const hash = `${worktreeId}:${pr.number}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}` + const hash = `${worktreeId}:${pr.number}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${reviewers.length}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}` if (this.lastHash.get(worktreeId) === hash) return this.lastHash.set(worktreeId, hash) @@ -431,10 +433,36 @@ export class PRStatusPoller { return info } + private async fetchReviewers(prNumber: number, cwd: string): Promise { + try { + const repo = await this.getRepoInfo(cwd) + const query = `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewRequests(first: 20) { + nodes { requestedReviewer { ... on User { login avatarUrl } } } + } + reviews(first: 20, states: [APPROVED, CHANGES_REQUESTED, COMMENTED]) { + nodes { author { login avatarUrl } state } + } + } + } + }` + const { stdout } = await this.gh( + ["api", "graphql", "-f", `query=${query}`, "-F", `owner=${repo.owner}`, "-F", `repo=${repo.name}`, "-F", `number=${prNumber}`], + { cwd, timeout: 15_000 }, + ) + const pr = JSON.parse(stdout)?.data?.repository?.pullRequest + return parseReviewers((pr?.reviewRequests?.nodes ?? []) as GhReviewRequest[], (pr?.reviews?.nodes ?? []) as GhReview[]) + } catch { + return [] + } + } + private async fetchComments( prNumber: number, cwd: string, - ): Promise<{ total: number; unresolved: number; comments: PRComment[]; reviewers: PRReviewer[] }> { + ): Promise<{ total: number; unresolved: number; comments: PRComment[] }> { try { const repo = await this.getRepoInfo(cwd) const query = `query($owner: String!, $repo: String!, $number: Int!) { @@ -456,12 +484,6 @@ export class PRStatusPoller { } } } - reviewRequests(first: 20) { - nodes { requestedReviewer { ... on User { login avatarUrl } } } - } - reviews(first: 20, states: [APPROVED, CHANGES_REQUESTED, COMMENTED]) { - nodes { author { login avatarUrl } state } - } } } }` @@ -483,11 +505,10 @@ export class PRStatusPoller { ) const pr = JSON.parse(stdout)?.data?.repository?.pullRequest const comments = parseComments((pr?.reviewThreads?.nodes ?? []) as GhThread[]) - const reviewers = parseReviewers((pr?.reviewRequests?.nodes ?? []) as GhReviewRequest[], (pr?.reviews?.nodes ?? []) as GhReview[]) - return { total: comments.length, unresolved: comments.filter((c) => !c.resolved).length, comments, reviewers } + return { total: comments.length, 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: [], reviewers: [] } + return { total: 0, unresolved: 0, comments: [] } } } } diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 836b50471a7..01c88ff1581 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -94,11 +94,11 @@ export interface PRStatus { pending: number checks: PRCheck[] } + reviewers: PRReviewer[] comments?: { total: number unresolved: number comments: PRComment[] - reviewers: PRReviewer[] } additions: number deletions: number diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx index 7bf8aef9f7b..20382b02972 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx @@ -44,8 +44,8 @@ export const PRPanel: Component = (props) => {

- - {(comments) => } + 0}> + {(body) => } diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index d308663f8bf..817310e1bb9 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -613,6 +613,7 @@ const basePR: PRStatus = { state: "open", review: null, checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, checks: [] }, + reviewers: [], additions: 978, deletions: 202, files: 12, diff --git a/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx index 23938282a84..a359eefea5f 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx @@ -593,6 +593,7 @@ export const WithPRBadges: Story = { additions: 120, deletions: 30, files: 5, + reviewers: [], checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, checks: [] }, }} /> @@ -611,6 +612,7 @@ export const WithPRBadges: Story = { additions: 15, deletions: 8, files: 3, + reviewers: [], checks: { status: "failure", total: 5, passed: 3, failed: 2, pending: 0, checks: [] }, }} /> @@ -629,6 +631,7 @@ export const WithPRBadges: Story = { additions: 60, deletions: 12, files: 4, + reviewers: [], checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, checks: [] }, }} /> @@ -647,6 +650,7 @@ export const WithPRBadges: Story = { additions: 200, deletions: 0, files: 8, + reviewers: [], checks: { status: "pending", total: 5, passed: 2, failed: 0, pending: 3, checks: [] }, }} /> @@ -665,6 +669,7 @@ export const WithPRBadges: Story = { additions: 30, deletions: 5, files: 2, + reviewers: [], checks: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] }, }} /> @@ -683,6 +688,7 @@ export const WithPRBadges: Story = { additions: 90, deletions: 20, files: 6, + reviewers: [], checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, checks: [] }, }} /> @@ -701,6 +707,7 @@ export const WithPRBadges: Story = { additions: 10, deletions: 4, files: 1, + reviewers: [], checks: { status: "failure", total: 5, passed: 1, failed: 4, pending: 0, checks: [] }, }} /> diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts b/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts index 23c0395a07d..f76fb185c7e 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts @@ -66,11 +66,11 @@ export interface PRStatus { pending: number checks: PRCheck[] } + reviewers: PRReviewer[] comments?: { total: number unresolved: number comments: PRComment[] - reviewers: PRReviewer[] } additions: number deletions: number From c20ccad2d7fb60ad8da9ccd943c0b4934aede1b7 Mon Sep 17 00:00:00 2001 From: cmanu Date: Fri, 7 Aug 2026 10:22:18 -0700 Subject: [PATCH 25/54] fix(pr-view): close pr window if wt has not pr --- .../kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 48d364feb36..234625e073b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -499,6 +499,7 @@ const AgentManagerContent: Component = () => { } createEffect(on(selection, () => cancelPendingDelete(), { defer: true })) createEffect(on(selection, () => clearReviewComposer(reviewComposer), { defer: true })) + createEffect(on(selection, () => { if (!activePR()) setSidePanel((p) => p === SidePanel.PR ? null : p) }, { defer: true })) onCleanup(() => clearTimeout(pendingDeleteTimer)) // Per-context tab memory lives in the active project's store: maps sidebar From e1f064387d8a0162524fee0e60888f3a65d2c582 Mon Sep 17 00:00:00 2001 From: cmanu Date: Fri, 7 Aug 2026 10:28:23 -0700 Subject: [PATCH 26/54] feat(pr-view): move file changes --- packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx | 2 -- .../kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx | 5 +++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx index 20382b02972..7e4e804371c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx @@ -9,7 +9,6 @@ import { PRBadge } from "./PRBadge" import { PROverview } from "./PROverview" import { PRReviewers } from "./PRReviewers" import { PRDescription } from "./PRDescription" -import { PRFileChanges } from "./PRFileChanges" import { PRChecks } from "./PRChecks" import { PRComments } from "./PRComments" import { PRSummary } from "./PRSummary" @@ -50,7 +49,6 @@ export const PRPanel: Component = (props) => { {(body) => } - 0}> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx index bd7dbf36d0e..5257014a3b0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx @@ -46,6 +46,11 @@ export function PRSummary(props: PRSummaryProps) {
PR Summary + + 0}>{props.pr.files}f + 0}>+{props.pr.additions} + 0}>−{props.pr.deletions} +
{rows().map((row) => ( From a357c7123045fd1b7b0739158c5710aa4758bece Mon Sep 17 00:00:00 2001 From: cmanu Date: Fri, 7 Aug 2026 10:36:11 -0700 Subject: [PATCH 27/54] feat(pr-view): Update pr button style --- .../kilo-vscode/webview-ui/agent-manager/TabBar.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx index ad0461db397..3f8cba105e7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx @@ -221,13 +221,14 @@ export const TabBar: Component = (props) => ( {(pr) => ( - + /> )} From 05301da2d23114b6a09caa2e3c9a1c551e685eb2 Mon Sep 17 00:00:00 2001 From: cmanu Date: Fri, 7 Aug 2026 13:01:24 -0700 Subject: [PATCH 28/54] fix(pr-view): Formatting and update css variable name for unit test --- .../src/agent-manager/PRStatusPoller.ts | 18 +- .../src/agent-manager/am-pr-types.ts | 29 ++- .../src/agent-manager/am-pr-utils.ts | 11 +- .../src/agent-manager/pr-status-bridge.ts | 3 +- .../tests/unit/am-pr-status-bridge.test.ts | 4 +- .../tests/unit/am-pr-utils.test.ts | 172 ++++++++++++++---- .../agent-manager/AgentManagerApp.tsx | 24 ++- .../webview-ui/agent-manager/WorktreeItem.tsx | 7 +- .../agent-manager/agent-manager.css | 34 ++-- .../agent-manager/default-base-branch.tsx | 11 +- .../webview-ui/agent-manager/pr/PRBadge.tsx | 7 +- .../webview-ui/agent-manager/pr/PRChecks.tsx | 4 +- .../agent-manager/pr/PRComments.tsx | 3 +- .../agent-manager/pr/PRFileChanges.tsx | 12 +- .../agent-manager/pr/PROverview.tsx | 4 +- .../webview-ui/agent-manager/pr/PRPanel.tsx | 12 +- .../webview-ui/agent-manager/pr/PRSummary.tsx | 12 +- .../webview-ui/agent-manager/pr/pr-panel.css | 120 ++++++++---- .../webview-ui/src/context/session.tsx | 5 +- .../src/types/messages/agent-manager.ts | 19 +- 20 files changed, 396 insertions(+), 115 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index 5a2e1e51ce0..afe6a3e3854 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -449,11 +449,25 @@ export class PRStatusPoller { } }` const { stdout } = await this.gh( - ["api", "graphql", "-f", `query=${query}`, "-F", `owner=${repo.owner}`, "-F", `repo=${repo.name}`, "-F", `number=${prNumber}`], + [ + "api", + "graphql", + "-f", + `query=${query}`, + "-F", + `owner=${repo.owner}`, + "-F", + `repo=${repo.name}`, + "-F", + `number=${prNumber}`, + ], { cwd, timeout: 15_000 }, ) const pr = JSON.parse(stdout)?.data?.repository?.pullRequest - return parseReviewers((pr?.reviewRequests?.nodes ?? []) as GhReviewRequest[], (pr?.reviews?.nodes ?? []) as GhReview[]) + return parseReviewers( + (pr?.reviewRequests?.nodes ?? []) as GhReviewRequest[], + (pr?.reviews?.nodes ?? []) as GhReview[], + ) } catch { return [] } diff --git a/packages/kilo-vscode/src/agent-manager/am-pr-types.ts b/packages/kilo-vscode/src/agent-manager/am-pr-types.ts index 59f4604c96b..4cd818d7a09 100644 --- a/packages/kilo-vscode/src/agent-manager/am-pr-types.ts +++ b/packages/kilo-vscode/src/agent-manager/am-pr-types.ts @@ -2,11 +2,30 @@ import type { PRState, ReviewDecision } from "./types" // Raw shapes returned by `gh pr view --json` -export interface GhAuthor { login?: string; avatarUrl?: string } -export interface GhComment { id: string; author?: GhAuthor; body?: string; path?: string; line?: number; url?: string; createdAt?: string } -export interface GhThread { isResolved?: boolean; comments?: { nodes?: GhComment[] } } -export interface GhReviewRequest { requestedReviewer?: GhAuthor } -export interface GhReview { author?: GhAuthor; state?: string } +export interface GhAuthor { + login?: string + avatarUrl?: string +} +export interface GhComment { + id: string + author?: GhAuthor + body?: string + path?: string + line?: number + url?: string + createdAt?: string +} +export interface GhThread { + isResolved?: boolean + comments?: { nodes?: GhComment[] } +} +export interface GhReviewRequest { + requestedReviewer?: GhAuthor +} +export interface GhReview { + author?: GhAuthor + state?: string +} export interface PRResult { number: number diff --git a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts b/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts index 25291c2a5db..9cf563d0aef 100644 --- a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts +++ b/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts @@ -7,10 +7,13 @@ export function parsePRResult(json: string): PRResult | null { const state = data.isDraft ? "draft" : (data.state?.toLowerCase() ?? "open") const decision = data.reviewDecision as string | undefined const review = - decision === "APPROVED" ? "approved" - : decision === "CHANGES_REQUESTED" ? "changes_requested" - : decision === "REVIEW_REQUIRED" ? "pending" - : null + decision === "APPROVED" + ? "approved" + : decision === "CHANGES_REQUESTED" + ? "changes_requested" + : decision === "REVIEW_REQUIRED" + ? "pending" + : null return { number: data.number, title: data.title ?? "", diff --git a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts index 6eb742fbd4e..af496281966 100644 --- a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts @@ -128,8 +128,7 @@ function bridgePollerOpts(bridge: PRStatusBridge, host: PRBridgeHost) { // Always forward auth/missing errors so the webview can show a toast, // regardless of whether prior data exists. Deduplicate per error type // so multiple failing worktrees don't produce multiple toasts. - if (err === "gh_auth" || err === "gh_missing") - bridge.notifyError(err) + if (err === "gh_auth" || err === "gh_missing") bridge.notifyError(err) return } const msg = { type: "agentManager.prStatus", worktreeId: id, pr, error: err } as AgentManagerOutMessage diff --git a/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts b/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts index 4752844023e..734b3128f6e 100644 --- a/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts @@ -122,7 +122,9 @@ describe("PRStatusBridge.replay", () => { onStatus("wt1", null, "gh_auth") sent.length = 0 bridge.replay() - expect(sent.some((m) => m.type === "agentManager.prError" && (m as never as { error: string }).error === "gh_auth")).toBe(true) + expect( + sent.some((m) => m.type === "agentManager.prError" && (m as never as { error: string }).error === "gh_auth"), + ).toBe(true) }) it("does not replay fetch_failed errors", () => { diff --git a/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts b/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts index e16ab9c259d..ba2d8d80070 100644 --- a/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts @@ -16,7 +16,18 @@ describe("parsePRResult", () => { }) it("parses an open PR", () => { - const raw = { number: 42, title: "my PR", body: "desc", url: "https://github.com/x/y/pull/42", state: "OPEN", isDraft: false, reviewDecision: null, additions: 10, deletions: 3, changedFiles: 2 } + const raw = { + number: 42, + title: "my PR", + body: "desc", + url: "https://github.com/x/y/pull/42", + state: "OPEN", + isDraft: false, + reviewDecision: null, + additions: 10, + deletions: 3, + changedFiles: 2, + } expect(parsePRResult(JSON.stringify(raw))).toEqual({ number: 42, title: "my PR", @@ -31,43 +42,122 @@ describe("parsePRResult", () => { }) it("maps isDraft to draft state regardless of gh state field", () => { - const raw = { number: 1, title: "", body: "", url: "", state: "OPEN", isDraft: true, reviewDecision: null, additions: 0, deletions: 0, changedFiles: 0 } + const raw = { + number: 1, + title: "", + body: "", + url: "", + state: "OPEN", + isDraft: true, + reviewDecision: null, + additions: 0, + deletions: 0, + changedFiles: 0, + } expect(parsePRResult(JSON.stringify(raw))?.state).toBe("draft") }) it("maps MERGED state", () => { - const raw = { number: 1, title: "", body: "", url: "", state: "MERGED", isDraft: false, reviewDecision: null, additions: 0, deletions: 0, changedFiles: 0 } + const raw = { + number: 1, + title: "", + body: "", + url: "", + state: "MERGED", + isDraft: false, + reviewDecision: null, + additions: 0, + deletions: 0, + changedFiles: 0, + } expect(parsePRResult(JSON.stringify(raw))?.state).toBe("merged") }) it("maps CLOSED state", () => { - const raw = { number: 1, title: "", body: "", url: "", state: "CLOSED", isDraft: false, reviewDecision: null, additions: 0, deletions: 0, changedFiles: 0 } + const raw = { + number: 1, + title: "", + body: "", + url: "", + state: "CLOSED", + isDraft: false, + reviewDecision: null, + additions: 0, + deletions: 0, + changedFiles: 0, + } expect(parsePRResult(JSON.stringify(raw))?.state).toBe("closed") }) it("maps APPROVED review decision", () => { - const raw = { number: 1, title: "", body: "", url: "", state: "OPEN", isDraft: false, reviewDecision: "APPROVED", additions: 0, deletions: 0, changedFiles: 0 } + const raw = { + number: 1, + title: "", + body: "", + url: "", + state: "OPEN", + isDraft: false, + reviewDecision: "APPROVED", + additions: 0, + deletions: 0, + changedFiles: 0, + } expect(parsePRResult(JSON.stringify(raw))?.review).toBe("approved") }) it("maps CHANGES_REQUESTED review decision", () => { - const raw = { number: 1, title: "", body: "", url: "", state: "OPEN", isDraft: false, reviewDecision: "CHANGES_REQUESTED", additions: 0, deletions: 0, changedFiles: 0 } + const raw = { + number: 1, + title: "", + body: "", + url: "", + state: "OPEN", + isDraft: false, + reviewDecision: "CHANGES_REQUESTED", + additions: 0, + deletions: 0, + changedFiles: 0, + } expect(parsePRResult(JSON.stringify(raw))?.review).toBe("changes_requested") }) it("maps REVIEW_REQUIRED review decision to pending", () => { - const raw = { number: 1, title: "", body: "", url: "", state: "OPEN", isDraft: false, reviewDecision: "REVIEW_REQUIRED", additions: 0, deletions: 0, changedFiles: 0 } + const raw = { + number: 1, + title: "", + body: "", + url: "", + state: "OPEN", + isDraft: false, + reviewDecision: "REVIEW_REQUIRED", + additions: 0, + deletions: 0, + changedFiles: 0, + } expect(parsePRResult(JSON.stringify(raw))?.review).toBe("pending") }) it("returns null review for unknown decision", () => { - const raw = { number: 1, title: "", body: "", url: "", state: "OPEN", isDraft: false, reviewDecision: "SOMETHING_ELSE", additions: 0, deletions: 0, changedFiles: 0 } + const raw = { + number: 1, + title: "", + body: "", + url: "", + state: "OPEN", + isDraft: false, + reviewDecision: "SOMETHING_ELSE", + additions: 0, + deletions: 0, + changedFiles: 0, + } expect(parsePRResult(JSON.stringify(raw))?.review).toBeNull() }) it("defaults missing fields to empty strings and zeros", () => { const result = parsePRResult(JSON.stringify({ number: 5 })) - expect(result).toEqual(expect.objectContaining({ title: "", body: "", url: "", additions: 0, deletions: 0, files: 0 })) + expect(result).toEqual( + expect.objectContaining({ title: "", body: "", url: "", additions: 0, deletions: 0, files: 0 }), + ) }) }) @@ -128,21 +218,37 @@ describe("parseComments", () => { }) it("parses a resolved thread", () => { - const threads: GhThread[] = [{ - isResolved: true, - comments: { nodes: [{ id: "c1", author: { login: "alice", avatarUrl: "https://avatar" }, body: "looks good", path: "src/foo.ts", line: 10, url: "https://url", createdAt: "2024-01-01T00:00:00Z" }] }, - }] - expect(parseComments(threads)).toEqual([{ - id: "c1", - author: "alice", - avatar: "https://avatar", - body: "looks good", - file: "src/foo.ts", - line: 10, - url: "https://url", - resolved: true, - createdAt: new Date("2024-01-01T00:00:00Z").getTime(), - }]) + const threads: GhThread[] = [ + { + isResolved: true, + comments: { + nodes: [ + { + id: "c1", + author: { login: "alice", avatarUrl: "https://avatar" }, + body: "looks good", + path: "src/foo.ts", + line: 10, + url: "https://url", + createdAt: "2024-01-01T00:00:00Z", + }, + ], + }, + }, + ] + expect(parseComments(threads)).toEqual([ + { + id: "c1", + author: "alice", + avatar: "https://avatar", + body: "looks good", + file: "src/foo.ts", + line: 10, + url: "https://url", + resolved: true, + createdAt: new Date("2024-01-01T00:00:00Z").getTime(), + }, + ]) }) it("defaults missing author to 'unknown'", () => { @@ -151,13 +257,17 @@ describe("parseComments", () => { }) it("only uses the first comment of each thread", () => { - const threads: GhThread[] = [{ - isResolved: false, - comments: { nodes: [ - { id: "first", body: "first comment" }, - { id: "second", body: "second comment" }, - ]}, - }] + const threads: GhThread[] = [ + { + isResolved: false, + comments: { + nodes: [ + { id: "first", body: "first comment" }, + { id: "second", body: "second comment" }, + ], + }, + }, + ] const result = parseComments(threads) expect(result).toHaveLength(1) expect(result[0]?.id).toBe("first") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 234625e073b..2dad2ea0209 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -499,7 +499,15 @@ const AgentManagerContent: Component = () => { } createEffect(on(selection, () => cancelPendingDelete(), { defer: true })) createEffect(on(selection, () => clearReviewComposer(reviewComposer), { defer: true })) - createEffect(on(selection, () => { if (!activePR()) setSidePanel((p) => p === SidePanel.PR ? null : p) }, { defer: true })) + createEffect( + on( + selection, + () => { + if (!activePR()) setSidePanel((p) => (p === SidePanel.PR ? null : p)) + }, + { defer: true }, + ), + ) onCleanup(() => clearTimeout(pendingDeleteTimer)) // Per-context tab memory lives in the active project's store: maps sidebar @@ -1581,7 +1589,11 @@ const AgentManagerContent: Component = () => { if (msg.type === "agentManager.prError") { const ev = msg as AgentManagerPRErrorMessage - showToast({ variant: "error", title: t(`agentManager.pr.error.${ev.error}.title`), description: t(`agentManager.pr.error.${ev.error}.description`) }) + showToast({ + variant: "error", + title: t(`agentManager.pr.error.${ev.error}.title`), + description: t(`agentManager.pr.error.${ev.error}.description`), + }) } if (projectLive.apply(msg)) return @@ -2656,7 +2668,13 @@ const AgentManagerContent: Component = () => { pr={data.pr} worktree={data.wt} onClose={() => setSidePanel(null)} - onOpenExternal={() => vscode.postMessage({ type: "agentManager.openPR", worktreeId: data.selected, url: data.pr.url })} + onOpenExternal={() => + vscode.postMessage({ + type: "agentManager.openPR", + worktreeId: data.selected, + url: data.pr.url, + }) + } /> ) })()} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx index 2b07b00269c..c44b767cffd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx @@ -360,7 +360,12 @@ export const WorktreeItem: Component = (props) => { #{pr().number} - + diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 332fe65426d..5017d37a669 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -907,15 +907,25 @@ html[data-theme="kilo-vscode"] /* PR badge accent colors via classList */ .am-pr-accent-open, -.am-pr-panel-badge.am-pr-accent-open { --pr-accent: var(--vscode-testing-iconPassed, #34d399); } +.am-pr-panel-badge.am-pr-accent-open { + --am-pr-accent: var(--vscode-testing-iconPassed, #34d399); +} .am-pr-accent-pending, -.am-pr-panel-badge.am-pr-accent-pending { --pr-accent: var(--vscode-charts-yellow, #fbbf24); } +.am-pr-panel-badge.am-pr-accent-pending { + --am-pr-accent: var(--vscode-charts-yellow, #fbbf24); +} .am-pr-accent-draft, -.am-pr-panel-badge.am-pr-accent-draft { --pr-accent: var(--text-weaker); } +.am-pr-panel-badge.am-pr-accent-draft { + --am-pr-accent: var(--text-weaker); +} .am-pr-accent-merged, -.am-pr-panel-badge.am-pr-accent-merged { --pr-accent: var(--vscode-charts-purple, var(--vscode-foreground)); } +.am-pr-panel-badge.am-pr-accent-merged { + --am-pr-accent: var(--vscode-charts-purple, var(--vscode-foreground)); +} .am-pr-accent-closed, -.am-pr-panel-badge.am-pr-accent-closed { --pr-accent: var(--vscode-testing-iconFailed, #f87171); } +.am-pr-panel-badge.am-pr-accent-closed { + --am-pr-accent: var(--vscode-testing-iconFailed, #f87171); +} .am-pr-badge { display: inline-flex; @@ -924,8 +934,8 @@ html[data-theme="kilo-vscode"] padding: 2px 4px; border-radius: 6px; border: none; - background: color-mix(in srgb, var(--pr-accent) 12%, transparent); - color: var(--pr-accent); + background: color-mix(in srgb, var(--am-pr-accent) 12%, transparent); + color: var(--am-pr-accent); cursor: pointer; font-size: var(--kilo-font-size-11); font-weight: 500; @@ -934,19 +944,19 @@ html[data-theme="kilo-vscode"] margin-left: auto; } .am-pr-badge:hover { - background: color-mix(in srgb, var(--pr-accent) 25%, transparent); + background: color-mix(in srgb, var(--am-pr-accent) 25%, transparent); } .am-pr-badge [data-component="icon"] { width: 12px; height: 12px; - color: var(--pr-accent); + color: var(--am-pr-accent); } .am-pr-badge-number { line-height: 1; color: var(--text-muted); } .am-pr-badge:hover .am-pr-badge-number { - color: var(--pr-accent); + color: var(--am-pr-accent); } /* Status icon overlaid on the badge — colored by CI/review result, independent of the badge's PR-state accent color. */ @@ -982,10 +992,10 @@ html[data-theme="kilo-vscode"] } .am-worktree-item-active .am-pr-badge { - background: color-mix(in srgb, var(--pr-accent) 18%, transparent); + background: color-mix(in srgb, var(--am-pr-accent) 18%, transparent); } .am-worktree-item-active .am-pr-badge:hover { - background: color-mix(in srgb, var(--pr-accent) 30%, transparent); + background: color-mix(in srgb, var(--am-pr-accent) 30%, transparent); } /* Clickable link icon in hover card to open PR on GitHub */ diff --git a/packages/kilo-vscode/webview-ui/agent-manager/default-base-branch.tsx b/packages/kilo-vscode/webview-ui/agent-manager/default-base-branch.tsx index 53c9b77443d..421c17c8bf3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/default-base-branch.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/default-base-branch.tsx @@ -21,7 +21,16 @@ interface Deps { } export function createChangeDefaultBaseBranch(deps: Deps) { - const { vscode, dialog, t, defaultBaseBranch, setDefaultBaseBranch, setRepoDetectedBranch, repoDetectedBranch, hasConfiguredBranch } = deps + const { + vscode, + dialog, + t, + defaultBaseBranch, + setDefaultBaseBranch, + setRepoDetectedBranch, + repoDetectedBranch, + hasConfiguredBranch, + } = deps return () => { const [search, setSearch] = createSignal("") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx index d77213de726..55bdea2165a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx @@ -26,12 +26,7 @@ export function PRBadge(props: { pr: PRStatus }) { > - + ) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx index 235124dc720..b010d221f71 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx @@ -34,7 +34,9 @@ export function PRChecks(props: { checks: PRStatus["checks"] }) { {check.name} {CHECK[check.status].label} - {check.duration} + + {check.duration} + e.preventDefault()}> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx index 6745a0522c3..b73c915f02d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx @@ -28,7 +28,8 @@ export function PRComments(props: { comments: NonNullable {comment.author} - {comment.file}{`:${comment.line}`} + {comment.file} + {`:${comment.line}`} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRFileChanges.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRFileChanges.tsx index b4e558d67b0..971948d8e28 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRFileChanges.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRFileChanges.tsx @@ -9,9 +9,15 @@ export function PRFileChanges(props: { files: number; additions: number; deletio
File Changes - 0}>{props.files}f - 0}>+{props.additions} - 0}>−{props.deletions} + 0}> + {props.files}f + + 0}> + +{props.additions} + + 0}> + −{props.deletions} +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PROverview.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PROverview.tsx index 6c1fc863739..577dd4a8e7d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PROverview.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PROverview.tsx @@ -34,7 +34,9 @@ export function PROverview(props: { pr: PRStatus; worktree?: WorktreeState }) {
Status - {STATE_LABEL[props.pr.state]} + + {STATE_LABEL[props.pr.state]} +
{(review) => ( diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx index 7e4e804371c..c0241f05120 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx @@ -33,7 +33,13 @@ export const PRPanel: Component = (props) => {
- + @@ -46,9 +52,7 @@ export const PRPanel: Component = (props) => { 0}> - - {(body) => } - + {(body) => } 0}> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx index 5257014a3b0..48d9d2ee2e8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx @@ -47,9 +47,15 @@ export function PRSummary(props: PRSummaryProps) {
PR Summary - 0}>{props.pr.files}f - 0}>+{props.pr.additions} - 0}>−{props.pr.deletions} + 0}> + {props.pr.files}f + + 0}> + +{props.pr.additions} + + 0}> + −{props.pr.deletions} +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css index 6f1cc8cc260..e505989acee 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css @@ -4,8 +4,14 @@ overflow: hidden; } -.am-pr-row { display: flex; align-items: center; } -.am-pr-col { display: flex; flex-direction: column; } +.am-pr-row { + display: flex; + align-items: center; +} +.am-pr-col { + display: flex; + flex-direction: column; +} .am-pr-panel-header { align-items: flex-start; @@ -29,9 +35,9 @@ border-radius: 4px; font-size: var(--kilo-font-size-10); font-variant-numeric: tabular-nums; - background: color-mix(in srgb, var(--pr-accent) 15%, transparent); - color: var(--pr-accent); - border: 1px solid color-mix(in srgb, var(--pr-accent) 40%, transparent); + background: color-mix(in srgb, var(--am-pr-accent) 15%, transparent); + color: var(--am-pr-accent); + border: 1px solid color-mix(in srgb, var(--am-pr-accent) 40%, transparent); white-space: nowrap; flex-shrink: 0; } @@ -121,9 +127,15 @@ } /* Check count color by status */ -.am-pr-checks-count-success { color: var(--vscode-testing-iconPassed, #34d399); } -.am-pr-checks-count-failure { color: var(--vscode-testing-iconFailed, #f87171); } -.am-pr-checks-count-pending { color: var(--vscode-charts-yellow, #fbbf24); } +.am-pr-checks-count-success { + color: var(--vscode-testing-iconPassed, #34d399); +} +.am-pr-checks-count-failure { + color: var(--vscode-testing-iconFailed, #f87171); +} +.am-pr-checks-count-pending { + color: var(--vscode-charts-yellow, #fbbf24); +} .am-pr-panel-section-count { font-size: var(--kilo-font-size-11); @@ -161,14 +173,28 @@ text-align: right; } -.am-pr-panel-value[data-pr-state="open"] { color: var(--vscode-testing-iconPassed, #34d399); } -.am-pr-panel-value[data-pr-state="merged"] { color: var(--vscode-charts-purple, #a78bfa); } -.am-pr-panel-value[data-pr-state="closed"] { color: var(--vscode-testing-iconFailed, #f87171); } -.am-pr-panel-value[data-pr-state="draft"] { color: var(--text-weaker); } +.am-pr-panel-value[data-pr-state="open"] { + color: var(--vscode-testing-iconPassed, #34d399); +} +.am-pr-panel-value[data-pr-state="merged"] { + color: var(--vscode-charts-purple, #a78bfa); +} +.am-pr-panel-value[data-pr-state="closed"] { + color: var(--vscode-testing-iconFailed, #f87171); +} +.am-pr-panel-value[data-pr-state="draft"] { + color: var(--text-weaker); +} -.am-pr-panel-value[data-checks="failure"] { color: var(--vscode-testing-iconFailed, #f87171); } -.am-pr-panel-value[data-checks="pending"] { color: var(--vscode-charts-yellow, #fbbf24); } -.am-pr-panel-value[data-checks="success"] { color: var(--vscode-testing-iconPassed, #34d399); } +.am-pr-panel-value[data-checks="failure"] { + color: var(--vscode-testing-iconFailed, #f87171); +} +.am-pr-panel-value[data-checks="pending"] { + color: var(--vscode-charts-yellow, #fbbf24); +} +.am-pr-panel-value[data-checks="success"] { + color: var(--vscode-testing-iconPassed, #34d399); +} .am-pr-panel-diff { gap: 6px; @@ -257,9 +283,15 @@ margin-bottom: 0.25em; } -.am-pr-panel-description [data-component="markdown"] h1 { font-size: var(--kilo-font-size-16); } -.am-pr-panel-description [data-component="markdown"] h2 { font-size: var(--kilo-font-size-14); } -.am-pr-panel-description [data-component="markdown"] h3 { font-size: var(--kilo-font-size-13); } +.am-pr-panel-description [data-component="markdown"] h1 { + font-size: var(--kilo-font-size-16); +} +.am-pr-panel-description [data-component="markdown"] h2 { + font-size: var(--kilo-font-size-14); +} +.am-pr-panel-description [data-component="markdown"] h3 { + font-size: var(--kilo-font-size-13); +} /* PR Reviewers */ .am-pr-panel-reviewers { @@ -276,10 +308,18 @@ flex-shrink: 0; } -.am-pr-panel-reviewer[data-state="approved"] .am-pr-reviewer-icon { color: var(--vscode-testing-iconPassed, #34d399); } -.am-pr-panel-reviewer[data-state="changes_requested"] .am-pr-reviewer-icon { color: var(--vscode-charts-yellow, #fbbf24); } -.am-pr-panel-reviewer[data-state="commented"] .am-pr-reviewer-icon { color: var(--text-weak); } -.am-pr-panel-reviewer[data-state="pending"] .am-pr-reviewer-icon { color: var(--text-weaker); } +.am-pr-panel-reviewer[data-state="approved"] .am-pr-reviewer-icon { + color: var(--vscode-testing-iconPassed, #34d399); +} +.am-pr-panel-reviewer[data-state="changes_requested"] .am-pr-reviewer-icon { + color: var(--vscode-charts-yellow, #fbbf24); +} +.am-pr-panel-reviewer[data-state="commented"] .am-pr-reviewer-icon { + color: var(--text-weak); +} +.am-pr-panel-reviewer[data-state="pending"] .am-pr-reviewer-icon { + color: var(--text-weaker); +} .am-pr-reviewer-login { flex: 1; @@ -306,11 +346,21 @@ flex-shrink: 0; } -.am-pr-panel-check-item[data-status="success"] .am-pr-check-icon { color: var(--vscode-testing-iconPassed, #34d399); } -.am-pr-panel-check-item[data-status="failure"] .am-pr-check-icon { color: var(--vscode-testing-iconFailed, #f87171); } -.am-pr-panel-check-item[data-status="cancelled"] .am-pr-check-icon { color: var(--text-weaker); } -.am-pr-panel-check-item[data-status="skipped"] .am-pr-check-icon { color: var(--text-weaker); } -.am-pr-panel-check-item[data-status="pending"] .am-pr-check-icon { color: var(--vscode-charts-yellow, #fbbf24); } +.am-pr-panel-check-item[data-status="success"] .am-pr-check-icon { + color: var(--vscode-testing-iconPassed, #34d399); +} +.am-pr-panel-check-item[data-status="failure"] .am-pr-check-icon { + color: var(--vscode-testing-iconFailed, #f87171); +} +.am-pr-panel-check-item[data-status="cancelled"] .am-pr-check-icon { + color: var(--text-weaker); +} +.am-pr-panel-check-item[data-status="skipped"] .am-pr-check-icon { + color: var(--text-weaker); +} +.am-pr-panel-check-item[data-status="pending"] .am-pr-check-icon { + color: var(--vscode-charts-yellow, #fbbf24); +} .am-pr-check-name { flex: 1; @@ -389,10 +439,18 @@ flex-shrink: 0; } -.am-pr-summary-row[data-status="success"] .am-pr-summary-icon { color: var(--vscode-testing-iconPassed, #34d399); } -.am-pr-summary-row[data-status="failure"] .am-pr-summary-icon { color: var(--vscode-testing-iconFailed, #f87171); } -.am-pr-summary-row[data-status="pending"] .am-pr-summary-icon { color: var(--vscode-charts-yellow, #fbbf24); } -.am-pr-summary-row[data-status="warning"] .am-pr-summary-icon { color: var(--vscode-charts-yellow, #fbbf24); } +.am-pr-summary-row[data-status="success"] .am-pr-summary-icon { + color: var(--vscode-testing-iconPassed, #34d399); +} +.am-pr-summary-row[data-status="failure"] .am-pr-summary-icon { + color: var(--vscode-testing-iconFailed, #f87171); +} +.am-pr-summary-row[data-status="pending"] .am-pr-summary-icon { + color: var(--vscode-charts-yellow, #fbbf24); +} +.am-pr-summary-row[data-status="warning"] .am-pr-summary-icon { + color: var(--vscode-charts-yellow, #fbbf24); +} .am-pr-summary-label { color: var(--vscode-foreground); diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index d0402d48e82..80435d4cc70 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -1453,7 +1453,10 @@ export const SessionProvider: ParentComponent = (props) => { // Reconcile fast-path: if the tail matches local state shape-wise, every // message+part-count already agrees with the server. Skip the reactive // store churn entirely — virtualizer and rendering stay untouched. - if (mode === "reconcile" && sameReconcileShape(store.messages[sessionID] ?? [], messages, (id) => store.parts[id])) { + if ( + mode === "reconcile" && + sameReconcileShape(store.messages[sessionID] ?? [], messages, (id) => store.parts[id]) + ) { const parts = messageParts(messages) for (const msg of messages) { if (store.parts[msg.id]) delete parts[msg.id] diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts b/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts index f76fb185c7e..4ccb83c0745 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts @@ -48,8 +48,23 @@ export interface SectionState { // PR status types — sub-types live in agent-manager/pr/pr-types.ts // --------------------------------------------------------------------------- -import type { PRState, ReviewDecision, AggregateCheckStatus, PRCheck, PRComment, PRReviewer } from "../../../agent-manager/pr/pr-types" -export type { PRState, ReviewDecision, CheckStatus, AggregateCheckStatus, PRCheck, PRComment, PRReviewer } from "../../../agent-manager/pr/pr-types" +import type { + PRState, + ReviewDecision, + AggregateCheckStatus, + PRCheck, + PRComment, + PRReviewer, +} from "../../../agent-manager/pr/pr-types" +export type { + PRState, + ReviewDecision, + CheckStatus, + AggregateCheckStatus, + PRCheck, + PRComment, + PRReviewer, +} from "../../../agent-manager/pr/pr-types" export interface PRStatus { number: number From db1331511a7045998aa025deeae6a77ece10157c Mon Sep 17 00:00:00 2001 From: cmanu Date: Fri, 7 Aug 2026 13:01:44 -0700 Subject: [PATCH 29/54] feat(pr-view): i18n for agent manager pr messaging --- .../webview-ui/agent-manager/i18n/ar.ts | 11 ++++++++--- .../webview-ui/agent-manager/i18n/br.ts | 11 ++++++++--- .../webview-ui/agent-manager/i18n/bs.ts | 13 +++++++++---- .../webview-ui/agent-manager/i18n/da.ts | 15 ++++++++++----- .../webview-ui/agent-manager/i18n/de.ts | 14 ++++++++++---- .../webview-ui/agent-manager/i18n/es.ts | 14 ++++++++++---- .../webview-ui/agent-manager/i18n/fa.ts | 11 ++++++++--- .../webview-ui/agent-manager/i18n/fr.ts | 14 ++++++++++---- .../webview-ui/agent-manager/i18n/it.ts | 8 +++++++- .../webview-ui/agent-manager/i18n/ja.ts | 15 +++++++++++---- .../webview-ui/agent-manager/i18n/ko.ts | 13 +++++++++---- .../webview-ui/agent-manager/i18n/nl.ts | 9 +++++++-- .../webview-ui/agent-manager/i18n/no.ts | 15 ++++++++++----- .../webview-ui/agent-manager/i18n/pl.ts | 11 ++++++++--- .../webview-ui/agent-manager/i18n/ru.ts | 11 ++++++++--- .../webview-ui/agent-manager/i18n/th.ts | 15 ++++++++++----- .../webview-ui/agent-manager/i18n/tr.ts | 8 +++++++- .../webview-ui/agent-manager/i18n/uk.ts | 7 ++++++- .../webview-ui/agent-manager/i18n/zh.ts | 13 +++++++++---- .../webview-ui/agent-manager/i18n/zht.ts | 13 +++++++++---- 20 files changed, 174 insertions(+), 67 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index 726e132d709..ae7aeceab7f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Worktree جديد", "agentManager.worktree.setupScript": "سكربت إعداد Worktree", "agentManager.worktree.delete": "حذف Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "حذف؟", "agentManager.worktree.stale": "قديم", "agentManager.worktree.staleTooltip": "مفقود على القرص أو لم يعد git worktree يتتبعه", "agentManager.worktree.removeStale": "إزالة Worktree القديم", @@ -104,8 +104,8 @@ export const dict = { "agentManager.run.options": "خيارات التشغيل", "agentManager.run.configure": "تكوين سكربت التشغيل", "agentManager.shortcuts.openAgentManager": "فتح Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "وضع الوكيل التالي", + "agentManager.shortcuts.cyclePreviousAgentMode": "وضع الوكيل السابق", "agentManager.shortcuts.showShortcuts": "إظهار اختصارات لوحة المفاتيح", "agentManager.dialog.removeStaleWorktree.title": "إزالة Worktree القديم", "agentManager.dialog.removeStaleWorktree.messagePre": "إزالة Worktree القديم ", @@ -211,4 +211,9 @@ export const dict = { "agentManager.import.noBranchesFound": "لم يتم العثور على فروع.", "agentManager.import.noBranchesHint": "الصق رابط PR أعلاه أو أنشئ Worktree جديدًا.", "agentManager.import.failed": "فشل الاستيراد", + + "agentManager.pr.error.gh_auth.title": "مصادقة GitHub مطلوبة", + "agentManager.pr.error.gh_auth.description": "شغّل 'gh auth login' في الطرفية لاستعادة حالة PR.", + "agentManager.pr.error.gh_missing.title": "لم يتم تثبيت GitHub CLI", + "agentManager.pr.error.gh_missing.description": "قم بتثبيت GitHub CLI لتفعيل حالة PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 8ff91cfdff6..843a6c86e4b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Novo Worktree", "agentManager.worktree.setupScript": "Script de configuração do Worktree", "agentManager.worktree.delete": "Excluir Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Excluir?", "agentManager.worktree.stale": "Obsoleto", "agentManager.worktree.staleTooltip": "Ausente no disco ou não é mais rastreado pelo git worktree", "agentManager.worktree.removeStale": "Remover Worktree obsoleto", @@ -106,8 +106,8 @@ export const dict = { "agentManager.run.options": "Opções de execução", "agentManager.run.configure": "Configurar script de execução", "agentManager.shortcuts.openAgentManager": "Abrir Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Próximo modo de agente", + "agentManager.shortcuts.cyclePreviousAgentMode": "Modo de agente anterior", "agentManager.shortcuts.showShortcuts": "Mostrar atalhos de teclado", "agentManager.dialog.removeStaleWorktree.title": "Remover Worktree obsoleto", "agentManager.dialog.removeStaleWorktree.messagePre": "Remover Worktree obsoleto ", @@ -215,4 +215,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Nenhum branch encontrado.", "agentManager.import.noBranchesHint": "Cole uma URL de PR acima ou crie um novo Worktree.", "agentManager.import.failed": "Falha na importação", + + "agentManager.pr.error.gh_auth.title": "Autenticação do GitHub necessária", + "agentManager.pr.error.gh_auth.description": "Execute 'gh auth login' no terminal para restaurar o status do PR.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI não instalado", + "agentManager.pr.error.gh_missing.description": "Instale o GitHub CLI para habilitar o status do PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index 10cd591c258..7ff68e5a77b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Novi Worktree", "agentManager.worktree.setupScript": "Skripta za postavljanje Worktree-a", "agentManager.worktree.delete": "Obriši Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Izbrisati?", "agentManager.worktree.stale": "Zastario", "agentManager.worktree.staleTooltip": "Nedostaje na disku ili ga git worktree više ne prati", "agentManager.worktree.removeStale": "Ukloni zastarjeli Worktree", @@ -106,8 +106,8 @@ export const dict = { "agentManager.run.options": "Opcije pokretanja", "agentManager.run.configure": "Konfiguriši skriptu za pokretanje", "agentManager.shortcuts.openAgentManager": "Otvori Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Sljedeći način rada agenta", + "agentManager.shortcuts.cyclePreviousAgentMode": "Prethodni način rada agenta", "agentManager.shortcuts.showShortcuts": "Prikaži prečice na tastaturi", "agentManager.dialog.removeStaleWorktree.title": "Ukloni zastarjeli Worktree", "agentManager.dialog.removeStaleWorktree.messagePre": "Ukloni zastarjeli Worktree ", @@ -203,7 +203,7 @@ export const dict = { "agentManager.review.imageUnavailable": "Pregled slike nije dostupan za ovaj snimak sesije.", "agentManager.review.endOfLongDiff": "Došli ste do kraja!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "Zahtjev za povlačenje", "agentManager.import.pastePrUrl": "Zalijepite PR URL...", "agentManager.import.open": "Otvori", "agentManager.import.branches": "Branchevi", @@ -214,4 +214,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Nisu pronađeni branchevi.", "agentManager.import.noBranchesHint": "Zalijepite PR URL iznad ili kreirajte novi Worktree.", "agentManager.import.failed": "Uvoz neuspješan", + + "agentManager.pr.error.gh_auth.title": "Potrebna GitHub autentikacija", + "agentManager.pr.error.gh_auth.description": "Pokrenite 'gh auth login' u terminalu da vratite status PR-a.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI nije instaliran", + "agentManager.pr.error.gh_missing.description": "Instalirajte GitHub CLI da omogućite status PR-a.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index d715e408088..d89a28452a7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Nyt Worktree", "agentManager.worktree.setupScript": "Worktree-opsætningsscript", "agentManager.worktree.delete": "Slet Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Slet?", "agentManager.worktree.stale": "Forældet", "agentManager.worktree.staleTooltip": "Mangler på disken eller spores ikke længere af git worktree", "agentManager.worktree.removeStale": "Fjern forældet Worktree", @@ -107,8 +107,8 @@ export const dict = { "agentManager.run.options": "Kørselsindstillinger", "agentManager.run.configure": "Konfigurer kørselsscript", "agentManager.shortcuts.openAgentManager": "Åbn Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Næste agenttilstand", + "agentManager.shortcuts.cyclePreviousAgentMode": "Forrige agenttilstand", "agentManager.shortcuts.showShortcuts": "Vis tastaturgenveje", "agentManager.dialog.removeStaleWorktree.title": "Fjern forældet Worktree", "agentManager.dialog.removeStaleWorktree.messagePre": "Fjern forældet Worktree ", @@ -205,10 +205,10 @@ export const dict = { "Forhåndsvisning af billedet er ikke tilgængelig for dette snapshot af sessionen.", "agentManager.review.endOfLongDiff": "Du nåede slutningen!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "Pull request", "agentManager.import.pastePrUrl": "Indsæt PR URL...", "agentManager.import.open": "Åbn", - "agentManager.import.branches": "Branches", + "agentManager.import.branches": "Grene", "agentManager.import.selectBranch": "Vælg branch...", "agentManager.import.loading": "Indlæser...", "agentManager.import.loadingBranches": "Indlæser branches...", @@ -216,4 +216,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Ingen branches fundet.", "agentManager.import.noBranchesHint": "Indsæt en PR URL ovenfor eller opret et nyt Worktree.", "agentManager.import.failed": "Import mislykkedes", + + "agentManager.pr.error.gh_auth.title": "GitHub-godkendelse påkrævet", + "agentManager.pr.error.gh_auth.description": "Kør 'gh auth login' i din terminal for at gendanne PR-status.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI er ikke installeret", + "agentManager.pr.error.gh_missing.description": "Installer GitHub CLI for at aktivere PR-status.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index f60caf8070b..0875d75f794 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Neuer Worktree", "agentManager.worktree.setupScript": "Worktree-Einrichtungsskript", "agentManager.worktree.delete": "Worktree löschen", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Löschen?", "agentManager.worktree.stale": "Veraltet", "agentManager.worktree.staleTooltip": "Fehlt auf der Festplatte oder wird nicht mehr von git worktree verfolgt", "agentManager.worktree.removeStale": "Veralteten Worktree entfernen", @@ -108,8 +108,8 @@ export const dict = { "agentManager.run.options": "Ausführungsoptionen", "agentManager.run.configure": "Ausführungsskript konfigurieren", "agentManager.shortcuts.openAgentManager": "Agent Manager öffnen", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Nächster Agentenmodus", + "agentManager.shortcuts.cyclePreviousAgentMode": "Vorheriger Agentenmodus", "agentManager.shortcuts.showShortcuts": "Tastenkürzel anzeigen", "agentManager.dialog.removeStaleWorktree.title": "Veralteten Worktree entfernen", "agentManager.dialog.removeStaleWorktree.messagePre": "Veralteten Worktree entfernen ", @@ -208,7 +208,7 @@ export const dict = { "agentManager.import.pullRequest": "Pull Request", "agentManager.import.pastePrUrl": "PR-URL einfügen...", "agentManager.import.open": "Öffnen", - "agentManager.import.branches": "Branches", + "agentManager.import.branches": "Zweige", "agentManager.import.selectBranch": "Branch auswählen...", "agentManager.import.loading": "Laden...", "agentManager.import.loadingBranches": "Branches werden geladen...", @@ -216,4 +216,10 @@ export const dict = { "agentManager.import.noBranchesFound": "Keine Branches gefunden.", "agentManager.import.noBranchesHint": "Fügen Sie oben eine PR-URL ein oder erstellen Sie einen neuen Worktree.", "agentManager.import.failed": "Import fehlgeschlagen", + + "agentManager.pr.error.gh_auth.title": "GitHub-Authentifizierung erforderlich", + "agentManager.pr.error.gh_auth.description": + "Führen Sie 'gh auth login' in Ihrem Terminal aus, um den PR-Status wiederherzustellen.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI nicht installiert", + "agentManager.pr.error.gh_missing.description": "Installieren Sie die GitHub CLI, um den PR-Status zu aktivieren.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index f0173a673fd..6b17280918d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Nuevo Worktree", "agentManager.worktree.setupScript": "Script de configuración de Worktree", "agentManager.worktree.delete": "Eliminar Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "¿Eliminar?", "agentManager.worktree.stale": "Obsoleto", "agentManager.worktree.staleTooltip": "No está en disco o ya no está seguido por git worktree", "agentManager.worktree.removeStale": "Eliminar Worktree obsoleto", @@ -107,8 +107,8 @@ export const dict = { "agentManager.run.options": "Opciones de ejecución", "agentManager.run.configure": "Configurar script de ejecución", "agentManager.shortcuts.openAgentManager": "Abrir Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Siguiente modo de agente", + "agentManager.shortcuts.cyclePreviousAgentMode": "Modo de agente anterior", "agentManager.shortcuts.showShortcuts": "Mostrar atajos de teclado", "agentManager.dialog.removeStaleWorktree.title": "Eliminar Worktree obsoleto", "agentManager.dialog.removeStaleWorktree.messagePre": "¿Eliminar Worktree obsoleto ", @@ -208,7 +208,7 @@ export const dict = { "agentManager.import.pullRequest": "Pull Request", "agentManager.import.pastePrUrl": "Pegar URL del PR...", "agentManager.import.open": "Abrir", - "agentManager.import.branches": "Branches", + "agentManager.import.branches": "Ramas", "agentManager.import.selectBranch": "Seleccionar branch...", "agentManager.import.loading": "Cargando...", "agentManager.import.loadingBranches": "Cargando branches...", @@ -216,4 +216,10 @@ export const dict = { "agentManager.import.noBranchesFound": "No se encontraron branches.", "agentManager.import.noBranchesHint": "Pega una URL de PR arriba o crea un nuevo Worktree.", "agentManager.import.failed": "Error en la importación", + + "agentManager.pr.error.gh_auth.title": "Se requiere autenticación de GitHub", + "agentManager.pr.error.gh_auth.description": + "Ejecuta 'gh auth login' en tu terminal para restaurar el estado del PR.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI no está instalado", + "agentManager.pr.error.gh_missing.description": "Instala GitHub CLI para habilitar el estado del PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts index b5d50a2b430..01aa40d7c65 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -3,8 +3,8 @@ export const dict = { "agentManager.sidebar.collapse": "بستن نوار کناری", "agentManager.sidebar.expand": "نمایش نوار کناری", "agentManager.section.worktrees": "WORKTREES", - "agentManager.section.sessions": "SESSIONS", - "agentManager.projects": "PROJECTS", + "agentManager.section.sessions": "جلسات", + "agentManager.projects": "پروژه‌ها", "agentManager.project.add": "افزودن پروژه", "agentManager.project.remove": "حذف از Agent Manager", "agentManager.project.trust": "اعتماد", @@ -207,7 +207,7 @@ export const dict = { "agentManager.review.imageUnavailable": "پیش‌نمایش تصویر برای این نمونه جلسه در دسترس نیست.", "agentManager.review.endOfLongDiff": "به انتها رسیدید!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "درخواست ادغام", "agentManager.import.pastePrUrl": "URL درخواست PR را وارد کنید...", "agentManager.import.open": "باز کردن", "agentManager.import.branches": "شاخه‌ها", @@ -218,4 +218,9 @@ export const dict = { "agentManager.import.noBranchesFound": "هیچ شاخه‌ای یافت نشد.", "agentManager.import.noBranchesHint": "یک URL درخواست ادغام را در بالا جای‌گذاری کنید یا یک worktree جدید بسازید.", "agentManager.import.failed": "وارد کردن ناموفق بود", + + "agentManager.pr.error.gh_auth.title": "احراز هویت GitHub لازم است", + "agentManager.pr.error.gh_auth.description": "برای بازیابی وضعیت PR، دستور 'gh auth login' را در ترمینال اجرا کنید.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI نصب نشده است", + "agentManager.pr.error.gh_missing.description": "برای فعال کردن وضعیت PR، GitHub CLI را نصب کنید.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index 544b5007a2c..3d8f7279b9f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Nouveau Worktree", "agentManager.worktree.setupScript": "Script de configuration du Worktree", "agentManager.worktree.delete": "Supprimer le Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Supprimer ?", "agentManager.worktree.stale": "Obsolète", "agentManager.worktree.staleTooltip": "Absent du disque ou n'est plus suivi par git worktree", "agentManager.worktree.removeStale": "Supprimer le Worktree obsolète", @@ -60,7 +60,7 @@ export const dict = { "agentManager.sidebarSearch.label": "Rechercher des Worktrees et des sessions", "agentManager.sidebarSearch.scope": "Recherche dans l'espace de travail local, les sessions locales, les Worktrees et leurs sessions", - "agentManager.sidebarSearch.contexts": "LOCAL & WORKTREES", + "agentManager.sidebarSearch.contexts": "LOCAL ET WORKTREES", "agentManager.terminal.new": "Nouvel onglet de terminal", "agentManager.terminal.ended": "terminal terminé — fermez l'onglet pour ignorer", @@ -107,8 +107,8 @@ export const dict = { "agentManager.run.options": "Options d'exécution", "agentManager.run.configure": "Configurer le script d'exécution", "agentManager.shortcuts.openAgentManager": "Ouvrir Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Mode d'agent suivant", + "agentManager.shortcuts.cyclePreviousAgentMode": "Mode d'agent précédent", "agentManager.shortcuts.showShortcuts": "Afficher les raccourcis clavier", "agentManager.dialog.removeStaleWorktree.title": "Supprimer le Worktree obsolète", "agentManager.dialog.removeStaleWorktree.messagePre": "Supprimer le Worktree obsolète ", @@ -216,4 +216,10 @@ export const dict = { "agentManager.import.noBranchesFound": "Aucune branche trouvée.", "agentManager.import.noBranchesHint": "Collez une URL de PR ci-dessus ou créez un nouveau Worktree.", "agentManager.import.failed": "Échec de l'importation", + + "agentManager.pr.error.gh_auth.title": "Authentification GitHub requise", + "agentManager.pr.error.gh_auth.description": + "Exécutez 'gh auth login' dans votre terminal pour restaurer le statut de la PR.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI non installé", + "agentManager.pr.error.gh_missing.description": "Installez GitHub CLI pour activer le statut de la PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index 59f6ea340c6..684ea283f4f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -36,7 +36,7 @@ export const dict = { "agentManager.section.moveUp": "Sposta su", "agentManager.section.moveDown": "Sposta giù", - "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.branch": "Ramo", "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessioni", @@ -222,4 +222,10 @@ export const dict = { "agentManager.import.noBranchesFound": "Nessun branch trovato.", "agentManager.import.noBranchesHint": "Incolla un URL PR sopra o crea un nuovo worktree.", "agentManager.import.failed": "Importazione non riuscita", + + "agentManager.pr.error.gh_auth.title": "Autenticazione GitHub richiesta", + "agentManager.pr.error.gh_auth.description": + "Esegui 'gh auth login' nel terminale per ripristinare lo stato della PR.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI non installato", + "agentManager.pr.error.gh_missing.description": "Installa GitHub CLI per abilitare lo stato della PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index 5e4dd806ef5..c6ef13f8cb0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "新しいWorktree", "agentManager.worktree.setupScript": "Worktreeセットアップスクリプト", "agentManager.worktree.delete": "Worktreeを削除", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "削除しますか?", "agentManager.worktree.stale": "無効", "agentManager.worktree.staleTooltip": "ディスク上に存在しないか、git worktree で追跡されていません", "agentManager.worktree.removeStale": "無効な Worktree を削除", @@ -107,8 +107,8 @@ export const dict = { "agentManager.run.options": "実行オプション", "agentManager.run.configure": "実行スクリプトを設定", "agentManager.shortcuts.openAgentManager": "Agent Managerを開く", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "次のエージェントモード", + "agentManager.shortcuts.cyclePreviousAgentMode": "前のエージェントモード", "agentManager.shortcuts.showShortcuts": "キーボードショートカットを表示", "agentManager.dialog.removeStaleWorktree.title": "無効な Worktree を削除", "agentManager.dialog.removeStaleWorktree.messagePre": "無効な Worktree を削除 ", @@ -204,7 +204,7 @@ export const dict = { "agentManager.review.imageUnavailable": "このセッションスナップショットでは画像プレビューを利用できません。", "agentManager.review.endOfLongDiff": "最後まで到達しました!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "プルリクエスト", "agentManager.import.pastePrUrl": "PR URLを貼り付け...", "agentManager.import.open": "開く", "agentManager.import.branches": "ブランチ", @@ -215,4 +215,11 @@ export const dict = { "agentManager.import.noBranchesFound": "ブランチが見つかりません。", "agentManager.import.noBranchesHint": "上にPR URLを貼り付けるか、新しいWorktreeを作成してください。", "agentManager.import.failed": "インポートに失敗しました", + + "agentManager.pr.error.gh_auth.title": "GitHub認証が必要です", + "agentManager.pr.error.gh_auth.description": + "PRステータスを復元するには、ターミナルで 'gh auth login' を実行してください。", + "agentManager.pr.error.gh_missing.title": "GitHub CLIがインストールされていません", + "agentManager.pr.error.gh_missing.description": + "PRステータスを有効にするには、GitHub CLIをインストールしてください。", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index b729493b1d3..e8fa9677984 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "새 Worktree", "agentManager.worktree.setupScript": "Worktree 설정 스크립트", "agentManager.worktree.delete": "Worktree 삭제", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "삭제하시겠습니까?", "agentManager.worktree.stale": "오래됨", "agentManager.worktree.staleTooltip": "디스크에 없거나 더 이상 git worktree에서 추적되지 않습니다", "agentManager.worktree.removeStale": "오래된 Worktree 제거", @@ -105,8 +105,8 @@ export const dict = { "agentManager.run.options": "실행 옵션", "agentManager.run.configure": "실행 스크립트 구성", "agentManager.shortcuts.openAgentManager": "Agent Manager 열기", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "다음 에이전트 모드", + "agentManager.shortcuts.cyclePreviousAgentMode": "이전 에이전트 모드", "agentManager.shortcuts.showShortcuts": "키보드 단축키 표시", "agentManager.dialog.removeStaleWorktree.title": "오래된 Worktree 제거", "agentManager.dialog.removeStaleWorktree.messagePre": "오래된 Worktree 제거 ", @@ -202,7 +202,7 @@ export const dict = { "agentManager.review.imageUnavailable": "이 세션 스냅샷에서는 이미지 미리보기를 사용할 수 없습니다.", "agentManager.review.endOfLongDiff": "끝까지 도달했습니다!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "풀 리퀘스트", "agentManager.import.pastePrUrl": "PR URL 붙여넣기...", "agentManager.import.open": "열기", "agentManager.import.branches": "브랜치", @@ -213,4 +213,9 @@ export const dict = { "agentManager.import.noBranchesFound": "브랜치를 찾을 수 없습니다.", "agentManager.import.noBranchesHint": "위에 PR URL을 붙여넣거나 새 Worktree를 만드세요.", "agentManager.import.failed": "가져오기 실패", + + "agentManager.pr.error.gh_auth.title": "GitHub 인증 필요", + "agentManager.pr.error.gh_auth.description": "PR 상태를 복원하려면 터미널에서 'gh auth login'을 실행하세요.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI가 설치되지 않음", + "agentManager.pr.error.gh_missing.description": "PR 상태를 활성화하려면 GitHub CLI를 설치하세요.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index 3ffc6c7dce5..89171ad1b81 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -138,7 +138,7 @@ export const dict = { "agentManager.dialog.baseBranch": "Basis branch", "agentManager.dialog.searchBranches": "Zoeken naar branches...", "agentManager.dialog.branchBadge.default": "standaard", - "agentManager.dialog.branchBadge.remote": "remote", + "agentManager.dialog.branchBadge.remote": "extern", "agentManager.dialog.versions": "Versies", "agentManager.dialog.versionHint": "{{count}} worktrees zullen parallel draaien", "agentManager.dialog.compareModels": "Modellen vergelijken", @@ -209,7 +209,7 @@ export const dict = { "agentManager.review.imageUnavailable": "Afbeeldingsvoorbeeld is niet beschikbaar voor deze sessiesnapshot.", "agentManager.review.endOfLongDiff": "Je hebt het einde bereikt!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "Pull request", "agentManager.import.pastePrUrl": "Plak PR URL...", "agentManager.import.open": "Openen", "agentManager.import.branches": "Branches", @@ -220,4 +220,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Geen branches gevonden.", "agentManager.import.noBranchesHint": "Plak hierboven een PR URL of maak een nieuwe worktree.", "agentManager.import.failed": "Importeren mislukt", + + "agentManager.pr.error.gh_auth.title": "GitHub-authenticatie vereist", + "agentManager.pr.error.gh_auth.description": "Voer 'gh auth login' uit in je terminal om de PR-status te herstellen.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI niet geïnstalleerd", + "agentManager.pr.error.gh_missing.description": "Installeer de GitHub CLI om de PR-status in te schakelen.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index d5dfa02a817..5807661c9d3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Nytt Worktree", "agentManager.worktree.setupScript": "Worktree-oppsettskript", "agentManager.worktree.delete": "Slett Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Slette?", "agentManager.worktree.stale": "Utdatert", "agentManager.worktree.staleTooltip": "Mangler på disken eller spores ikke lenger av git worktree", "agentManager.worktree.removeStale": "Fjern utdatert Worktree", @@ -105,8 +105,8 @@ export const dict = { "agentManager.run.options": "Kjøringsalternativer", "agentManager.run.configure": "Konfigurer kjøreskript", "agentManager.shortcuts.openAgentManager": "Åpne Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Neste agentmodus", + "agentManager.shortcuts.cyclePreviousAgentMode": "Forrige agentmodus", "agentManager.shortcuts.showShortcuts": "Vis tastatursnarveier", "agentManager.dialog.removeStaleWorktree.title": "Fjern utdatert Worktree", "agentManager.dialog.removeStaleWorktree.messagePre": "Fjern utdatert Worktree ", @@ -202,10 +202,10 @@ export const dict = { "Forhåndsvisning av bildet er ikke tilgjengelig for dette øyeblikksbildet av økten.", "agentManager.review.endOfLongDiff": "Du nådde slutten!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "Pull request", "agentManager.import.pastePrUrl": "Lim inn PR URL...", "agentManager.import.open": "Åpne", - "agentManager.import.branches": "Branches", + "agentManager.import.branches": "Grener", "agentManager.import.selectBranch": "Velg branch...", "agentManager.import.loading": "Laster...", "agentManager.import.loadingBranches": "Laster branches...", @@ -213,4 +213,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Ingen branches funnet.", "agentManager.import.noBranchesHint": "Lim inn en PR URL ovenfor eller opprett et nytt Worktree.", "agentManager.import.failed": "Import mislyktes", + + "agentManager.pr.error.gh_auth.title": "GitHub-autentisering kreves", + "agentManager.pr.error.gh_auth.description": "Kjør 'gh auth login' i terminalen for å gjenopprette PR-status.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI er ikke installert", + "agentManager.pr.error.gh_missing.description": "Installer GitHub CLI for å aktivere PR-status.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 9fb33a5c980..ddd54169991 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Nowy Worktree", "agentManager.worktree.setupScript": "Skrypt konfiguracji Worktree", "agentManager.worktree.delete": "Usuń Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Usunąć?", "agentManager.worktree.stale": "Nieaktualny", "agentManager.worktree.staleTooltip": "Brak na dysku lub nie jest już śledzony przez git worktree", "agentManager.worktree.removeStale": "Usuń nieaktualny Worktree", @@ -107,8 +107,8 @@ export const dict = { "agentManager.run.options": "Opcje uruchamiania", "agentManager.run.configure": "Konfiguruj skrypt uruchamiania", "agentManager.shortcuts.openAgentManager": "Otwórz Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Następny tryb agenta", + "agentManager.shortcuts.cyclePreviousAgentMode": "Poprzedni tryb agenta", "agentManager.shortcuts.showShortcuts": "Pokaż skróty klawiszowe", "agentManager.dialog.removeStaleWorktree.title": "Usuń nieaktualny Worktree", "agentManager.dialog.removeStaleWorktree.messagePre": "Usunąć nieaktualny Worktree ", @@ -214,4 +214,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Nie znaleziono branchy.", "agentManager.import.noBranchesHint": "Wklej URL PR powyżej lub utwórz nowy Worktree.", "agentManager.import.failed": "Import nie powiódł się", + + "agentManager.pr.error.gh_auth.title": "Wymagana autoryzacja GitHub", + "agentManager.pr.error.gh_auth.description": "Uruchom 'gh auth login' w terminalu, aby przywrócić status PR.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI nie jest zainstalowane", + "agentManager.pr.error.gh_missing.description": "Zainstaluj GitHub CLI, aby włączyć status PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index 74afb2c7aa3..90ae939cbdc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Новый Worktree", "agentManager.worktree.setupScript": "Скрипт настройки Worktree", "agentManager.worktree.delete": "Удалить Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Удалить?", "agentManager.worktree.stale": "Устаревший", "agentManager.worktree.staleTooltip": "Отсутствует на диске или больше не отслеживается git worktree", "agentManager.worktree.removeStale": "Удалить устаревший Worktree", @@ -107,8 +107,8 @@ export const dict = { "agentManager.run.options": "Параметры запуска", "agentManager.run.configure": "Настроить скрипт запуска", "agentManager.shortcuts.openAgentManager": "Открыть Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Следующий режим агента", + "agentManager.shortcuts.cyclePreviousAgentMode": "Предыдущий режим агента", "agentManager.shortcuts.showShortcuts": "Показать сочетания клавиш", "agentManager.dialog.removeStaleWorktree.title": "Удалить устаревший Worktree", "agentManager.dialog.removeStaleWorktree.messagePre": "Удалить устаревший Worktree ", @@ -215,4 +215,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Ветки не найдены.", "agentManager.import.noBranchesHint": "Вставьте URL PR выше или создайте новый Worktree.", "agentManager.import.failed": "Ошибка импорта", + + "agentManager.pr.error.gh_auth.title": "Требуется аутентификация GitHub", + "agentManager.pr.error.gh_auth.description": "Выполните 'gh auth login' в терминале, чтобы восстановить статус PR.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI не установлен", + "agentManager.pr.error.gh_missing.description": "Установите GitHub CLI, чтобы включить статус PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index b65655f053c..0a395b5ff8f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Worktree ใหม่", "agentManager.worktree.setupScript": "สคริปต์ตั้งค่า Worktree", "agentManager.worktree.delete": "ลบ Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "ลบ?", "agentManager.worktree.stale": "ล้าสมัย", "agentManager.worktree.staleTooltip": "ไม่พบบนดิสก์หรือไม่ได้ถูกติดตามโดย git worktree อีกต่อไป", "agentManager.worktree.removeStale": "ลบ Worktree ที่ล้าสมัย", @@ -102,8 +102,8 @@ export const dict = { "agentManager.run.options": "ตัวเลือกการเรียกใช้", "agentManager.run.configure": "กำหนดค่าสคริปต์การเรียกใช้", "agentManager.shortcuts.openAgentManager": "เปิด Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "โหมดเอเจนต์ถัดไป", + "agentManager.shortcuts.cyclePreviousAgentMode": "โหมดเอเจนต์ก่อนหน้า", "agentManager.shortcuts.showShortcuts": "แสดงปุ่มลัดแป้นพิมพ์", "agentManager.dialog.removeStaleWorktree.title": "ลบ Worktree ที่ล้าสมัย", "agentManager.dialog.removeStaleWorktree.messagePre": "ลบ Worktree ที่ล้าสมัย ", @@ -198,10 +198,10 @@ export const dict = { "agentManager.review.imageUnavailable": "ไม่สามารถแสดงตัวอย่างรูปภาพสำหรับสแนปช็อตของเซสชันนี้ได้", "agentManager.review.endOfLongDiff": "คุณมาถึงท้ายสุดแล้ว!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "คำขอรวมโค้ด", "agentManager.import.pastePrUrl": "วาง URL ของ PR...", "agentManager.import.open": "เปิด", - "agentManager.import.branches": "Branches", + "agentManager.import.branches": "สาขา", "agentManager.import.selectBranch": "เลือก Branch...", "agentManager.import.loading": "กำลังโหลด...", "agentManager.import.loadingBranches": "กำลังโหลด Branches...", @@ -209,4 +209,9 @@ export const dict = { "agentManager.import.noBranchesFound": "ไม่พบ Branch", "agentManager.import.noBranchesHint": "วาง URL ของ PR ด้านบนหรือสร้าง Worktree ใหม่", "agentManager.import.failed": "นำเข้าล้มเหลว", + + "agentManager.pr.error.gh_auth.title": "ต้องยืนยันตัวตน GitHub", + "agentManager.pr.error.gh_auth.description": "รันคำสั่ง 'gh auth login' ในเทอร์มินัลเพื่อกู้คืนสถานะ PR", + "agentManager.pr.error.gh_missing.title": "ไม่ได้ติดตั้ง GitHub CLI", + "agentManager.pr.error.gh_missing.description": "ติดตั้ง GitHub CLI เพื่อเปิดใช้งานสถานะ PR", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index 43b033d32a9..ed3c98980c7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -211,7 +211,7 @@ export const dict = { "agentManager.review.imageUnavailable": "Bu oturum anlık görüntüsü için görsel önizlemesi kullanılamıyor.", "agentManager.review.endOfLongDiff": "Sonuna geldiniz!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "Çekme İsteği", "agentManager.import.pastePrUrl": "PR URL'sini yapıştırın...", "agentManager.import.open": "Aç", "agentManager.import.branches": "Dallar", @@ -222,4 +222,10 @@ export const dict = { "agentManager.import.noBranchesFound": "Hiçbir dal bulunamadı.", "agentManager.import.noBranchesHint": "Yukarıya bir PR URL'si yapıştırın veya yeni bir worktree oluşturun.", "agentManager.import.failed": "İçe aktarma başarısız oldu", + + "agentManager.pr.error.gh_auth.title": "GitHub kimlik doğrulaması gerekli", + "agentManager.pr.error.gh_auth.description": + "PR durumunu geri yüklemek için terminalinizde 'gh auth login' komutunu çalıştırın.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI yüklü değil", + "agentManager.pr.error.gh_missing.description": "PR durumunu etkinleştirmek için GitHub CLI'yi yükleyin.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index 0242498ddf5..a6c8f9dc53e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -212,7 +212,7 @@ export const dict = { "agentManager.review.imageUnavailable": "Попередній перегляд зображення недоступний для цього знімка сеансу.", "agentManager.review.endOfLongDiff": "Ви дійшли до кінця!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "Пул-реквест", "agentManager.import.pastePrUrl": "Вставте URL PR...", "agentManager.import.open": "Відкрити", "agentManager.import.branches": "Гілки", @@ -223,4 +223,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Гілок не знайдено.", "agentManager.import.noBranchesHint": "Вставте URL PR вище або створіть нове робоче дерево.", "agentManager.import.failed": "Імпортування не вдалося", + + "agentManager.pr.error.gh_auth.title": "Потрібна автентифікація GitHub", + "agentManager.pr.error.gh_auth.description": "Виконайте 'gh auth login' у терміналі, щоб відновити статус PR.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI не встановлено", + "agentManager.pr.error.gh_missing.description": "Встановіть GitHub CLI, щоб увімкнути статус PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index 8bbcfd45263..8010f67b7d7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "新建 Worktree", "agentManager.worktree.setupScript": "Worktree 设置脚本", "agentManager.worktree.delete": "删除 Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "删除?", "agentManager.worktree.stale": "已失效", "agentManager.worktree.staleTooltip": "在磁盘上缺失,或不再由 git worktree 跟踪", "agentManager.worktree.removeStale": "移除失效 Worktree", @@ -102,8 +102,8 @@ export const dict = { "agentManager.run.options": "运行选项", "agentManager.run.configure": "配置运行脚本", "agentManager.shortcuts.openAgentManager": "打开 Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "下一个代理模式", + "agentManager.shortcuts.cyclePreviousAgentMode": "上一个代理模式", "agentManager.shortcuts.showShortcuts": "显示键盘快捷键", "agentManager.dialog.removeStaleWorktree.title": "移除失效 Worktree", "agentManager.dialog.removeStaleWorktree.messagePre": "移除失效 Worktree ", @@ -196,7 +196,7 @@ export const dict = { "agentManager.review.imageUnavailable": "此会话快照无法提供图片预览。", "agentManager.review.endOfLongDiff": "你已经到末尾了!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "拉取请求", "agentManager.import.pastePrUrl": "粘贴 PR URL...", "agentManager.import.open": "打开", "agentManager.import.branches": "分支", @@ -207,4 +207,9 @@ export const dict = { "agentManager.import.noBranchesFound": "未找到分支。", "agentManager.import.noBranchesHint": "在上方粘贴 PR URL 或创建新的 Worktree。", "agentManager.import.failed": "导入失败", + + "agentManager.pr.error.gh_auth.title": "需要 GitHub 身份验证", + "agentManager.pr.error.gh_auth.description": "在终端中运行 'gh auth login' 以恢复 PR 状态。", + "agentManager.pr.error.gh_missing.title": "未安装 GitHub CLI", + "agentManager.pr.error.gh_missing.description": "安装 GitHub CLI 以启用 PR 状态。", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index 0acadafdbfe..ddb93a8804a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "新建 Worktree", "agentManager.worktree.setupScript": "Worktree 設定指令碼", "agentManager.worktree.delete": "刪除 Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "刪除?", "agentManager.worktree.stale": "已失效", "agentManager.worktree.staleTooltip": "磁碟上不存在,或不再由 git worktree 追蹤", "agentManager.worktree.removeStale": "移除失效 Worktree", @@ -102,8 +102,8 @@ export const dict = { "agentManager.run.options": "執行選項", "agentManager.run.configure": "設定執行指令碼", "agentManager.shortcuts.openAgentManager": "開啟 Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "下一個代理模式", + "agentManager.shortcuts.cyclePreviousAgentMode": "上一個代理模式", "agentManager.shortcuts.showShortcuts": "顯示鍵盤快捷鍵", "agentManager.dialog.removeStaleWorktree.title": "移除失效 Worktree", "agentManager.dialog.removeStaleWorktree.messagePre": "移除失效 Worktree ", @@ -196,7 +196,7 @@ export const dict = { "agentManager.review.imageUnavailable": "此工作階段快照無法預覽圖片。", "agentManager.review.endOfLongDiff": "你已經到最後了!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "提取請求", "agentManager.import.pastePrUrl": "貼上 PR URL...", "agentManager.import.open": "開啟", "agentManager.import.branches": "分支", @@ -207,4 +207,9 @@ export const dict = { "agentManager.import.noBranchesFound": "找不到分支。", "agentManager.import.noBranchesHint": "在上方貼上 PR URL 或建立新的 Worktree。", "agentManager.import.failed": "匯入失敗", + + "agentManager.pr.error.gh_auth.title": "需要 GitHub 驗證", + "agentManager.pr.error.gh_auth.description": "在終端機中執行 'gh auth login' 以還原 PR 狀態。", + "agentManager.pr.error.gh_missing.title": "未安裝 GitHub CLI", + "agentManager.pr.error.gh_missing.description": "安裝 GitHub CLI 以啟用 PR 狀態。", } From baf568564efc1fcff9bf8057fc09e053005df508 Mon Sep 17 00:00:00 2001 From: cmanu Date: Fri, 7 Aug 2026 13:19:31 -0700 Subject: [PATCH 30/54] fix(pr-view): don't include skipped prs in pr summary and also remove unused files causing checks to fail --- .../src/agent-manager/PRStatusPoller.ts | 2 +- .../src/agent-manager/am-pr-utils.ts | 4 +- .../agent-manager/default-base-branch.tsx | 130 ------------------ .../agent-manager/pr/PRFileChanges.tsx | 26 ---- 4 files changed, 4 insertions(+), 158 deletions(-) delete mode 100644 packages/kilo-vscode/webview-ui/agent-manager/default-base-branch.tsx delete mode 100644 packages/kilo-vscode/webview-ui/agent-manager/pr/PRFileChanges.tsx diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index afe6a3e3854..b66c42ba434 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -405,7 +405,7 @@ export class PRStatusPoller { duration: formatCheckDuration(c.startedAt, c.completedAt), })) - const total = checks.length + const total = checks.filter((c) => c.status !== "skipped").length const passed = checks.filter((c) => c.status === "success").length const failed = checks.filter((c) => c.status === "failure").length const pending = checks.filter((c) => c.status === "pending").length diff --git a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts b/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts index 9cf563d0aef..5877d4ab021 100644 --- a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts +++ b/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts @@ -30,9 +30,11 @@ export function parsePRResult(json: string): PRResult | null { export function checkStatus(state: string): CheckStatus { switch (state.toUpperCase()) { case "SUCCESS": + case "NEUTRAL": return "success" case "FAILURE": case "ERROR": + case "ACTION_REQUIRED": return "failure" case "PENDING": case "QUEUED": @@ -48,7 +50,7 @@ export function checkStatus(state: string): CheckStatus { case "STARTUP_FAILURE": return "cancelled" default: - return "pending" + return "cancelled" } } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/default-base-branch.tsx b/packages/kilo-vscode/webview-ui/agent-manager/default-base-branch.tsx deleted file mode 100644 index 421c17c8bf3..00000000000 --- a/packages/kilo-vscode/webview-ui/agent-manager/default-base-branch.tsx +++ /dev/null @@ -1,130 +0,0 @@ -/** @jsxImportSource solid-js */ - -import { createSignal, createMemo, onCleanup } from "solid-js" -import type { AgentManagerBranchesMessage, BranchInfo } from "../src/types/messages" -import { Dialog } from "@kilocode/kilo-ui/dialog" -import { useDialog } from "@kilocode/kilo-ui/context/dialog" -import { BranchSelect } from "../src/components/shared/BranchSelect" -import type { useVSCode } from "../src/context/vscode" -import type { useLanguage } from "../src/context/language" -import type { Accessor, Setter } from "solid-js" - -interface Deps { - vscode: ReturnType - dialog: ReturnType - t: ReturnType["t"] - defaultBaseBranch: Accessor - setDefaultBaseBranch: (v: Parameters>[0]) => void - setRepoDetectedBranch: Setter - repoDetectedBranch: Accessor - hasConfiguredBranch: Accessor -} - -export function createChangeDefaultBaseBranch(deps: Deps) { - const { - vscode, - dialog, - t, - defaultBaseBranch, - setDefaultBaseBranch, - setRepoDetectedBranch, - repoDetectedBranch, - hasConfiguredBranch, - } = deps - - return () => { - const [search, setSearch] = createSignal("") - const [branches, setBranches] = createSignal([]) - const [loading, setLoading] = createSignal(true) - const [highlighted, setHighlighted] = createSignal(-1) - - const unsub = vscode.onMessage((msg) => { - if (msg.type === "agentManager.branches") { - const ev = msg as AgentManagerBranchesMessage - setBranches(ev.branches) - if (ev.defaultBranch) setRepoDetectedBranch(ev.defaultBranch) - setLoading(false) - } - }) - - vscode.postMessage({ type: "agentManager.requestBranches" }) - - const filtered = createMemo(() => { - const s = search().toLowerCase() - if (!s) return branches() - return branches().filter((b) => b.name.toLowerCase().includes(s)) - }) - - const selectBranch = (name: string | undefined) => { - vscode.postMessage({ type: "agentManager.setDefaultBaseBranch", branch: name }) - setDefaultBaseBranch(name) - dialog.close() - } - - const handleKeyDown = (e: KeyboardEvent) => { - const items = filtered() - // offset by 1 for auto-detect option (-1 = auto-detect) - const total = items.length + 1 - if (e.key === "ArrowDown") { - e.preventDefault() - e.stopPropagation() - setHighlighted((prev) => Math.min(prev + 1, total - 2)) - } else if (e.key === "ArrowUp") { - e.preventDefault() - e.stopPropagation() - setHighlighted((prev) => Math.max(prev - 1, -1)) - } else if (e.key === "Enter") { - e.preventDefault() - e.stopPropagation() - const idx = highlighted() - if (idx === -1) { - selectBranch(undefined) - } else { - const branch = items[idx] - if (branch) selectBranch(branch.name) - } - } else if (e.key === "Escape") { - e.preventDefault() - e.stopPropagation() - dialog.close() - } - } - - dialog.show(() => { - onCleanup(unsub) - return ( - -
- { - setSearch(v) - setHighlighted(-1) - }} - onSelect={(b) => selectBranch(b.name)} - onSearchKeyDown={handleKeyDown} - selected={defaultBaseBranch()} - highlighted={highlighted()} - onHighlight={setHighlighted} - searchPlaceholder={t("agentManager.dialog.searchBranches")} - emptyLabel={t("agentManager.import.noMatchingBranches")} - loadingLabel={t("agentManager.import.loadingBranches")} - defaultLabel={t("agentManager.dialog.branchBadge.default")} - remoteLabel={t("agentManager.dialog.branchBadge.remote")} - defaultName={defaultBaseBranch()} - autoOption={{ - label: t("agentManager.worktree.defaultBaseBranchAuto"), - hint: repoDetectedBranch(), - active: !hasConfiguredBranch(), - highlighted: highlighted() === -1, - onSelect: () => selectBranch(undefined), - }} - /> -
-
- ) - }) - } -} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRFileChanges.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRFileChanges.tsx deleted file mode 100644 index 971948d8e28..00000000000 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRFileChanges.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/** @jsxImportSource solid-js */ -import { Show } from "solid-js" - -export function PRFileChanges(props: { files: number; additions: number; deletions: number }) { - return ( - <> -
-
-
- File Changes - - 0}> - {props.files}f - - 0}> - +{props.additions} - - 0}> - −{props.deletions} - - -
-
- - ) -} From c313dc47879218e89515765255316cf75ead61cf Mon Sep 17 00:00:00 2001 From: cmanu Date: Fri, 7 Aug 2026 13:44:09 -0700 Subject: [PATCH 31/54] fix(pr-view): default pr status state --- packages/kilo-vscode/src/agent-manager/am-pr-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts b/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts index 5877d4ab021..5fe6e0fad5a 100644 --- a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts +++ b/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts @@ -50,7 +50,7 @@ export function checkStatus(state: string): CheckStatus { case "STARTUP_FAILURE": return "cancelled" default: - return "cancelled" + return "pending" } } From 2dc7442ab611c1070a8c8f6d17f956c229f9c84b Mon Sep 17 00:00:00 2001 From: cmanu Date: Fri, 7 Aug 2026 14:05:18 -0700 Subject: [PATCH 32/54] fix(pr-view): add kilocode change --- packages/ui/src/components/icon.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index e2170c0772e..8bd13c7599f 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -18,7 +18,7 @@ const icons = { "chevron-grabber-vertical": ``, "chevron-double-right": ``, "circle-x": ``, - "circle-x-outline": ``, + "circle-x-outline": ``, // kilocode_change close: ``, "close-small": ``, checklist: ``, @@ -91,7 +91,7 @@ const icons = { download: ``, menu: ``, server: ``, - "pull-request": ``, + "pull-request": ``, // kilocode_change branch: ``, edit: ``, history: ``, @@ -108,7 +108,7 @@ const icons = { "star-filled": ``, warning: ``, reset: ``, - refresh: ``, + refresh: ``, // kilocode_change link: ``, providers: ``, layers: ``, From 48c4a4af227572011bf44c172ab0ae86e0c2a429 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:20:37 +0000 Subject: [PATCH 33/54] fix(gateway): ignore negative prices in model catalog Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .changeset/ignore-negative-model-prices.md | 7 +++ packages/kilo-gateway/src/api/models.ts | 2 +- packages/kilo-gateway/test/api/models.test.ts | 63 +++++++++++++++++++ .../tests/unit/model-price-format.test.ts | 5 ++ .../components/shared/model-preview-utils.ts | 1 + .../components/model-info-panel-utils.ts | 1 + .../kilocode/model-info-panel-utils.test.ts | 5 ++ 7 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 .changeset/ignore-negative-model-prices.md diff --git a/.changeset/ignore-negative-model-prices.md b/.changeset/ignore-negative-model-prices.md new file mode 100644 index 00000000000..629d5fcc02c --- /dev/null +++ b/.changeset/ignore-negative-model-prices.md @@ -0,0 +1,7 @@ +--- +"@kilocode/kilo-gateway": patch +"@kilocode/cli": patch +"kilo-code": patch +--- + +Ignore negative pricing entries from model catalogs and handle unpriced models gracefully in UI price formatting. diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index 8188c1f825b..3ccb4ff2534 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -76,7 +76,7 @@ type OpenRouterModel = z.infer function parseApiPrice(price: string | null | undefined): number | undefined { if (!price) return undefined const parsed = parseFloat(price) - if (isNaN(parsed)) return undefined + if (isNaN(parsed) || parsed < 0) return undefined return parsed * 1_000_000 // Convert $/token → $/M tokens } diff --git a/packages/kilo-gateway/test/api/models.test.ts b/packages/kilo-gateway/test/api/models.test.ts index 0833ec11f30..7d73b3d1b5e 100644 --- a/packages/kilo-gateway/test/api/models.test.ts +++ b/packages/kilo-gateway/test/api/models.test.ts @@ -383,3 +383,66 @@ test("keeps organization catalog errors from silently falling back to personal m expect(calls[0]).toContain("/api/gateway/transcription-models") expect(headers[0]?.get("X-KILOCODE-ORGANIZATIONID")).toBe("org-1") }) + +test("omits cost when pricing contains negative values (dynamic/auto-routed pricing)", async () => { + const orig = globalThis.fetch + stubFetch( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: "openrouter/auto", + name: "Auto Router", + context_length: 128000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text"], + output_modalities: ["text"], + }, + supported_parameters: ["tools"], + pricing: { + prompt: "-1", + completion: "-1", + }, + }, + { + id: "test/fixed-price", + name: "Fixed Price Model", + context_length: 128000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text"], + output_modalities: ["text"], + }, + supported_parameters: ["tools"], + pricing: { + prompt: "0.000003", + completion: "0.000015", + input_cache_read: "0.0000003", + input_cache_write: "-1", + }, + }, + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.error).toBeUndefined() + expect(result.models["openrouter/auto"]).toBeDefined() + expect(result.models["openrouter/auto"].cost).toBeUndefined() + expect(result.models["test/fixed-price"].cost).toEqual({ + input: 3, + output: 15, + cache_read: 0.3, + }) +}) + diff --git a/packages/kilo-vscode/tests/unit/model-price-format.test.ts b/packages/kilo-vscode/tests/unit/model-price-format.test.ts index d5aab2d6729..8a01b838094 100644 --- a/packages/kilo-vscode/tests/unit/model-price-format.test.ts +++ b/packages/kilo-vscode/tests/unit/model-price-format.test.ts @@ -18,6 +18,11 @@ describe("fmtPrice", () => { expect(fmtPrice(0)).toBe("Free") }) + it("returns dash for negative price", () => { + expect(fmtPrice(-1)).toBe("—") + expect(fmtPrice(-1000000)).toBe("—") + }) + it("uses 4 decimal places for sub-cent prices", () => { expect(fmtPrice(0.005)).toBe("$0.0050/1M") }) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts b/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts index 980ab78f368..10cd1f44d76 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts @@ -3,6 +3,7 @@ * Expects `n` in $/M tokens (as stored in model.cost.input / model.cost.output). */ export function fmtPrice(n: number): string { + if (n < 0) return "—" if (n === 0) return "Free" if (n < 0.01) return `$${n.toFixed(4)}/1M` return `$${n.toFixed(2)}/1M` diff --git a/packages/opencode/src/kilocode/components/model-info-panel-utils.ts b/packages/opencode/src/kilocode/components/model-info-panel-utils.ts index 1c7bb828b56..5cfc69d7b70 100644 --- a/packages/opencode/src/kilocode/components/model-info-panel-utils.ts +++ b/packages/opencode/src/kilocode/components/model-info-panel-utils.ts @@ -8,6 +8,7 @@ interface Cost { } export function fmtPrice(n: number): string { + if (n < 0) return "—" if (n === 0) return "Free" if (n < 0.01) return `$${n.toFixed(4)}/1M` return `$${n.toFixed(2)}/1M` diff --git a/packages/opencode/test/kilocode/model-info-panel-utils.test.ts b/packages/opencode/test/kilocode/model-info-panel-utils.test.ts index 8a07fcb2291..f24f2b3ba85 100644 --- a/packages/opencode/test/kilocode/model-info-panel-utils.test.ts +++ b/packages/opencode/test/kilocode/model-info-panel-utils.test.ts @@ -6,6 +6,11 @@ describe("model info panel price formatting", () => { expect(fmtPrice(0)).toBe("Free") }) + test("fmtPrice returns dash for negative values", () => { + expect(fmtPrice(-1)).toBe("—") + expect(fmtPrice(-1000000)).toBe("—") + }) + test("fmtPrice uses four decimals for very small prices", () => { expect(fmtPrice(0.0095)).toBe("$0.0095/1M") }) From 08f8c5fc5937063f1e01e73526ce224bb09a71fb Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:28:01 +0000 Subject: [PATCH 34/54] fix(ui): hide pricing section for unpriced models Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .changeset/ignore-negative-model-prices.md | 1 + .../session/ui/model/ModelDetailsPanel.kt | 6 +-- .../src/components/shared/ModelPreview.tsx | 9 +++-- .../kilocode/components/model-info-panel.tsx | 37 ++++++++++--------- 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/.changeset/ignore-negative-model-prices.md b/.changeset/ignore-negative-model-prices.md index 629d5fcc02c..66bcf01a6dd 100644 --- a/.changeset/ignore-negative-model-prices.md +++ b/.changeset/ignore-negative-model-prices.md @@ -2,6 +2,7 @@ "@kilocode/kilo-gateway": patch "@kilocode/cli": patch "kilo-code": patch +"@kilocode/kilo-jetbrains": patch --- Ignore negative pricing entries from model catalogs and handle unpriced models gracefully in UI price formatting. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt index eb83cf748e6..b6541f267a6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt @@ -157,14 +157,14 @@ internal class ModelDetailsPanel( return buildList { item.releaseDate?.let { add(KiloBundle.message("model.picker.details.released") to date(it)) } if (!item.free) { - item.cost?.let { cost -> + item.cost?.takeIf { it.input > 0.0 || it.output > 0.0 }?.let { cost -> add(KiloBundle.message("model.picker.details.input") to price(cost.input)) add(KiloBundle.message("model.picker.details.output") to price(cost.output)) add(KiloBundle.message("model.picker.details.cached") to cached(cost.input, cost.cache?.read)) add(KiloBundle.message("model.picker.details.average") to price(average(cost.input, cost.output, cost.cache?.read))) } ?: run { - item.inputPrice?.let { add(KiloBundle.message("model.picker.details.input") to price(it)) } - item.outputPrice?.let { add(KiloBundle.message("model.picker.details.output") to price(it)) } + item.inputPrice?.takeIf { it > 0.0 }?.let { add(KiloBundle.message("model.picker.details.input") to price(it)) } + item.outputPrice?.takeIf { it > 0.0 }?.let { add(KiloBundle.message("model.picker.details.output") to price(it)) } } } ctx?.let { add(KiloBundle.message("model.picker.details.context") to context(it)) } diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx index a88bc752668..163cd5adf30 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx @@ -50,12 +50,13 @@ export const ModelPreview: Component = (props) => { {(model) => { const cost = () => model().cost + const hasPricing = () => cost() && (cost()!.input > 0 || cost()!.output > 0) const bench = () => model().terminalBench const cachedText = () => { - if (!cost()) return "" + if (!cost() || !hasPricing()) return "" return fmtCachedPrice(cost()!) ?? language.t("model.preview.value.notSupported") } - const avg = () => (cost() ? avgPrice(cost()!) : undefined) + const avg = () => (cost() && hasPricing() ? avgPrice(cost()!) : undefined) const freeLabel = () => language.t("model.tag.free") const dataLabel = () => freeDataLabel(language.t("model.tag.free"), language.t("model.tag.dataCollected")) const autoLabel = () => autoSummary(model()) @@ -139,8 +140,8 @@ export const ModelPreview: Component = (props) => { {fmtDate(model().releaseDate!)} - {/* Pricing — hidden for free models */} - + {/* Pricing — hidden for free models or models without fixed pricing */} + {language.t("model.preview.label.input")} {fmtPrice(cost()!.input)} {language.t("model.preview.label.output")} diff --git a/packages/opencode/src/kilocode/components/model-info-panel.tsx b/packages/opencode/src/kilocode/components/model-info-panel.tsx index e7fa1893499..8ed65dda997 100644 --- a/packages/opencode/src/kilocode/components/model-info-panel.tsx +++ b/packages/opencode/src/kilocode/components/model-info-panel.tsx @@ -27,8 +27,9 @@ export function ModelInfoPanel(props: Props) { const maxHeight = createMemo(() => Math.floor(dimensions().height / 2) - 3) const cost = () => m().cost - const cached = () => (cost() ? fmtCachedPrice(cost()) : null) - const avg = () => (cost() ? avgPrice(cost()) : undefined) + const hasPricing = () => m().cost?.input > 0 || m().cost?.output > 0 + const cached = () => (cost() && hasPricing() ? fmtCachedPrice(cost()) : null) + const avg = () => (cost() && hasPricing() ? avgPrice(cost()) : undefined) const caps = () => m().capabilities const inputs = () => caps()?.input const outputs = () => caps()?.output @@ -99,25 +100,27 @@ export function ModelInfoPanel(props: Props) { - - Input - {m() ? fmtPrice(m().cost.input) : "—"} - - - Output - {m() ? fmtPrice(m().cost.output) : "—"} - - + - Cached - {cached()} + Input + {m() ? fmtPrice(m().cost.input) : "—"} - - - Avg Cost - {m() ? fmtPrice(avg()!) : "—"} + Output + {m() ? fmtPrice(m().cost.output) : "—"} + + + Cached + {cached()} + + + + + Avg Cost + {m() ? fmtPrice(avg()!) : "—"} + + Context From fdc46654c5860c01276d7034ba715f779b163bca Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Thu, 6 Aug 2026 18:08:52 +0200 Subject: [PATCH 35/54] fix(memory): quiet transient capture timeouts and stop same-turn retries --- .../fix-memory-model-timeout-warnings.md | 7 + .../kilo-memory/src/capture/operations.ts | 9 +- packages/kilo-memory/src/capture/outcome.ts | 73 +++++++++- packages/kilo-memory/src/effect/capture.ts | 122 +++-------------- packages/kilo-memory/src/effect/service.ts | 23 +--- packages/kilo-memory/src/memory-notice.ts | 24 +--- packages/kilo-memory/src/memory.ts | 59 -------- packages/kilo-memory/src/recall/indexer.ts | 1 - packages/kilo-memory/src/recall/shared.ts | 1 + packages/kilo-memory/src/schema.ts | 6 + packages/kilo-memory/src/storage/audit.ts | 44 +----- packages/kilo-memory/src/storage/state.ts | 11 +- packages/kilo-memory/src/tool.ts | 129 ++---------------- packages/kilo-memory/test/capture.test.ts | 84 ++++++++++-- packages/kilo-memory/test/core.test.ts | 16 +++ .../kilo-memory/test/effect-capture.test.ts | 127 ++++++++++++++++- packages/kilo-vscode/src/KiloProvider.ts | 7 +- .../unit/kilo-provider-memory-events.test.ts | 37 +++++ .../src/kilocode/cli/cmd/tui/memory-events.ts | 3 + .../opencode/src/kilocode/memory/ports.ts | 3 +- .../cli/cmd/tui/memory-command.test.ts | 13 +- .../test/kilocode/memory/memory-ports.test.ts | 48 ++++++- 22 files changed, 446 insertions(+), 401 deletions(-) create mode 100644 .changeset/fix-memory-model-timeout-warnings.md diff --git a/.changeset/fix-memory-model-timeout-warnings.md b/.changeset/fix-memory-model-timeout-warnings.md new file mode 100644 index 00000000000..5a0c8700162 --- /dev/null +++ b/.changeset/fix-memory-model-timeout-warnings.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-memory": patch +"kilo-code": patch +--- + +Reduce noisy memory timeout warnings and retry transient background consolidation failures once. diff --git a/packages/kilo-memory/src/capture/operations.ts b/packages/kilo-memory/src/capture/operations.ts index f4703fdd160..53a1dcf345f 100644 --- a/packages/kilo-memory/src/capture/operations.ts +++ b/packages/kilo-memory/src/capture/operations.ts @@ -126,7 +126,7 @@ export namespace MemoryOperations { const adds = input.ops .filter((item): item is Add => item.action === "add") // Redact rejected text too: this filter runs before the secret one, so a rejected op never - // reaches it, and skips flow into the persistent decisions audit (/memory/show, TUI). + // reaches it, and skips remain visible in the operation result. .filter((op) => { const item = reject(op) if (!item) return true @@ -314,7 +314,7 @@ export namespace MemoryOperations { } } - async function persist(input: { root: string; state: MemorySchema.State; count: number; removed: number }) { + async function persist(input: { root: string; state: MemorySchema.State; count: number }) { const index = await MemoryIndexer.rebuild({ root: input.root, state: input.state }) await MemoryFiles.writeState(input.root, { ...input.state, @@ -323,7 +323,6 @@ export namespace MemoryOperations { lastOperationCount: input.count, }, }) - await MemoryFiles.append(input.root, `apply ops=${input.count} removed=${input.removed}`) return index } @@ -365,9 +364,9 @@ export namespace MemoryOperations { const prepared = prepare({ state, ops: input.ops, max: state.limits.maxLineChars }) const removes = input.ops.filter((item): item is Remove => item.action === "remove") const plan = planOps({ docs, inventory, removes, adds: prepared.adds, now: Date.now() }) - // Commit (IO): write changed documents, then rebuild the index, persist state, and audit. + // Commit (IO): write changed documents, then rebuild the index and persist state. await writeDocs({ root: input.root, plan }) - const index = await persist({ root: input.root, state, count: plan.count, removed: plan.removed }) + const index = await persist({ root: input.root, state, count: plan.count }) return { operationCount: plan.count, added: plan.added, diff --git a/packages/kilo-memory/src/capture/outcome.ts b/packages/kilo-memory/src/capture/outcome.ts index 9ee4be803d7..ce65e172862 100644 --- a/packages/kilo-memory/src/capture/outcome.ts +++ b/packages/kilo-memory/src/capture/outcome.ts @@ -1,9 +1,12 @@ import { MemoryOperations } from "./operations" import { MemoryRedact } from "./redact" import { MemoryShared } from "../recall/shared" +import { TRANSIENT } from "../schema" import type { MemoryFiles } from "../storage/store" import type { CaptureSkip } from "./parse" +export { TRANSIENT } + export type CaptureSourceItem = { id: string text: string @@ -69,14 +72,75 @@ export function errorReason(err: unknown) { return MemoryShared.brief(MemoryRedact.text(parts.join(" ")), 500) } -export function guardReason(input: string) { - const value = input.toLowerCase() - if (/\b(429|rate[_ -]?limit|too many requests)\b/.test(value)) return "rate_limit_guard" - if (/\b(insufficient[_ -]?quota|quota exceeded|exceeded your quota|billing|credits?|credit balance)\b/.test(value)) +const RATE_TEXT = /\b(429|rate[_ -]?limit|too many requests)\b/ +const QUOTA_TEXT = /\b(insufficient[_ -]?quota|quota exceeded|exceeded your quota|billing|credits?|credit balance)\b/ +const TIMEOUT_TEXT = + /\b(timeouterror|etimedout|deadline[ _-]?exceeded|timed out|(connect|headers|body|gateway)[ _-]?time[ -]?out)\b|^timeout$/ +const TIMEOUT_CODES = new Set(["ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]) + +/** Structural timeout detection over an error's name/status/code, its message text, and its nested + * cause/errors chains (cycle-safe). Message matching is limited to `message` — never the serialized + * body/data blobs — so response payloads that merely mention "timeout" cannot classify as transient. */ +function timedOut(item: unknown, seen = new WeakSet()): boolean { + if (!item || typeof item !== "object" || seen.has(item)) return false + seen.add(item) + const err = item as { + name?: unknown + code?: unknown + status?: unknown + statusCode?: unknown + message?: unknown + cause?: unknown + errors?: unknown + } + if (err.name === "TimeoutError") return true + if (err.status === 504 || err.statusCode === 504) return true + if (typeof err.code === "string" && TIMEOUT_CODES.has(err.code)) return true + if (typeof err.message === "string" && TIMEOUT_TEXT.test(err.message.toLowerCase())) return true + if (timedOut(err.cause, seen)) return true + return Array.isArray(err.errors) && err.errors.some((entry) => timedOut(entry, seen)) +} + +function rateGuarded(item: unknown, seen = new WeakSet()): boolean { + if (!item || typeof item !== "object" || seen.has(item)) return false + seen.add(item) + const err = item as { + status?: unknown + statusCode?: unknown + message?: unknown + cause?: unknown + errors?: unknown + } + if (err.status === 429 || err.statusCode === 429) return true + if (typeof err.message === "string" && RATE_TEXT.test(err.message.toLowerCase())) return true + if (rateGuarded(err.cause, seen)) return true + return Array.isArray(err.errors) && err.errors.some((entry) => rateGuarded(entry, seen)) +} + +function quotaGuarded(item: unknown, seen = new WeakSet()): boolean { + if (!item || typeof item !== "object" || seen.has(item)) return false + seen.add(item) + const err = item as { + message?: unknown + cause?: unknown + errors?: unknown + } + if (typeof err.message === "string" && QUOTA_TEXT.test(err.message.toLowerCase())) return true + if (quotaGuarded(err.cause, seen)) return true + return Array.isArray(err.errors) && err.errors.some((entry) => quotaGuarded(entry, seen)) +} + +export function guardReason(input: unknown) { + const value = (typeof input === "string" ? input : errorReason(input)).toLowerCase() + if (typeof input === "string" ? RATE_TEXT.test(value) : rateGuarded(input) || RATE_TEXT.test(value)) + return "rate_limit_guard" + if (typeof input === "string" ? QUOTA_TEXT.test(value) : quotaGuarded(input) || QUOTA_TEXT.test(value)) return "quota_guard" + if (typeof input === "string" ? TIMEOUT_TEXT.test(value) : timedOut(input)) return TRANSIENT return undefined } +/** @deprecated Memory audit persistence was removed. */ export function skipped(input: { sessionID: string; reason: string }): MemoryFiles.Decision { return { kind: "typed", @@ -94,6 +158,7 @@ export function skipped(input: { sessionID: string; reason: string }): MemoryFil } } +/** @deprecated Memory audit persistence was removed. */ export function auditOps(ops: MemoryOperations.Op[]) { return MemoryShared.audit(ops) } diff --git a/packages/kilo-memory/src/effect/capture.ts b/packages/kilo-memory/src/effect/capture.ts index fda14a1a19a..cd0e1308d20 100644 --- a/packages/kilo-memory/src/effect/capture.ts +++ b/packages/kilo-memory/src/effect/capture.ts @@ -1,6 +1,5 @@ import { Cause, Effect } from "effect" import { - auditOps, cap, capturePlan, digestPrompt, @@ -18,9 +17,9 @@ import { parseJson, parseOps, salvageTyped, - skipped, summarize, summarizeDiffs, + TRANSIENT, typedPrompt, usage, verifySkips, @@ -105,11 +104,16 @@ export namespace MemoryCapture { yield* memory.prepare({ root }) const state = yield* memory.state({ root }) const reported = new Set() - const fail = (reason: string) => + const fail = (reason: string, detail?: string) => Effect.promise(async () => { const safe = MemoryRedact.text(reason) if (reported.has(safe)) return reported.add(safe) + if (reason === TRANSIENT) + MemoryLog.warn("memory capture transient failure", { + reason: safe, + detail: MemoryShared.brief(MemoryRedact.text(detail ?? safe), 160), + }) await MemoryEvents.publish({ event: "error", payload: MemoryEvents.status({ @@ -123,7 +127,6 @@ export namespace MemoryCapture { }) const skip = (reason: string, opts?: { idleFlush?: boolean }) => Effect.gen(function* () { - if (state.enabled) yield* memory.decide({ root, decision: skipped({ sessionID: input.sessionID, reason }) }) yield* Effect.promise(() => MemoryEvents.publish({ event: "status", @@ -203,23 +206,6 @@ export namespace MemoryCapture { tokens: 0, fallback: true, }) - yield* memory.decide({ - root, - decision: { - kind: "digest", - trigger: "turn-close", - sessionID: input.sessionID, - result: "fallback", - llm: false, - parsed: false, - fallback: true, - reason: input.reason, - tokens: 0, - operationCount: 1, - skippedCount: 0, - summary: `session digest fallback on ${input.reason ?? "close"}`, - }, - }) } return yield* skip(plan.skipReason, plan.idleFlush ? { idleFlush: true } : undefined) } @@ -232,22 +218,12 @@ export namespace MemoryCapture { const model = digestDue || typedCall - ? yield* Effect.gen(function* () { - const resolution = yield* input.model.resolve({ + ? ( + yield* input.model.resolve({ configured: input.memoryModel, session: view.sessionModel, }) - if (resolution.fallback) { - yield* memory.append({ - root, - text: `memory_model_config reason=${MemoryShared.brief( - MemoryRedact.text(resolution.fallback.reason), - 160, - )} fallback=1`, - }) - } - return resolution.handle - }) + ).handle : undefined const digestEffect = digestDue ? Effect.gen(function* () { @@ -277,9 +253,8 @@ export namespace MemoryCapture { Effect.gen(function* () { if (signal.aborted) return { ok: false as const, reason: "cancelled" } const raw = errorReason(err) - const reason = MemoryRedact.text(guardReason(raw) ?? raw) - yield* fail(reason) - yield* memory.append({ root, text: `digest error=${MemoryShared.brief(reason, 160)} fallback=1` }) + const reason = MemoryRedact.text(guardReason(err) ?? raw) + yield* fail(reason, raw) return { ok: false as const, reason } }), ), @@ -296,14 +271,9 @@ export namespace MemoryCapture { try: () => parseJson(digestSchema, result.result.text), catch: (error) => error, }).pipe( - Effect.catch((err: unknown) => + Effect.catch(() => Effect.gen(function* () { - const reason = MemoryRedact.text(errorReason(err)) yield* fail("digest parse_error") - yield* memory.append({ - root, - text: `digest parse_error=${MemoryShared.brief(reason, 160)} fallback=1`, - }) return undefined }), ), @@ -402,9 +372,8 @@ export namespace MemoryCapture { Effect.gen(function* () { if (signal.aborted) return { ok: false as const, reason: "cancelled" } const raw = errorReason(err) - const reason = MemoryRedact.text(guardReason(raw) ?? raw) - yield* fail(reason) - yield* memory.append({ root, text: `consolidate error=${MemoryShared.brief(reason, 160)}` }) + const reason = MemoryRedact.text(guardReason(err) ?? raw) + yield* fail(reason, raw) return { ok: false as const, reason } }), ), @@ -423,11 +392,9 @@ export namespace MemoryCapture { try: () => salvageTyped(result.result.text), catch: (error) => error, }).pipe( - Effect.catch((err: unknown) => + Effect.catch(() => Effect.gen(function* () { - const reason = MemoryRedact.text(errorReason(err)) yield* fail("consolidate parse_error") - yield* memory.append({ root, text: `consolidate parse_error=${MemoryShared.brief(reason, 160)}` }) return undefined }), ), @@ -475,30 +442,6 @@ export namespace MemoryCapture { fallback: Boolean(digest.reason), }) } - if (digestDue) { - yield* memory.decide({ - root, - decision: { - kind: "digest", - trigger: "turn-close", - sessionID: input.sessionID, - result: digest.reason ? "fallback" : digest.summary ? "saved" : "skipped", - llm: true, - parsed: Boolean(digest.summary && !digest.reason), - fallback: Boolean(digest.reason), - reason: digest.reason, - tokens: digest.tokens, - operationCount: digest.summary ? 1 : 0, - skippedCount: digest.summary ? 0 : 1, - summary: digest.reason - ? `session digest used fallback after ${digest.reason}` - : digest.summary - ? "session digest saved" - : "session digest skipped", - }, - }) - } - // Apply adds only: a same-key add supersedes/updates an existing fact in place. reconcile also // surfaces exact-key auto-removes, but V0 keeps hard removes explicit-only — auto-capture never // deletes memory it merely paraphrased (or wrongly flags), so reconciled.removes is not applied. @@ -506,39 +449,11 @@ export namespace MemoryCapture { const ops = reconciled.ops.slice(0, state.capture.maxOpsPerRun) const project = ops.length > 0 ? yield* memory.apply({ root, ops, trigger: "turn-close", tokens: generated.tokens }) : undefined - // Apply-time skips (content gate + secret, both redacted at creation) surface in the typed audit - // record alongside the model's own declared skips. const applied: CaptureSkip[] = [...generated.skipped, ...(project?.skipped ?? [])] const count = project?.operationCount ?? 0 - if (typedCall) { - yield* memory.decide({ - root, - decision: { - kind: "typed", - trigger: "turn-close", - sessionID: input.sessionID, - result: generated.fallback ? "fallback" : count > 0 ? "saved" : "skipped", - llm: true, - parsed: !generated.fallback, - fallback: generated.fallback, - reason: generated.reason, - tokens: generated.tokens, - operationCount: count, - skippedCount: applied.length, - skipped: applied, - operations: auditOps(ops), - files: [...new Set(ops.flatMap((item) => (item.action === "add" && item.file ? [item.file] : [])))], - summary: generated.fallback - ? `typed consolidation skipped after ${generated.reason ?? "model failure"}` - : count > 0 - ? `typed consolidation saved ${count} ops` - : `typed consolidation skipped ${applied.length} candidates`, - }, - }) - } const tokens = digest.tokens + generated.tokens if (!digest.summary && !typedCall && count === 0) return yield* skip("no_ops") - if ((digestDue || typedCall || count > 0) && (!typedCall || !generated.fallback)) { + if (digestDue || typedCall || count > 0) { yield* memory.commit({ root, now, @@ -546,8 +461,7 @@ export namespace MemoryCapture { tokens, count, digest: Boolean(digest.summary), - typed: typedCall, - skipped: applied, + typed: typedCall && !generated.fallback, }) } const updated = yield* memory.state({ root }) diff --git a/packages/kilo-memory/src/effect/service.ts b/packages/kilo-memory/src/effect/service.ts index 9f705499360..026553a61a3 100644 --- a/packages/kilo-memory/src/effect/service.ts +++ b/packages/kilo-memory/src/effect/service.ts @@ -1,5 +1,4 @@ import { Context, Effect, Layer, Semaphore } from "effect" -import { skipLine, type CaptureSkip } from "../capture/capture" import type { Memory } from "../memory" import type { MemoryOperations } from "../capture/operations" import { MemoryRecall } from "../recall/recall" @@ -104,7 +103,6 @@ type CommitInput = RootInput & { // shared typed-interval clock (lastTypedConsolidationAt); a digest-only commit must leave it untouched so a // digest in one session cannot throttle another session's typed capture. typed: boolean - skipped: CaptureSkip[] cost?: number } @@ -156,10 +154,12 @@ export namespace MemoryService { readonly recent: ( input: RecentInput, ) => Effect.Effect>, Failure> + /** @deprecated Memory audit persistence was removed. */ readonly append: (input: AppendInput) => Effect.Effect readonly index: (input: RootInput) => Effect.Effect readonly commit: (input: CommitInput) => Effect.Effect readonly recordRecall: (input: RecordRecallInput) => Effect.Effect + /** @deprecated Memory audit persistence was removed. */ readonly decide: (input: DecideInput) => Effect.Effect readonly readSource: (input: ReadSourceInput) => Effect.Effect readonly turnLock: (sessionID: SessionID) => Semaphore.Semaphore @@ -200,7 +200,7 @@ export namespace MemoryService { return Object.fromEntries(entries) as Sources }), recent: (input) => bridge(() => MemoryFiles.recentSessions(input.root, input.limit, input.max)), - append: (input) => bridge(() => MemoryFiles.append(input.root, input.text)), + append: () => Effect.void, index: (input) => bridge(async () => { const text = await MemoryFiles.readIndex(input.root) @@ -219,20 +219,11 @@ export namespace MemoryService { lastSessionSavedAt: input.digest ? input.now : state.stats.lastSessionSavedAt, lastConsolidatedMessageID: input.messageID, lastConsolidationCost: input.cost ?? state.stats.lastConsolidationCost, - lastConsolidationTokens: input.tokens, - lastOperationCount: input.count, + lastConsolidationTokens: + input.typed || input.digest ? input.tokens : state.stats.lastConsolidationTokens, + lastOperationCount: input.typed ? input.count : state.stats.lastOperationCount, }, }) - const skip = skipLine(input.skipped) - await MemoryFiles.append( - input.root, - [ - `consolidate trigger=turn-close digest=${input.digest ? 1 : 0} ops=${input.count} tokens=${input.tokens}`, - skip, - ] - .filter(Boolean) - .join(" "), - ) }), ), recordRecall: (input) => @@ -266,7 +257,7 @@ export namespace MemoryService { }), }) }), - decide: (input) => bridge(() => MemoryFiles.decide(input.root, input.decision)), + decide: () => Effect.void, readSource: (input) => bridge(() => MemoryFiles.readSource(input.root, input.file)), // Ref-counted so every acquirer — in-flight or queued behind `withPermits` — shares one // semaphore. Each call must be balanced by exactly one `dropLock`. diff --git a/packages/kilo-memory/src/memory-notice.ts b/packages/kilo-memory/src/memory-notice.ts index f96bca8eec7..983b4613879 100644 --- a/packages/kilo-memory/src/memory-notice.ts +++ b/packages/kilo-memory/src/memory-notice.ts @@ -1,23 +1,12 @@ import { MemoryShared } from "./recall/shared" import type { MemoryOperations } from "./capture/operations" -import { MemoryRedact } from "./capture/redact" -/** Human-facing messages and audit views describing an explicit apply result. */ +/** Human-facing messages describing an explicit apply result. */ export namespace MemoryNotice { export function saved(input: { added: number; removed: number }) { return input.removed > 0 || input.added > 0 } - export function summary(input: { added: number; removed: number; count: number }) { - if (input.added > 0 && input.removed > 0) { - return `explicit memory operation saved ${input.added} and removed ${input.removed}` - } - if (input.added > 0) return `explicit memory operation saved ${input.added} ops` - if (input.removed > 0) return `explicit memory operation removed ${input.removed} entries` - if (input.count > 0) return "explicit memory operation matched no source memory" - return "explicit memory operation had no accepted ops" - } - export function message(input: { ops: MemoryOperations.Op[]; added: number; removed: number; count: number }) { const refs = MemoryShared.refs(input.ops) if (input.added > 0 && input.removed > 0) return `Memory updated · ${input.added} saved, ${input.removed} removed` @@ -25,15 +14,4 @@ export namespace MemoryNotice { if (input.removed > 0) return `Memory updated · ${input.removed} removed` return `Memory unchanged · ${input.count} ops` } - - export function skip(input: MemoryOperations.Rejection[]) { - return input.map((item) => (item.reason === "out_of_scope" ? { reason: item.reason } : item)) - } - - export function ops(input: { ops: MemoryOperations.Op[]; skipped: MemoryOperations.Rejection[] }) { - const blocked = new Set(input.skipped.filter((item) => item.reason === "out_of_scope").map((item) => item.text)) - return MemoryShared.audit( - input.ops.filter((item) => item.action !== "add" || !blocked.has(MemoryRedact.text(item.text))), - ) - } } diff --git a/packages/kilo-memory/src/memory.ts b/packages/kilo-memory/src/memory.ts index 8bd14dbf421..267b92093af 100644 --- a/packages/kilo-memory/src/memory.ts +++ b/packages/kilo-memory/src/memory.ts @@ -8,7 +8,6 @@ import { MemorySchema } from "./schema" import { MemoryShared } from "./recall/shared" import { MemoryToken } from "./recall/token" import { MemorySlug } from "./slug" -import { MemoryRedact } from "./capture/redact" /** Root-bound package facade. External Kilo surfaces should derive root from workspace context first. */ export namespace Memory { @@ -97,7 +96,6 @@ export namespace Memory { const state = await MemoryFiles.readState(input.root) const next = { ...state, enabled: false } await MemoryFiles.writeState(input.root, next) - await MemoryFiles.append(input.root, `disable ${next.scope} source=command`) return { root: input.root, state: next } }) } @@ -124,16 +122,6 @@ export namespace Memory { ...(input.settings.verbose === undefined ? {} : { verbose: input.settings.verbose }), } await MemoryFiles.writeState(input.root, next) - await MemoryFiles.append( - input.root, - [ - `settings ${next.scope}`, - input.settings.autoConsolidate === undefined ? "" : `autoConsolidate=${next.autoConsolidate}`, - input.settings.verbose === undefined ? "" : `verbose=${next.verbose}`, - ] - .filter(Boolean) - .join(" "), - ) return { root: input.root, state: next } }) } @@ -216,24 +204,6 @@ export namespace Memory { } const state = await MemoryFiles.readState(input.root) const ok = MemoryNotice.saved({ added: result.added, removed: result.removed }) - if (trigger === "explicit") { - await MemoryFiles.decide(input.root, { - kind: "typed", - trigger, - sessionID: input.sessionID, - result: ok ? "saved" : "skipped", - llm: false, - parsed: true, - fallback: false, - tokens: input.tokens ?? 0, - operationCount: result.operationCount, - skippedCount: result.skipped.length || (ok ? 0 : 1), - skipped: MemoryNotice.skip(result.skipped), - operations: MemoryNotice.ops({ ops: accepted, skipped: result.skipped }), - files: MemoryShared.files(accepted), - summary: MemoryNotice.summary({ added: result.added, removed: result.removed, count: result.operationCount }), - }) - } return { root: input.root, state, @@ -318,31 +288,6 @@ export namespace Memory { const hits = result?.hits ?? [] const files = [...new Set(hits.map((hit) => hit.source))] const topics = [...new Set(hits.flatMap((hit) => (hit.topics?.length ? hit.topics : [hit.kind])))] - await MemoryFiles.decide(input.root, { - kind: "recall", - trigger: "targeted-recall", - sessionID: input.sessionID, - result: result ? "recalled" : "skipped", - llm: false, - parsed: false, - fallback: false, - reason: result ? undefined : "no_matches", - query: MemoryShared.brief(MemoryRedact.text(input.query), 240), - topics, - files, - tokens: result?.tokens ?? 0, - operationCount: hits.length, - skippedCount: result ? 0 : 1, - summary: result ? `targeted recall matched ${hits.length} memories` : "targeted recall found no matches", - }) - if (result) { - await MemoryFiles.queue(input.root, async () => { - await MemoryFiles.append( - input.root, - `recall session=${input.sessionID ?? ""} hits=${result.hits.length} tokens=${result.tokens} files=${files.join(",")}`, - ) - }) - } return { root: input.root, state, result, hits, files, topics } } @@ -368,10 +313,6 @@ export namespace Memory { }) await MemoryFiles.pruneSessions(input.root, state.limits.maxSessionFiles) const index = await MemoryIndexer.rebuild({ root: input.root, state }) - await MemoryFiles.append( - input.root, - `session digest session=${input.sessionID} tokens=${input.tokens ?? 0} indexTokens=${index.tokens}`, - ) return { root: input.root, state, skipped: false as const, index } }) } diff --git a/packages/kilo-memory/src/recall/indexer.ts b/packages/kilo-memory/src/recall/indexer.ts index e4c219cbb01..1f906d2867b 100644 --- a/packages/kilo-memory/src/recall/indexer.ts +++ b/packages/kilo-memory/src/recall/indexer.ts @@ -226,7 +226,6 @@ export namespace MemoryIndexer { return MemoryFiles.queue(input.root, async () => { const result = await build(input) await MemoryFiles.writeIndex(input.root, result.text) - await MemoryFiles.append(input.root, `regenerate index.kmem bytes=${result.bytes} tokens=${result.tokens}`) return result }) } diff --git a/packages/kilo-memory/src/recall/shared.ts b/packages/kilo-memory/src/recall/shared.ts index b24829b2f46..b38f215e6ac 100644 --- a/packages/kilo-memory/src/recall/shared.ts +++ b/packages/kilo-memory/src/recall/shared.ts @@ -94,6 +94,7 @@ export namespace MemoryShared { ] } + /** @deprecated Memory audit persistence was removed. */ export function audit(ops: MemoryOperations.Op[]) { return ops.map((item) => item.action === "add" diff --git a/packages/kilo-memory/src/schema.ts b/packages/kilo-memory/src/schema.ts index 59436a8c144..0ca36e7616e 100644 --- a/packages/kilo-memory/src/schema.ts +++ b/packages/kilo-memory/src/schema.ts @@ -1,3 +1,9 @@ +/** Wire contract for suppressible background-capture failures: guardReason emits this as the + * `memory.error` reason, and every client surface (TUI toast filter, VS Code provider) matches + * against this same constant rather than a local literal. Lives here (dependency-free module) + * so clients can import it without pulling the capture pipeline into their bundles. */ +export const TRANSIENT = "transient" as const + export namespace MemorySchema { export const VERSION = 1 export const maxStoredDigestSummary = 4_000 diff --git a/packages/kilo-memory/src/storage/audit.ts b/packages/kilo-memory/src/storage/audit.ts index 70c7c42bf3a..58fe9f496b7 100644 --- a/packages/kilo-memory/src/storage/audit.ts +++ b/packages/kilo-memory/src/storage/audit.ts @@ -1,15 +1,5 @@ -import z from "zod" -import { MemoryFs } from "./fs" - +/** Compatibility facade for the removed memory audit log. */ export namespace MemoryAudit { - const Log = z - .object({ - kind: z.literal("log"), - summary: z.string(), - time: z.string().optional(), - }) - .passthrough() - export type Decision = | { kind: "log" @@ -43,18 +33,14 @@ export namespace MemoryAudit { }[] } - function audit(root: string, input: Decision) { - void root - void input - return Promise.resolve() - } - export async function append(root: string, text: string) { - await audit(root, { kind: "log", result: "logged", summary: text }) + void root + void text } export async function decide(root: string, input: Decision) { - await audit(root, input) + void root + void input } export async function readDecisions(root: string) { @@ -62,24 +48,8 @@ export namespace MemoryAudit { return "" } - function record(input: string) { - try { - const data = JSON.parse(input) - const parsed = Log.safeParse(data) - return parsed.success ? parsed.data : undefined - } catch (error) { - if (MemoryFs.parse(error)) return undefined - throw error - } - } - export async function readChanges(root: string) { - const lines = (await readDecisions(root)).split("\n").flatMap((line) => { - const data = record(line) - if (!data) return [] - const time = data.time ?? "" - return [`${time} ${data.summary}`.trim()] - }) - return lines.join("\n") + void root + return "" } } diff --git a/packages/kilo-memory/src/storage/state.ts b/packages/kilo-memory/src/storage/state.ts index bb373e24610..136a83a3e5e 100644 --- a/packages/kilo-memory/src/storage/state.ts +++ b/packages/kilo-memory/src/storage/state.ts @@ -1,6 +1,5 @@ import { readdir, rm } from "fs/promises" import path from "path" -import { MemoryAudit } from "./audit" import { MemoryFs } from "./fs" import { MemoryMarkdown } from "./markdown" import { MemoryPaths } from "./paths" @@ -19,13 +18,10 @@ export namespace MemoryState { "corrections.md": "# Corrective Memory\n\n## Corrections\n", } - async function recover(root: string, file: string, error: unknown) { + async function recover(root: string, file: string) { await MemoryFs.backup(file) const state = MemorySchema.missing() await writeState(root, state) - await MemoryAudit.append(root, `recover state.json error=${MemoryFs.brief(error)}`).catch((err: unknown) => - MemoryFs.warn("failed to audit memory state recovery", { err, root }), - ) return state } @@ -33,14 +29,14 @@ export namespace MemoryState { const file = MemoryPaths.files(root).state const data = await MemoryFs.json(file).catch(async (error: unknown) => { if (MemoryFs.miss(error)) return undefined - if (MemoryFs.parse(error)) return recover(root, file, error) + if (MemoryFs.parse(error)) return recover(root, file) throw error }) if (data === undefined) return MemorySchema.missing() return Promise.resolve() .then(() => MemorySchema.parse(data)) .catch((error: unknown) => { - if (MemoryFs.parse(error)) return recover(root, file, error) + if (MemoryFs.parse(error)) return recover(root, file) throw error }) } @@ -199,7 +195,6 @@ export namespace MemoryState { ? { ...(await readState(root)), enabled: true, autoInject: true } : { ...MemorySchema.create(), enabled: true } await writeState(root, state) - await MemoryAudit.append(root, "enable project source=command") return state } diff --git a/packages/kilo-memory/src/tool.ts b/packages/kilo-memory/src/tool.ts index e87fb85d0f5..cef71179d35 100644 --- a/packages/kilo-memory/src/tool.ts +++ b/packages/kilo-memory/src/tool.ts @@ -4,7 +4,6 @@ import { MemoryError, type MemoryError as Failure } from "./effect/errors" import { MemoryPaths } from "./effect/paths" import { MemoryService } from "./effect/service" import { MemoryRecall } from "./recall/recall" -import { MemoryToken } from "./recall/token" import { MemorySchema } from "./schema" import recallDescription from "./prompts/tool-memory-recall.txt" import saveDescription from "./prompts/tool-memory-save.txt" @@ -115,50 +114,6 @@ export namespace MemoryTool { } } - function audit( - memory: MemoryService.Interface, - input: { - root: string - params: RecallParams - current: string - hits: MemoryRecall.Hit[] - skipped?: string - output: string - }, - ) { - const files = [...new Set(input.hits.map((hit) => hit.source))] - const topics = [...new Set(input.hits.flatMap((hit) => (hit.topics?.length ? hit.topics : [hit.kind])))] - const query = - input.params.query ?? - (input.params.sessionID - ? `sessionID=${input.params.sessionID}` - : input.params.mode === "digest" - ? "recent digests" - : undefined) - return memory.decide({ - root: input.root, - decision: { - kind: "recall", - trigger: "targeted-recall", - sessionID: input.current, - result: input.hits.length ? "recalled" : "skipped", - llm: false, - parsed: false, - fallback: false, - reason: input.skipped, - query, - topics, - files, - tokens: MemoryToken.estimate(input.output), - operationCount: input.hits.length, - skippedCount: input.hits.length ? 0 : 1, - summary: input.hits.length - ? `memory recall returned ${input.hits.length} ${input.params.mode} hits` - : `memory recall found no ${input.params.mode} hits`, - }, - }) - } - function miss(input: { params: RecallParams; current: string }) { const self = input.params.sessionID === input.current if (self && input.params.mode === "digest") { @@ -239,35 +194,6 @@ export namespace MemoryTool { }) } - function catalogAudit( - memory: MemoryService.Interface, - input: { - root: string - current: string - query: string - result: { output: string; count: number; files: string[] } - }, - ) { - return memory.decide({ - root: input.root, - decision: { - kind: "recall", - trigger: "targeted-recall", - sessionID: input.current, - result: input.result.count ? "recalled" : "skipped", - llm: false, - parsed: false, - fallback: false, - query: input.query || "all keys", - files: input.result.files, - tokens: MemoryToken.estimate(input.result.output), - operationCount: input.result.count, - skippedCount: input.result.count ? 0 : 1, - summary: `memory catalog listed ${input.result.count} entries`, - }, - }) - } - function approvalRecall(input: Recall) { return input.ask({ permission: "kilo_memory_recall", @@ -285,7 +211,6 @@ export namespace MemoryTool { return Effect.gen(function* () { const result = yield* catalog(input.memory, { root: live.root, query }) const safe = { ...result, output: block(result.output) } - yield* catalogAudit(input.memory, { root: live.root, current: live.current, query, result: safe }) yield* input.memory.recordRecall({ root: live.root, sessionID: live.current, now: Date.now(), count: result.count }) return { title: `Kilo memory catalog: ${result.count} entr${result.count === 1 ? "y" : "ies"}`, @@ -296,22 +221,13 @@ export namespace MemoryTool { } function recallQuery(input: Recall, live: Live) { - return Effect.gen(function* () { - const output = "Provide a topic query for typed/search memory recall." - yield* audit(input.memory, { - root: live.root, - params: input.params, - current: live.current, - hits: [], - skipped: "missing_query", - output, - }) - return { + return Effect.succeed( + { title: `Kilo memory ${input.params.mode}: no query`, - output, + output: "Provide a topic query for typed/search memory recall.", metadata: { sources: [], count: 0 }, - } satisfies Result - }) + } satisfies Result, + ) } function recallSearch(input: Recall, live: Live, query: string, mode: MemoryRecall.Mode) { @@ -327,15 +243,7 @@ export namespace MemoryTool { limit, }) const hits = result?.hits ?? [] - const self = input.params.sessionID === live.current - const skipped = - input.params.sessionID && input.params.mode === "digest" && hits.length === 0 - ? self - ? "current_session_digest" - : "missing_session_digest" - : undefined const output = hits.length ? result!.block : miss({ params: input.params, current: live.current }) - yield* audit(input.memory, { root: live.root, params: input.params, current: live.current, hits, skipped, output }) yield* input.memory.recordRecall({ root: live.root, sessionID: live.current, now: Date.now(), count: hits.length }) if (hits.length === 0) { @@ -440,29 +348,8 @@ export namespace MemoryTool { return saved(input) } - function skip(input: Base & { params: SaveParams }, root: string) { - return Effect.gen(function* () { - const reason = input.params.reason ?? "out_of_scope" - yield* input.memory.decide({ - root, - decision: { - kind: "typed", - trigger: "explicit", - sessionID: input.sessionID, - result: "skipped", - llm: false, - parsed: true, - fallback: false, - reason, - tokens: 0, - operationCount: 0, - skippedCount: 1, - skipped: [{ reason }], - summary: `explicit memory save skipped: ${reason}`, - }, - }) - return skipped({ reason }) - }) + function skip(input: Base & { params: SaveParams }) { + return Effect.succeed(skipped({ reason: input.params.reason ?? "out_of_scope" })) } function forget(input: Save, root: string) { @@ -497,7 +384,7 @@ export namespace MemoryTool { const state = yield* input.memory.state({ root }) if (!state.enabled) return disabled() if (input.params.action === "forget") return yield* forget(input, root) - if (input.params.action === "skip") return yield* skip(input, root) + if (input.params.action === "skip") return yield* skip(input) return yield* write(input, root) }) } diff --git a/packages/kilo-memory/test/capture.test.ts b/packages/kilo-memory/test/capture.test.ts index a1dcdc609c2..516ed3a65d3 100644 --- a/packages/kilo-memory/test/capture.test.ts +++ b/packages/kilo-memory/test/capture.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" import { - auditOps, capturePlan, duplicateOps, + errorReason, fallbackDigest, guardReason, hasSubstantialDiff, @@ -128,7 +128,7 @@ describe("memory capture parsing", () => { expect(() => salvageTyped(`{"op":"upsert_project_fact","key":"good_one","value":"Keep this fact."}`)).toThrow() }) - test("redacts secrets in salvaged unsupported ops before they reach the audit", () => { + test("redacts secrets in salvaged unsupported ops before they reach callers", () => { const parsed = salvageTyped( `{"operations":[{"op":"not_a_real_op","key":"leak","value":"key is sk-abcdefghijklmnopqrstuvwxyz"}],"skipped":[]}`, ) @@ -169,17 +169,6 @@ describe("memory capture parsing", () => { expect(JSON.stringify(parsed.skipped)).not.toContain(secret.slice(0, 20)) }) - test("redacts remove audit queries before truncating", () => { - const secret = "sk-" + "a".repeat(40) - const query = "x".repeat(100) + secret - const audit = auditOps([{ action: "remove", query }]) - const text = JSON.stringify(audit) - - expect(text).toContain("[redacted]") - expect(text).not.toContain(secret) - expect(text).not.toContain(secret.slice(0, 20)) - }) - test("truncates typed batches beyond the op cap instead of failing", () => { const ops = Array.from( { length: 20 }, @@ -332,6 +321,11 @@ describe("memory capture parsing", () => { input: base, expected: { session: true, digestDue: true, typedCall: true, typedWork: true, skipReason: undefined }, }, + { + name: "expected work: a persisted null typed clock does not throttle capture", + input: { ...base, lastTypedConsolidationAt: null }, + expected: { session: true, digestDue: true, typedCall: true, typedWork: true, skipReason: undefined }, + }, { name: "expected idle flush: completed turn inside interval skips now", input: { ...base, priorTime: 900, lastTypedConsolidationAt: 900 }, @@ -650,6 +644,70 @@ describe("memory capture parsing", () => { ) expect(guardReason("429 too many requests")).toBe("rate_limit_guard") expect(guardReason("billing credits exhausted")).toBe("quota_guard") + expect(guardReason("request timed out")).toBe("transient") + expect(guardReason("deadline exceeded")).toBe("transient") + expect(guardReason("DeadlineExceeded")).toBe("transient") + expect(guardReason('cause={"code":"ETIMEDOUT"}')).toBe("transient") + expect(guardReason("504 Gateway Timeout")).toBe("transient") + expect(guardReason("Connect Timeout Error")).toBe("transient") + expect(guardReason("Headers Timeout Error")).toBe("transient") + expect(guardReason("Body Timeout Error")).toBe("transient") + expect(guardReason("connect_timeout")).toBe("transient") + expect(guardReason('status=400 body={"error":"invalid parameter: timeout"}')).toBeUndefined() + expect(guardReason("set request timeout to 30000")).toBeUndefined() + const timeout = new Error("request aborted") + timeout.name = "TimeoutError" + expect(guardReason(timeout)).toBe("transient") + expect(errorReason(timeout)).toBe("request aborted") + expect(guardReason(Object.assign(new Error("request failed"), { status: 504 }))).toBe("transient") + expect( + guardReason(Object.assign(new Error("request failed"), { cause: { code: "UND_ERR_CONNECT_TIMEOUT" } })), + ).toBe("transient") + expect( + guardReason(Object.assign(new Error("request failed"), { cause: { code: "UND_ERR_HEADERS_TIMEOUT" } })), + ).toBe("transient") + expect(guardReason(Object.assign(new Error("request failed"), { cause: { code: "ETIMEDOUT" } }))).toBe( + "transient", + ) + expect( + guardReason(Object.assign(new Error("failed after 2 attempts"), { errors: [{ statusCode: 504 }] })), + ).toBe("transient") + expect(guardReason(new Error("set request timeout to 30000"))).toBeUndefined() + expect(guardReason(new Error("request timed out"))).toBe("transient") + expect(guardReason(Object.assign(new Error("request failed"), { cause: new Error("connect timeout") }))).toBe( + "transient", + ) + expect( + guardReason( + Object.assign(new Error("failed after 2 attempts"), { + errors: [Object.assign(new Error("rate limit reached"), { status: 429 }), timeout], + }), + ), + ).toBe("rate_limit_guard") + expect( + guardReason( + Object.assign(new Error("failed after 2 attempts"), { + errors: [new Error("insufficient quota available"), timeout], + }), + ), + ).toBe("quota_guard") + expect( + guardReason( + Object.assign(new Error("request failed"), { + cause: Object.assign(new Error("rate limit reached"), { status: 429 }), + }), + ), + ).toBe("rate_limit_guard") + expect( + guardReason( + Object.assign(new Error("timed out"), { + name: "TimeoutError", + cause: new Error("billing credit balance exhausted"), + }), + ), + ).toBe("quota_guard") + expect(guardReason("timeout after 429 too many requests")).toBe("rate_limit_guard") + expect(guardReason("timeout after quota exceeded")).toBe("quota_guard") }) test("redacts common secret token shapes", () => { diff --git a/packages/kilo-memory/test/core.test.ts b/packages/kilo-memory/test/core.test.ts index 04b52a03624..9c029e6e211 100644 --- a/packages/kilo-memory/test/core.test.ts +++ b/packages/kilo-memory/test/core.test.ts @@ -50,6 +50,22 @@ describe("memory core package", () => { }) }) + test("legacy audit compatibility APIs remain no-op", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await MemoryFiles.append(t.root, "provider error with sensitive detail") + await MemoryFiles.decide(t.root, { + kind: "typed", + result: "error", + reason: "provider error with sensitive detail", + }) + + expect(await MemoryFiles.readChanges(t.root)).toBe("") + expect(await MemoryFiles.readDecisions(t.root)).toBe("") + expect(await Bun.file(path.join(t.root, "decisions.jsonl")).exists()).toBe(false) + }) + }) + test("prepare removes legacy decisions once from owned memory roots", async () => { await use(async (t) => { await Memory.enable({ root: t.root }) diff --git a/packages/kilo-memory/test/effect-capture.test.ts b/packages/kilo-memory/test/effect-capture.test.ts index 91e0ec84e04..50a86b0f1f4 100644 --- a/packages/kilo-memory/test/effect-capture.test.ts +++ b/packages/kilo-memory/test/effect-capture.test.ts @@ -7,6 +7,7 @@ import { digestPrompt, typedPrompt } from "../src/capture/capture" import { MemoryCapture } from "../src/effect/capture" import { MemoryEvents } from "../src/effect/events" import { KiloMemory } from "../src/effect/index" +import { MemoryLog } from "../src/effect/log" import type { MemoryPorts } from "../src/effect/ports" import { MemoryService } from "../src/effect/service" import { MemoryTimers } from "../src/effect/timers" @@ -49,11 +50,18 @@ function session(turn: MemoryPorts.TurnView | undefined): MemoryPorts.SessionPor /** Model port that answers digest/typed calls from canned JSON, keyed by system prompt so it is * order-independent (digest and typed run concurrently). */ -function model(input: { digest: string; typed: string; fallback?: string; onRun?: (system: string) => void }): MemoryPorts.ModelPort { +function model(input: { + digest: string + typed: string + fallback?: string + fail?: Error + onRun?: (system: string) => void +}): MemoryPorts.ModelPort { return { resolve: () => Effect.succeed({ handle: {}, ...(input.fallback ? { fallback: { reason: input.fallback } } : {}) }), run: async ({ system }) => { input.onRun?.(system) + if (system === typedPrompt && input.fail) throw input.fail const text = system === digestPrompt ? input.digest : system === typedPrompt ? input.typed : "{}" return { text, usage: USAGE } }, @@ -66,6 +74,7 @@ function run(input: { model: MemoryPorts.ModelPort memoryModel?: string reason?: "completed" | "interrupted" | "error" + bypassInterval?: boolean }) { return Effect.runPromise( MemoryCapture.turn({ @@ -75,6 +84,7 @@ function run(input: { model: input.model, memoryModel: input.memoryModel, reason: input.reason ?? "completed", + bypassInterval: input.bypassInterval, }).pipe(Effect.provideService(MemoryService.Service, MemoryService.make())), ) } @@ -107,6 +117,121 @@ describe("MemoryCapture (fake ports)", () => { } }) + test("typed timeout preserves digest progress without advancing the typed clock", async () => { + const t = await tmp() + const events: MemoryEvents.Status[] = [] + const logs: string[] = [] + try { + await KiloMemory.enable({ root: t.root }) + await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } }) + MemoryLog.setWarn((message, meta) => logs.push(`${message}:${meta?.reason}:${meta?.detail}`)) + MemoryEvents.setSink((input) => { + events.push(input.payload) + }) + + const result = await run({ + root: t.root, + session: session(view()), + model: model({ + digest: '{"topic":"repo setup","summary":"Explored repo setup commands. Next step: verify memory tests."}', + typed: "{}", + fail: new DOMException("memory model timed out", "TimeoutError"), + }), + }) + + expect(result).toMatchObject({ skipped: false, operationCount: 0 }) + const state = await MemoryFiles.readState(t.root) + expect(state.stats.lastTypedConsolidationAt).toBeNull() + expect(state.stats.lastSessionSavedAt).toEqual(expect.any(Number)) + expect(state.stats.lastConsolidatedMessageID).toBe("msg_assistant") + expect(events.find((item) => item.state === "error")?.reason).toBe("transient") + expect(logs).toEqual(["memory capture transient failure:transient:memory model timed out"]) + + const calls: string[] = [] + const retry = await run({ + root: t.root, + session: session(view()), + model: model({ + digest: "{}", + typed: '{"operations":[],"skipped":[]}', + onRun: (system) => calls.push(system), + }), + bypassInterval: true, + }) + expect(retry).toMatchObject({ skipped: true, reason: "no_new_content" }) + expect(calls).toEqual([]) + + const next = await run({ + root: t.root, + session: session( + view({ + assistant: "Use bun install, then run the package tests and typecheck.", + lastAssistantID: "msg_assistant_next", + }), + ), + model: model({ + digest: "{}", + typed: '{"operations":[],"skipped":[]}', + onRun: (system) => calls.push(system), + }), + }) + expect(next).toMatchObject({ skipped: false, operationCount: 0 }) + expect(calls).toEqual([typedPrompt]) + const updated = await MemoryFiles.readState(t.root) + expect(updated.stats.lastTypedConsolidationAt).toEqual(expect.any(Number)) + expect(updated.stats.lastConsolidatedMessageID).toBe("msg_assistant_next") + } finally { + MemoryLog.setWarn(() => {}) + MemoryEvents.setSink(() => {}) + await t.done() + } + }) + + test("fallback commit preserves metrics from the prior successful typed consolidation", async () => { + const t = await tmp() + try { + await KiloMemory.enable({ root: t.root }) + await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } }) + + const first = await run({ + root: t.root, + session: session(view()), + model: model({ + digest: '{"topic":"repo setup","summary":"Explored repo setup."}', + typed: + '{"operations":[{"op":"upsert_environment_fact","section":"Commands","key":"test_cmd","value":"bun test"}],"skipped":[]}', + }), + }) + expect(first).toMatchObject({ skipped: false, operationCount: 1 }) + const initial = await MemoryFiles.readState(t.root) + expect(initial.stats.lastOperationCount).toBe(1) + expect(initial.stats.lastTypedConsolidationAt).toEqual(expect.any(Number)) + + const second = await run({ + root: t.root, + session: session( + view({ + assistant: "Investigated a timeout edge case.", + lastAssistantID: "msg_assistant_timeout", + }), + ), + model: model({ + digest: '{"topic":"investigation","summary":"Investigated timeouts."}', + typed: "{}", + fail: new DOMException("memory model timed out", "TimeoutError"), + }), + bypassInterval: true, + }) + expect(second).toMatchObject({ skipped: false, operationCount: 0 }) + const preserved = await MemoryFiles.readState(t.root) + expect(preserved.stats.lastOperationCount).toBe(1) + expect(preserved.stats.lastTypedConsolidationAt).toBe(initial.stats.lastTypedConsolidationAt) + expect(preserved.stats.lastConsolidatedMessageID).toBe("msg_assistant_timeout") + } finally { + await t.done() + } + }) + test("turn-close skips a secret-like op and applies the rest of the batch", async () => { const t = await tmp() const events: MemoryEvents.Status[] = [] diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 4660904c00a..918b0a0afc3 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1,6 +1,7 @@ import * as path from "path" import { existsSync } from "fs" import * as vscode from "vscode" +import { TRANSIENT as MEMORY_TRANSIENT } from "@kilocode/kilo-memory/schema" import type { KiloClient, Session, @@ -4307,8 +4308,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper for (const sid of trackedByDir) targets.add(sid) if (local && active) targets.add(active) if (targets.size === 0 && local) targets.add(undefined) - const detail = - props.detail && typeof props.detail === "object" + const transient = event.type === "memory.error" && props.reason === MEMORY_TRANSIENT + const detail = transient + ? undefined + : props.detail && typeof props.detail === "object" ? props.detail : event.type === "memory.error" && typeof props.reason === "string" ? { type: "error", message: props.reason, reason: props.reason } diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts index 274cb8cc60d..815ab32f8bd 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts @@ -127,6 +127,43 @@ describe("KiloProvider memory events", () => { expect(calls).toEqual(["/repo", "/repo"]) }) + it("refreshes status without forwarding transient memory errors", async () => { + const calls: string[] = [] + const posts: unknown[] = [] + const client = { + memory: { + status: async (input: { directory: string }) => { + calls.push(input.directory) + return { data: status(input.directory) } + }, + }, + } as unknown as KiloClient + const provider = new KiloProvider( + {} as never, + { + getClient: () => client, + } as never, + ) + const item = provider as unknown as Internals + item.webview = { postMessage: async (message) => posts.push(message) } + item.currentSession = { id: "ses_active" } + item.trackedSessionIds.add("ses_active") + provider.setSessionDirectory("ses_active", "/repo") + + item.handleEvent( + { + type: "memory.error", + properties: { sessionID: "ses_active", reason: "transient" }, + }, + "/repo", + ) + await item.memory.idle() + + expect(posts).not.toContainEqual(expect.objectContaining({ type: "memoryEvent" })) + expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_active" })) + expect(calls).toEqual(["/repo"]) + }) + it("uses the project directory when toggling memory", async () => { const calls: unknown[] = [] const client = { diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts b/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts index c388231b922..293feee20cf 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts @@ -1,3 +1,5 @@ +import { TRANSIENT } from "@kilocode/kilo-memory/schema" + type Event = { on(type: "memory.error", fn: (event: MemoryEvent) => void): void | (() => void) } @@ -22,6 +24,7 @@ export namespace MemoryTuiEvents { }) { const handler = (event: MemoryEvent) => { if (event.properties.sessionID && event.properties.sessionID !== input.sessionID) return + if (event.properties.reason === TRANSIENT) return const detail = event.properties.detail if (!detail || typeof detail !== "object") { input.toast.show({ diff --git a/packages/opencode/src/kilocode/memory/ports.ts b/packages/opencode/src/kilocode/memory/ports.ts index 03d26d54674..9b38ceac89d 100644 --- a/packages/opencode/src/kilocode/memory/ports.ts +++ b/packages/opencode/src/kilocode/memory/ports.ts @@ -191,6 +191,7 @@ async function memoryText(input: { temperature: input.temperature, topP: input.topP, topK: input.topK, + maxRetries: 1, } const work = async () => { if (!openai) return generateText(common) @@ -210,7 +211,7 @@ async function memoryText(input: { const timeout = new Promise((_, reject) => { timer = setTimeout(() => { ctl.abort() - reject(new Error("memory model timed out")) + reject(new DOMException("memory model timed out", "TimeoutError")) }, ms) }) try { diff --git a/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts index 42df5344333..7448077955b 100644 --- a/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts @@ -276,7 +276,7 @@ describe("memory TUI events", () => { expect(handlers).toEqual({ "memory.error": [expect.any(Function)] }) }) - test("keeps generic and detailed errors visible", async () => { + test("suppresses transient errors while keeping generic and detailed errors visible", async () => { const shown: string[] = [] const handlers: Record = {} MemoryTuiEvents.attach({ @@ -298,6 +298,17 @@ describe("memory TUI events", () => { fn({ properties: { sessionID: "ses_tui_memory", reason: "model failed" } }), ), ) + await Promise.all( + (handlers["memory.error"] ?? []).map((fn) => + fn({ + properties: { + sessionID: "ses_tui_memory", + reason: "transient", + detail: { message: "Memory model timed out" }, + }, + }), + ), + ) await Promise.all( (handlers["memory.error"] ?? []).map((fn) => fn({ properties: { sessionID: "ses_tui_memory", detail: { message: "Memory save failed" } } }), diff --git a/packages/opencode/test/kilocode/memory/memory-ports.test.ts b/packages/opencode/test/kilocode/memory/memory-ports.test.ts index 8016c1ab00f..8b39e8e99c0 100644 --- a/packages/opencode/test/kilocode/memory/memory-ports.test.ts +++ b/packages/opencode/test/kilocode/memory/memory-ports.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" +import { APICallError } from "ai" import { Effect } from "effect" import { ModelNotFoundError, type Provider } from "../../../src/provider/provider" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -31,15 +32,22 @@ function mdl(id = mid): Provider.Model { } as unknown as Provider.Model } -function lang(outputs = ["{}"]): LanguageModelV3 { +function lang(outputs: (string | Error)[] = ["{}"], calls?: unknown[], hang?: boolean): LanguageModelV3 { let idx = 0 + const next = () => { + const item = outputs[idx++] ?? outputs.at(-1) ?? "{}" + if (item instanceof Error) throw item + return item + } return { specificationVersion: "v3", provider: "test", modelId: "fake-memory-model", supportedUrls: {}, - doGenerate: async () => { - const text = outputs[idx++] ?? outputs.at(-1) ?? "{}" + doGenerate: async (...args: Parameters) => { + calls?.push(args[0]) + if (hang) return new Promise(() => {}) + const text = next() return { content: [{ type: "text", text }], finishReason: { unified: "stop" }, @@ -57,7 +65,9 @@ function lang(outputs = ["{}"]): LanguageModelV3 { } as unknown as LanguageModelV3 } -function provider(input: { outputs?: string[]; seen?: string[] } = {}): Provider.Interface { +function provider( + input: { outputs?: (string | Error)[]; seen?: string[]; calls?: unknown[]; hang?: boolean } = {}, +): Provider.Interface { const base = mdl() const mem = mdl(ModelV2.ID.make("memory-config-model")) const info = { @@ -78,7 +88,7 @@ function provider(input: { outputs?: string[]; seen?: string[] } = {}): Provider }, getLanguage: (model) => { input.seen?.push(model.id) - return Effect.succeed(lang(input.outputs)) + return Effect.succeed(lang(input.outputs, input.calls, input.hang)) }, closest: () => Effect.succeed({ providerID: pid, modelID: base.id }), getSmallModel: () => Effect.succeed(mem), @@ -280,6 +290,34 @@ describe("memory ports", () => { expect(seen).toEqual(["memory-config-model", "fake-memory-model"]) }) + test("model port retries a transient provider failure once", async () => { + const calls: unknown[] = [] + const err = new APICallError({ + message: "temporarily unavailable", + url: "https://example.com/v1/generate", + requestBodyValues: {}, + statusCode: 503, + responseHeaders: {}, + responseBody: '{"error":"temporarily unavailable"}', + isRetryable: true, + }) + const port = MemoryModel.port({ provider: provider({ outputs: [err, "{}"], calls }) }) + const resolved = await Effect.runPromise(port.resolve({ session: ref })) + + await port.run({ handle: resolved.handle, system: "system", prompt: "prompt", timeoutMs: 30_000 }) + + expect(calls).toHaveLength(2) + }) + + test("model port emits a structured timeout error", async () => { + const port = MemoryModel.port({ provider: provider({ hang: true }) }) + const resolved = await Effect.runPromise(port.resolve({ session: ref })) + + await expect( + port.run({ handle: resolved.handle, system: "system", prompt: "prompt", timeoutMs: 1 }), + ).rejects.toMatchObject({ name: "TimeoutError", message: "memory model timed out" }) + }) + test("model port clears its timeout after successful output", async () => { const set = globalThis.setTimeout const clear = globalThis.clearTimeout From 2e5199a40d8791e99dc304ad722bf6baa707f09c Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:49:48 +0000 Subject: [PATCH 36/54] feat(cli): exclude ChatGPT subscriptions from explicit prompt cache breakpoints --- ...de-chatgpt-from-prompt-cache-breakpoint.md | 5 ++ packages/opencode/src/provider/transform.ts | 19 ++++-- .../opencode/test/provider/transform.test.ts | 64 +++++++++++++++++++ 3 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 .changeset/exclude-chatgpt-from-prompt-cache-breakpoint.md diff --git a/.changeset/exclude-chatgpt-from-prompt-cache-breakpoint.md b/.changeset/exclude-chatgpt-from-prompt-cache-breakpoint.md new file mode 100644 index 00000000000..6362bdd7ff1 --- /dev/null +++ b/.changeset/exclude-chatgpt-from-prompt-cache-breakpoint.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Exclude ChatGPT subscriptions from explicit prompt cache breakpoints. diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index ff534bf107f..7a9659b25d2 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -328,15 +328,22 @@ function normalizeMessages( return msgs } -// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ -function supportsPromptCacheBreakpoint(modelId: string): boolean { - const match = modelId.match(/gpt-(\d+)\.(\d+)/) +// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ (excluding ChatGPT subscriptions) +function isChatGPTSubscription(model: Provider.Model): boolean { + if (model.providerID === "openai" && model.cost?.input === 0 && model.cost?.output === 0) return true + if (typeof model.api.url === "string" && model.api.url.includes("chatgpt.com")) return true + return false +} + +function supportsPromptCacheBreakpoint(model: Provider.Model): boolean { + if (isChatGPTSubscription(model)) return false + const match = model.api.id.match(/gpt-(\d+)\.(\d+)/) if (match) { const major = Number(match[1]) const minor = Number(match[2]) if (major > 5 || (major === 5 && minor >= 6)) return true } - const majorMatch = modelId.match(/gpt-(\d+)/) + const majorMatch = model.api.id.match(/gpt-(\d+)/) if (majorMatch && Number(majorMatch[1]) >= 6) return true return false } @@ -366,7 +373,7 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage cacheControl: { type: "ephemeral" }, }, // kilocode_change start - ...(supportsPromptCacheBreakpoint(model.api.id) + ...(supportsPromptCacheBreakpoint(model) ? { openai: { promptCacheBreakpoint: { mode: "explicit" }, @@ -494,7 +501,7 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re ((model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure" || model.api.npm === "@kilocode/kilo-gateway") && - supportsPromptCacheBreakpoint(model.api.id))) && + supportsPromptCacheBreakpoint(model))) && model.api.npm !== "@ai-sdk/gateway" ) { msgs = applyCaching(msgs, model) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 74ef174ade8..508f6a8cd0b 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3162,6 +3162,70 @@ describe("ProviderTransform.message - cache control on gateway", () => { }, }) }) + + test("openai gpt-5.6 with ChatGPT subscription (zero cost) does not apply promptCacheBreakpoint", () => { + const model = createModel({ + providerID: "openai", + api: { + id: "gpt-5.6", + url: "https://api.openai.com/v1", + npm: "@ai-sdk/openai", + }, + id: "gpt-5.6", + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + }) + const msgs = [ + { + role: "system", + content: "You are a helpful assistant", + }, + { + role: "user", + content: "Hello", + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + }) + + test("openai gpt-5.6 with ChatGPT backend endpoint does not apply promptCacheBreakpoint", () => { + const model = createModel({ + providerID: "openai", + api: { + id: "gpt-5.6", + url: "https://chatgpt.com/backend-api/codex/responses", + npm: "@ai-sdk/openai", + }, + id: "gpt-5.6", + cost: { + input: 0.002, + output: 0.008, + cache: { read: 0.0005, write: 0.002 }, + }, + }) + const msgs = [ + { + role: "system", + content: "You are a helpful assistant", + }, + { + role: "user", + content: "Hello", + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + }) // kilocode_change end }) From 9b820d2f9d153baf0c8e8840d83c9de98b93ab58 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:04:02 +0000 Subject: [PATCH 37/54] refactor(cli): simplify ChatGPT subscription detection to zero-cost heuristic Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/opencode/src/provider/transform.ts | 8 ++--- .../opencode/test/provider/transform.test.ts | 34 +------------------ 2 files changed, 4 insertions(+), 38 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 7a9659b25d2..310c9d0c83c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -329,14 +329,12 @@ function normalizeMessages( } // kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ (excluding ChatGPT subscriptions) -function isChatGPTSubscription(model: Provider.Model): boolean { - if (model.providerID === "openai" && model.cost?.input === 0 && model.cost?.output === 0) return true - if (typeof model.api.url === "string" && model.api.url.includes("chatgpt.com")) return true - return false +function isLikelyChatGPTSubscription(model: Provider.Model): boolean { + return model.providerID === "openai" && model.cost?.input === 0 && model.cost?.output === 0 } function supportsPromptCacheBreakpoint(model: Provider.Model): boolean { - if (isChatGPTSubscription(model)) return false + if (isLikelyChatGPTSubscription(model)) return false const match = model.api.id.match(/gpt-(\d+)\.(\d+)/) if (match) { const major = Number(match[1]) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 508f6a8cd0b..e834ec326eb 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3163,7 +3163,7 @@ describe("ProviderTransform.message - cache control on gateway", () => { }) }) - test("openai gpt-5.6 with ChatGPT subscription (zero cost) does not apply promptCacheBreakpoint", () => { + test("openai gpt-5.6 with ChatGPT subscription (zero cost heuristic) does not apply promptCacheBreakpoint", () => { const model = createModel({ providerID: "openai", api: { @@ -3194,38 +3194,6 @@ describe("ProviderTransform.message - cache control on gateway", () => { expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() }) - - test("openai gpt-5.6 with ChatGPT backend endpoint does not apply promptCacheBreakpoint", () => { - const model = createModel({ - providerID: "openai", - api: { - id: "gpt-5.6", - url: "https://chatgpt.com/backend-api/codex/responses", - npm: "@ai-sdk/openai", - }, - id: "gpt-5.6", - cost: { - input: 0.002, - output: 0.008, - cache: { read: 0.0005, write: 0.002 }, - }, - }) - const msgs = [ - { - role: "system", - content: "You are a helpful assistant", - }, - { - role: "user", - content: "Hello", - }, - ] as any[] - - const result = ProviderTransform.message(msgs, model, {}) as any[] - - expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() - expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() - }) // kilocode_change end }) From 00dc8d0242f787a48e0cf44396f6a743b75b45e0 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Mon, 10 Aug 2026 15:05:35 +0200 Subject: [PATCH 38/54] fix(vscode): keep session errors visible --- .changeset/show-vscode-session-errors.md | 5 +++ .../kilo-vscode/src/kilo-provider-utils.ts | 3 +- .../tests/unit/kilo-provider-utils.test.ts | 25 +++++++++++++ .../tests/unit/session-errors.test.ts | 36 +++++++++++++++++- .../webview-ui/src/context/session-errors.ts | 21 +++++++++++ .../webview-ui/src/context/session.tsx | 37 ++++++++++--------- .../src/types/messages/extension-messages.ts | 1 + .../webview-ui/src/types/messages/sessions.ts | 1 + 8 files changed, 109 insertions(+), 20 deletions(-) create mode 100644 .changeset/show-vscode-session-errors.md diff --git a/.changeset/show-vscode-session-errors.md b/.changeset/show-vscode-session-errors.md new file mode 100644 index 00000000000..5302a13a707 --- /dev/null +++ b/.changeset/show-vscode-session-errors.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep model and provider errors visible in VS Code when chat history refreshes. diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index c4bd2f9f74e..c1284b1d481 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -450,7 +450,7 @@ export type WebviewMessage = | { type: "sessionUpdated"; session: ReturnType } | { type: "sessionDeleted"; sessionID: string } | { type: "messageRemoved"; sessionID: string; messageID: string } - | { type: "sessionError"; sessionID?: string; error?: unknown } + | { type: "sessionError"; eventID: string; sessionID?: string; error?: unknown } | { type: "sandboxStatus" sessionID: string @@ -620,6 +620,7 @@ export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: strin case "session.error": { return { type: "sessionError", + eventID: event.id, sessionID: event.properties.sessionID, error: event.properties.error, } diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index 3a9213d887f..75ed68671e5 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -21,6 +21,7 @@ import type { Event, EventSessionStatus, EventSessionTurnClose, + EventSessionError, EventSandboxStatusChanged, EventPermissionAsked, EventPermissionReplied, @@ -375,6 +376,30 @@ describe("mapSSEEventToWebviewMessage", () => { expect(msg).toEqual({ type: "sessionTurnClosed", sessionID: "sess-1", reason: "interrupted" }) }) + it("maps session errors with their event identity and message", () => { + const event: EventSessionError = { + id: "evt-error", + type: "session.error", + properties: { + sessionID: "sess-1", + error: { + name: "APIError", + data: { + message: "prompt_cache_breakpoint is not supported on this model", + isRetryable: false, + }, + }, + }, + } + + expect(mapSSEEventToWebviewMessage(event, "sess-1")).toEqual({ + type: "sessionError", + eventID: "evt-error", + sessionID: "sess-1", + error: event.properties.error, + }) + }) + it("maps permission.asked to permissionRequest", () => { const event: EventPermissionAsked = { type: "permission.asked", diff --git a/packages/kilo-vscode/tests/unit/session-errors.test.ts b/packages/kilo-vscode/tests/unit/session-errors.test.ts index c24ed0297a8..53eb01d8152 100644 --- a/packages/kilo-vscode/tests/unit/session-errors.test.ts +++ b/packages/kilo-vscode/tests/unit/session-errors.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "bun:test" -import { errorIDs, visibleError } from "../../webview-ui/src/context/session-errors" +import { + errorIDs, + preserveSessionErrors, + visibleError, + withoutResolvedSessionErrors, +} from "../../webview-ui/src/context/session-errors" import type { Message } from "../../webview-ui/src/types/messages" const base = { @@ -45,3 +50,32 @@ describe("visibleError", () => { expect(visibleError(messages, () => false)).toBeUndefined() }) }) + +describe("session error reconciliation", () => { + const error = { + name: "APIError", + data: { message: "prompt_cache_breakpoint is not supported on this model", isRetryable: false }, + } + + it("preserves transient errors across stale history replacements", () => { + const user = { ...base, id: "message_1", role: "user" as const } + const transient = { ...assistant("message_2", error), parentID: user.id, sessionErrorID: "event_1" } + + expect(preserveSessionErrors([user, transient], [user])).toEqual([user, transient]) + }) + + it("replaces a transient error with its persisted assistant message", () => { + const transient = { ...assistant("message_2", error), parentID: "message_1", sessionErrorID: "event_1" } + const persisted = { ...assistant("message_3", error), parentID: "message_1" } + + expect(preserveSessionErrors([transient], [persisted])).toEqual([persisted]) + expect(withoutResolvedSessionErrors([transient], [persisted])).toEqual([]) + }) + + it("deduplicates repeated delivery of the same session error event", () => { + const first = { ...assistant("message_2", error), parentID: "message_1", sessionErrorID: "event_1" } + const repeat = { ...assistant("message_3", error), parentID: "message_1", sessionErrorID: "event_1" } + + expect(withoutResolvedSessionErrors([first], [repeat])).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/context/session-errors.ts b/packages/kilo-vscode/webview-ui/src/context/session-errors.ts index a5fd388726f..7d7f56aa072 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-errors.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-errors.ts @@ -3,6 +3,27 @@ import type { Message } from "../types/messages" type Entry = { id: string; error?: Message["error"] } type Error = NonNullable +function sameError(a: Message, b: Message) { + if (!a.error || !b.error || a.error.name !== b.error.name) return false + if (a.parentID !== b.parentID) return false + return JSON.stringify(a.error.data) === JSON.stringify(b.error.data) +} + +export function withoutResolvedSessionErrors(current: Message[], incoming: Message[]) { + const events = new Set(incoming.map((msg) => msg.sessionErrorID).filter((id): id is string => !!id)) + return current.filter((msg) => { + if (!msg.sessionErrorID) return true + if (events.has(msg.sessionErrorID)) return false + return !incoming.some((next) => !next.sessionErrorID && sameError(msg, next)) + }) +} + +export function preserveSessionErrors(current: Message[], incoming: Message[]) { + const ids = new Set(incoming.map((msg) => msg.id)) + const errors = withoutResolvedSessionErrors(current, incoming).filter((msg) => msg.sessionErrorID && !ids.has(msg.id)) + return [...incoming, ...errors] +} + export function errorIDs(messages: Entry[]) { return messages.filter((msg) => !!msg.error).map((msg) => msg.id) } diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 8831d7edfb0..42fe0c86cd9 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -70,7 +70,7 @@ import { Identifier } from "../utils/id" import { resolveModelSelection } from "./model-selection" import { getAgentModel } from "./session-model-store" import { resolveMessagePrefs } from "./session-preferences" -import { errorIDs } from "./session-errors" +import { errorIDs, preserveSessionErrors, withoutResolvedSessionErrors } from "./session-errors" import { PartStash } from "./part-stash" import { mergeParts, sameParts } from "./session-parts" import { state as todoState } from "./todo-revert" @@ -1175,7 +1175,7 @@ export const SessionProvider: ParentComponent = (props) => { break case "sessionError": { - if (message.error?.name === "MessageAbortedError") break + if (!message.error || message.error.name === "MessageAbortedError") break const sid = message.sessionID ?? currentSessionID() if (!sid) break // Find the last user message in this session to use as parentID @@ -1188,6 +1188,7 @@ export const SessionProvider: ParentComponent = (props) => { createdAt: new Date().toISOString(), parentID: parent?.id, error: message.error, + sessionErrorID: message.eventID, } handleMessageCreated(errorMsg) break @@ -1381,20 +1382,17 @@ export const SessionProvider: ParentComponent = (props) => { } function mergeMessages(current: Message[], incoming: Message[], mode: Exclude) { + const kept = withoutResolvedSessionErrors(current, incoming) if (mode === "reconcile") { - // Tail reconcile: incoming is the authoritative newest-N snapshot. - // Local state may already hold some of those IDs and may also hold - // newer optimistic entries created after the fetch was taken. Merge - // by id (server wins on collision) then sort by createdAt so new - // server messages land in the right position and optimistic tail - // entries stay at the end. + // Merge the authoritative newest-N snapshot by ID, then sort so newer + // optimistic entries stay at the end. const byId = new Map() - for (const msg of current) byId.set(msg.id, msg) + for (const msg of kept) byId.set(msg.id, msg) for (const msg of incoming) byId.set(msg.id, msg) return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) } const seen = new Set() - const source = mode === "prepend" ? [...incoming, ...current] : incoming + const source = mode === "prepend" ? [...incoming, ...kept] : incoming return source.filter((msg) => { if (seen.has(msg.id)) return false seen.add(msg.id) @@ -1418,12 +1416,13 @@ export const SessionProvider: ParentComponent = (props) => { } function withPending(sessionID: string, messages: Message[]) { - const pending = pendingOptimistic.get(sessionID) - if (!pending || pending.size === 0) return messages - const ids = new Set(messages.map((msg) => msg.id)) const current = store.messages[sessionID] ?? [] + const merged = preserveSessionErrors(current, messages) + const pending = pendingOptimistic.get(sessionID) + if (!pending || pending.size === 0) return merged + const ids = new Set(merged.map((msg) => msg.id)) const orphans = current.filter((msg) => pending.has(msg.id) && !ids.has(msg.id)) - return [...messages, ...orphans] + return [...merged, ...orphans] } // Cheap tail check: same ids in the same order and no visible streamed-part @@ -1601,16 +1600,18 @@ export const SessionProvider: ParentComponent = (props) => { const exists = (store.messages[message.sessionID] ?? []).some((msg) => msg.id === message.id) setStore("messages", message.sessionID, (msgs = []) => { + const current = withoutResolvedSessionErrors(msgs, [message]) + if (message.sessionErrorID && current.some((msg) => msg.sessionErrorID === message.sessionErrorID)) return current // Check if message already exists (optimistic or update case). // Since we now use the same messageID for optimistic and server messages, // this naturally handles the optimistic→real transition. - const idx = msgs.findIndex((m) => m.id === message.id) + const idx = current.findIndex((m) => m.id === message.id) if (idx >= 0) { - const updated = [...msgs] - updated[idx] = { ...msgs[idx], ...message } + const updated = [...current] + updated[idx] = { ...current[idx], ...message } return updated } - return [...msgs, message] + return [...current, message] }) patchPage(message.sessionID, { lastMutation: exists ? "update" : "append" }) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index ed8aa570f7f..222b45fee6a 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -148,6 +148,7 @@ export interface SessionTurnClosedMessage { export interface SessionErrorMessage { type: "sessionError" + eventID: string sessionID?: string error?: { name: string; data?: Record } } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts index 05477b126a8..eec557fe1e7 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts @@ -22,6 +22,7 @@ export interface Message { parentID?: string path?: { cwd: string; root: string } error?: { name: string; data?: Record } + sessionErrorID?: string summary?: { title?: string; body?: string; diffs?: unknown[] } | boolean cost?: number tokens?: TokenUsage From 981600921ae695decac7f80449d765cae67cd83d Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Mon, 10 Aug 2026 15:17:17 +0200 Subject: [PATCH 39/54] feat(vscode): improve custom provider dialog layout and model toggles --- ...ustom-provider-edit-screen-improvements.md | 6 + packages/kilo-ui/src/components/dialog.css | 18 +++ .../custom-provider-dialog-validate.test.ts | 16 +++ .../settings/CustomProviderDialog.tsx | 132 ++++++++++++------ .../settings/CustomProviderModelCard.tsx | 72 +++++----- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/fa.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/it.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 2 + 26 files changed, 207 insertions(+), 79 deletions(-) create mode 100644 .changeset/custom-provider-edit-screen-improvements.md diff --git a/.changeset/custom-provider-edit-screen-improvements.md b/.changeset/custom-provider-edit-screen-improvements.md new file mode 100644 index 00000000000..b4b383ff25f --- /dev/null +++ b/.changeset/custom-provider-edit-screen-improvements.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/kilo-ui": patch +--- + +Improve custom provider edit dialog layout, make advanced configuration action prominent, and add bulk toggle buttons for reasoning and image modalities across all models. diff --git a/packages/kilo-ui/src/components/dialog.css b/packages/kilo-ui/src/components/dialog.css index d80f2b0d117..fd9018f9dea 100644 --- a/packages/kilo-ui/src/components/dialog.css +++ b/packages/kilo-ui/src/components/dialog.css @@ -12,6 +12,24 @@ border: 1px solid var(--border-weak-base); } + &[data-size="large"] [data-slot="dialog-container"] { + width: min(calc(100vw - 32px), 800px); + height: min(calc(100vh - 32px), 720px); + } + + &[data-size="large"] [data-slot="dialog-content"] { + max-width: 800px; + } + + &[data-size="x-large"] [data-slot="dialog-container"] { + width: min(calc(100vw - 32px), 980px); + height: min(calc(100vh - 32px), 800px); + } + + &[data-size="x-large"] [data-slot="dialog-content"] { + max-width: 980px; + } + [data-slot="dialog-header"] { font-size: var(--kilo-font-size-14); } diff --git a/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts b/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts index 814b2147e0b..788fa6615ac 100644 --- a/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts +++ b/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts @@ -292,4 +292,20 @@ describe("validateCustomProvider – variant name validation", () => { const saved = out.result!.config.models["model-1"] as Record expect(saved.modalities).toEqual({ input: ["text", "audio", "video", "pdf"], output: ["text", "audio"] }) }) + + it("handles multiple models with reasoning and images toggled", () => { + const form = base() + form.models = [ + { id: "m1", name: "Model 1", reasoning: true, supportsImages: true, modalities: {}, variants: [] }, + { id: "m2", name: "Model 2", reasoning: true, supportsImages: false, modalities: {}, variants: [] }, + ] + const out = validateCustomProvider(args(form)) + expect(out.result).toBeDefined() + const m1 = out.result!.config.models["m1"] as Record + const m2 = out.result!.config.models["m2"] as Record + expect(m1.reasoning).toBe(true) + expect(m1.modalities).toEqual({ input: ["text", "image"] }) + expect(m2.reasoning).toBe(true) + expect(m2.modalities).toBeUndefined() + }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx index c548b06c76d..5d966e9c578 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx @@ -451,6 +451,18 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { setErrors("models", (v) => v.filter((_, i) => i !== index)) } + function toggleAllReasoning() { + const all = form.models.length > 0 && form.models.every((m) => m.reasoning) + const target = !all + form.models.forEach((_, i) => setForm("models", i, "reasoning", target)) + } + + function toggleAllImages() { + const all = form.models.length > 0 && form.models.every((m) => m.supportsImages) + const target = !all + form.models.forEach((_, i) => setForm("models", i, "supportsImages", target)) + } + function addHeader() { setForm("headers", (v) => [...v, { key: "", value: "" }]) setErrors("headers", (v) => [...v, {}]) @@ -515,6 +527,7 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { return ( { style={{ display: "flex", "flex-direction": "column", - gap: "24px", - padding: "0 10px 12px 10px", + gap: "20px", + padding: "0 16px 16px 16px", "overflow-y": "auto", - "max-height": "60vh", + flex: 1, + width: "100%", + "box-sizing": "border-box", }} > -
+
{
-
-
- {language.t("provider.custom.description.prefix")} - { - e.preventDefault() - vscode.postMessage({ - type: "openExternal", - url: "https://kilo.ai/docs/ai-providers#custom-provider", - }) - }} - > - {language.t("provider.custom.description.link")} - - {language.t("provider.custom.description.suffix")} + +
+ - @@ -651,19 +665,49 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { {/* Models */}
-
- - - - +
+
+ + + + +
+
+ + +
{(m, i) => ( diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx index a33984738c3..49338afe000 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx @@ -98,42 +98,44 @@ export function ModelCard(props: ModelCardProps) { />
- {/* Reasoning toggle */} - + {/* Reasoning and Image toggles */} +
+ - + +
{(error) => ( diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 4e8295ad13d..4c5496bc988 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -443,6 +443,8 @@ export const dict = { "provider.custom.models.name.placeholder": "الاسم المعروض", "provider.custom.models.reasoning.label": "الاستدلال", "provider.custom.models.modalities.image": "صورة", + "provider.custom.models.toggleReasoning": "تبديل التفكير للكل", + "provider.custom.models.toggleImages": "تبديل الصور للكل", "provider.custom.models.remove": "إزالة النموذج", "provider.custom.models.add": "إضافة نموذج", "provider.custom.models.fetch.authError": "فشلت المصادقة. تحقق من مفتاح API أعلاه وحاول مرة أخرى.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 8e825534964..f7412b3e7ea 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -454,6 +454,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Nome de Exibição", "provider.custom.models.reasoning.label": "Raciocínio", "provider.custom.models.modalities.image": "Imagem", + "provider.custom.models.toggleReasoning": "Alternar raciocínio para todos", + "provider.custom.models.toggleImages": "Alternar imagem para todos", "provider.custom.models.remove": "Remover modelo", "provider.custom.models.add": "Adicionar modelo", "provider.custom.models.fetch.authError": "Falha na autenticação. Verifique a chave de API acima e tente novamente.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 36817205996..f372413ef25 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -496,6 +496,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Naziv za prikaz", "provider.custom.models.reasoning.label": "Zaključivanje", "provider.custom.models.modalities.image": "Slika", + "provider.custom.models.toggleReasoning": "Uključi/isključi rezonovanje za sve", + "provider.custom.models.toggleImages": "Uključi/isključi slike za sve", "provider.custom.models.remove": "Ukloni model", "provider.custom.models.add": "Dodaj model", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index af7a402f188..2c5df219a56 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -494,6 +494,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Visningsnavn", "provider.custom.models.reasoning.label": "Ræsonnement", "provider.custom.models.modalities.image": "Billede", + "provider.custom.models.toggleReasoning": "Slå ræsonnement til/fra for alle", + "provider.custom.models.toggleImages": "Slå billede til/fra for alle", "provider.custom.models.remove": "Fjern model", "provider.custom.models.add": "Tilføj model", "provider.custom.models.fetch.authError": "Godkendelse mislykkedes. Kontrollér API-nøglen ovenfor, og prøv igen.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 3bd2234ee43..9e83f815f8c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -504,6 +504,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Anzeigename", "provider.custom.models.reasoning.label": "Schlussfolgerung", "provider.custom.models.modalities.image": "Bild", + "provider.custom.models.toggleReasoning": "Schlussfolgerung für alle umschalten", + "provider.custom.models.toggleImages": "Bild für alle umschalten", "provider.custom.models.remove": "Modell entfernen", "provider.custom.models.add": "Modell hinzufügen", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 637305d8f22..cf4a857e1af 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -408,6 +408,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Display Name", "provider.custom.models.reasoning.label": "Reasoning", "provider.custom.models.modalities.image": "Image", + "provider.custom.models.toggleReasoning": "Toggle reasoning for all", + "provider.custom.models.toggleImages": "Toggle image for all", "provider.custom.models.remove": "Remove model", "provider.custom.models.add": "Add model", "provider.custom.models.fetch.authError": "Authentication failed. Check the API key above and try again.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 60816982138..644800a5221 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -497,6 +497,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Nombre para mostrar", "provider.custom.models.reasoning.label": "Razonamiento", "provider.custom.models.modalities.image": "Imagen", + "provider.custom.models.toggleReasoning": "Alternar razonamiento para todos", + "provider.custom.models.toggleImages": "Alternar imagen para todos", "provider.custom.models.remove": "Eliminar modelo", "provider.custom.models.add": "Añadir modelo", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 2a6ac366bb9..3b1530349db 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -409,6 +409,8 @@ export const dict = { "provider.custom.models.name.placeholder": "نام نمایشی", "provider.custom.models.reasoning.label": "استدلال", "provider.custom.models.modalities.image": "تصویر", + "provider.custom.models.toggleReasoning": "تغییر وضعیت استدلال برای همه", + "provider.custom.models.toggleImages": "تغییر وضعیت تصویر برای همه", "provider.custom.models.remove": "حذف مدل", "provider.custom.models.add": "افزودن مدل", "provider.custom.models.fetch.authError": "احراز هویت ناموفق بود. کلید API بالا را بررسی کرده و دوباره امتحان کنید.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index e1957ba7500..5ee7c64be6b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -498,6 +498,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Nom d'affichage", "provider.custom.models.reasoning.label": "Raisonnement", "provider.custom.models.modalities.image": "Image", + "provider.custom.models.toggleReasoning": "Basculer le raisonnement pour tous", + "provider.custom.models.toggleImages": "Basculer l'image pour tous", "provider.custom.models.remove": "Supprimer le modèle", "provider.custom.models.add": "Ajouter un modèle", "provider.custom.models.fetch.authError": "Échec de l'authentification. Vérifiez la clé API ci-dessus et réessayez.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 3f804c4b4dd..833f568ff41 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -318,6 +318,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Nome visualizzato", "provider.custom.models.reasoning.label": "Reasoning", "provider.custom.models.modalities.image": "Immagine", + "provider.custom.models.toggleReasoning": "Attiva/disattiva reasoning per tutti", + "provider.custom.models.toggleImages": "Attiva/disattiva immagine per tutti", "provider.custom.models.remove": "Rimuovi modello", "provider.custom.models.add": "Aggiungi modello", "provider.custom.models.fetch.authError": "Autenticazione non riuscita. Controlla l'API key sopra e riprova.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 36b3bb91cb3..511b5034aa8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -490,6 +490,8 @@ export const dict = { "provider.custom.models.name.placeholder": "表示名", "provider.custom.models.reasoning.label": "推論", "provider.custom.models.modalities.image": "画像", + "provider.custom.models.toggleReasoning": "すべてのモデルの推論を切り替え", + "provider.custom.models.toggleImages": "すべてのモデルの画像を切り替え", "provider.custom.models.remove": "モデルを削除", "provider.custom.models.add": "モデルを追加", "provider.custom.models.fetch.authError": "認証に失敗しました。上記のAPIキーを確認して再試行してください。", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index ed69706294b..aad05fc7ca1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -451,6 +451,8 @@ export const dict = { "provider.custom.models.name.placeholder": "표시 이름", "provider.custom.models.reasoning.label": "추론", "provider.custom.models.modalities.image": "이미지", + "provider.custom.models.toggleReasoning": "모든 모델의 추론 전환", + "provider.custom.models.toggleImages": "모든 모델의 이미지 전환", "provider.custom.models.remove": "모델 제거", "provider.custom.models.add": "모델 추가", "provider.custom.models.fetch.authError": "인증에 실패했습니다. 위의 API 키를 확인하고 다시 시도하세요.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 71ad69b7f7c..5d1a26d21f9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -447,6 +447,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Weergavenaam", "provider.custom.models.reasoning.label": "Redeneren", "provider.custom.models.modalities.image": "Afbeelding", + "provider.custom.models.toggleReasoning": "Redeneren voor alle modellen in-/uitschakelen", + "provider.custom.models.toggleImages": "Afbeelding voor alle modellen in-/uitschakelen", "provider.custom.models.remove": "Model verwijderen", "provider.custom.models.add": "Model toevoegen", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index f2c3e5425e2..eb98954aba1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -457,6 +457,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Visningsnavn", "provider.custom.models.reasoning.label": "Resonnering", "provider.custom.models.modalities.image": "Bilde", + "provider.custom.models.toggleReasoning": "Veksle resonnering for alle", + "provider.custom.models.toggleImages": "Veksle bilde for alle", "provider.custom.models.remove": "Fjern modell", "provider.custom.models.add": "Legg til modell", "provider.custom.models.fetch.authError": "Autentisering mislyktes. Sjekk API-nøkkelen ovenfor og prøv igjen.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index e2048370847..d711cff1355 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -452,6 +452,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Nazwa wyświetlana", "provider.custom.models.reasoning.label": "Rozumowanie", "provider.custom.models.modalities.image": "Obraz", + "provider.custom.models.toggleReasoning": "Przełącz wnioskowanie dla wszystkich", + "provider.custom.models.toggleImages": "Przełącz obraz dla wszystkich", "provider.custom.models.remove": "Usuń model", "provider.custom.models.add": "Dodaj model", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index b022005b489..eae378a40f7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -491,6 +491,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Отображаемое имя", "provider.custom.models.reasoning.label": "Рассуждение", "provider.custom.models.modalities.image": "Изображение", + "provider.custom.models.toggleReasoning": "Переключить рассуждения для всех", + "provider.custom.models.toggleImages": "Переключить изображения для всех", "provider.custom.models.remove": "Удалить модель", "provider.custom.models.add": "Добавить модель", "provider.custom.models.fetch.authError": "Ошибка аутентификации. Проверьте API-ключ выше и попробуйте снова.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 189ae8e5b5e..4dba9385c68 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -488,6 +488,8 @@ export const dict = { "provider.custom.models.name.placeholder": "ชื่อที่แสดง", "provider.custom.models.reasoning.label": "การใช้เหตุผล", "provider.custom.models.modalities.image": "รูปภาพ", + "provider.custom.models.toggleReasoning": "สลับการให้เหตุผลสำหรับทุกโมเดล", + "provider.custom.models.toggleImages": "สลับรูปภาพสำหรับทุกโมเดล", "provider.custom.models.remove": "ลบโมเดล", "provider.custom.models.add": "เพิ่มโมเดล", "provider.custom.models.fetch.authError": "การยืนยันตัวตนล้มเหลว ตรวจสอบคีย์ API ด้านบนแล้วลองอีกครั้ง", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 7c569cf1db9..e8767e36004 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -442,6 +442,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Görünen Ad", "provider.custom.models.reasoning.label": "Akıl Yürütme", "provider.custom.models.modalities.image": "Görüntü", + "provider.custom.models.toggleReasoning": "Tüm modeller için akıl yürütmeyi aç/kapat", + "provider.custom.models.toggleImages": "Tüm modeller için görüntüyü aç/kapat", "provider.custom.models.remove": "Modeli kaldır", "provider.custom.models.add": "Model ekle", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index ea30dd59d27..84672e9f904 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -446,6 +446,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Відображувана назва", "provider.custom.models.reasoning.label": "Міркування", "provider.custom.models.modalities.image": "Зображення", + "provider.custom.models.toggleReasoning": "Перемкнути міркування для всіх", + "provider.custom.models.toggleImages": "Перемкнути зображення для всіх", "provider.custom.models.remove": "Видалити модель", "provider.custom.models.add": "Додати модель", "provider.custom.models.fetch.authError": "Автентифікація не вдалася. Перевірте API-ключ вище і спробуйте ще раз.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 11bbdef8350..28fcf881cd9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -473,6 +473,8 @@ export const dict = { "provider.custom.models.name.placeholder": "显示名称", "provider.custom.models.reasoning.label": "推理", "provider.custom.models.modalities.image": "图片", + "provider.custom.models.toggleReasoning": "切换所有模型的推理", + "provider.custom.models.toggleImages": "切换所有模型的图像", "provider.custom.models.remove": "移除模型", "provider.custom.models.add": "添加模型", "provider.custom.models.fetch.authError": "认证失败。请检查上方的 API 密钥后重试。", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index d679c55c7fe..e6842c06da4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -433,6 +433,8 @@ export const dict = { "provider.custom.models.name.placeholder": "顯示名稱", "provider.custom.models.reasoning.label": "推理", "provider.custom.models.modalities.image": "圖片", + "provider.custom.models.toggleReasoning": "切換所有模型的推理", + "provider.custom.models.toggleImages": "切換所有模型的圖像", "provider.custom.models.remove": "移除模型", "provider.custom.models.add": "新增模型", "provider.custom.models.fetch.authError": "驗證失敗。請檢查上方的 API 金鑰後重試。", From 1b9fedbbe6b16aabcf8cadb0b900ba261e7c65b6 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Mon, 10 Aug 2026 15:17:54 +0200 Subject: [PATCH 40/54] fix(vscode): stabilize session error deduplication --- packages/kilo-vscode/webview-ui/src/context/session.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 42fe0c86cd9..0451e708583 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -1600,8 +1600,8 @@ export const SessionProvider: ParentComponent = (props) => { const exists = (store.messages[message.sessionID] ?? []).some((msg) => msg.id === message.id) setStore("messages", message.sessionID, (msgs = []) => { + if (message.sessionErrorID && msgs.some((msg) => msg.sessionErrorID === message.sessionErrorID)) return msgs const current = withoutResolvedSessionErrors(msgs, [message]) - if (message.sessionErrorID && current.some((msg) => msg.sessionErrorID === message.sessionErrorID)) return current // Check if message already exists (optimistic or update case). // Since we now use the same messageID for optimistic and server messages, // this naturally handles the optimistic→real transition. From 3a9f5a69e78111b59b1a6066f3761260c06a917d Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Mon, 10 Aug 2026 15:23:48 +0200 Subject: [PATCH 41/54] test(vscode): add storybook stories for custom provider dialog --- .../src/stories/settings.stories.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx index 7b510d6a644..34738b5943f 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx @@ -17,6 +17,8 @@ import ModeEditView from "../components/settings/ModeEditView" import McpEditView from "../components/settings/McpEditView" import type { AgentConfig, CommandConfig, Config } from "../types/messages" import IndexingTab from "../components/settings/IndexingTab" +import CustomProviderDialog from "../components/settings/CustomProviderDialog" +import { useDialog } from "@kilocode/kilo-ui/context/dialog" import { SidebarEmptyState } from "../components/chat/SidebarEmptyState" import { WorkStyleContext, type WorkStyleContextValue } from "../context/work-style" @@ -695,3 +697,44 @@ export const IndexingKiloCatalogLoading: Story = { ) }, } + +function CustomProviderDialogMount(props: { existing?: Parameters[0]["existing"] }) { + const dialog = useDialog() + onMount(() => dialog.show(() => )) + return null +} + +export const CustomProviderCreateDialog: Story = { + name: "Custom Provider — create dialog", + render: () => ( + + + + ), +} + +export const CustomProviderEditDialog: Story = { + name: "Custom Provider — edit dialog", + render: () => ( + + + + ), +} From 97d0c97626b49a34f9ef7e67d49e63831dce5316 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Mon, 10 Aug 2026 13:27:08 +0000 Subject: [PATCH 42/54] chore: update kilo-vscode visual regression baselines --- .../settings/custom-provider-create-dialog-chromium-linux.png | 3 +++ .../settings/custom-provider-edit-dialog-chromium-linux.png | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/custom-provider-create-dialog-chromium-linux.png create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/custom-provider-edit-dialog-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/custom-provider-create-dialog-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/custom-provider-create-dialog-chromium-linux.png new file mode 100644 index 00000000000..038643cd74a --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/custom-provider-create-dialog-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72a0a206e978250d41b42f3c0585691a78cbe2635c12d320bcefe61cd938ced5 +size 280 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/custom-provider-edit-dialog-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/custom-provider-edit-dialog-chromium-linux.png new file mode 100644 index 00000000000..038643cd74a --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/custom-provider-edit-dialog-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72a0a206e978250d41b42f3c0585691a78cbe2635c12d320bcefe61cd938ced5 +size 280 From d074e0eb4b7ebea1a6c4473cf2d732b184bbcf37 Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 10 Aug 2026 08:11:27 -0700 Subject: [PATCH 43/54] fix(pr-view): Update mergeMessages to main --- .../kilo-vscode/webview-ui/src/context/session-merge.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/context/session-merge.ts b/packages/kilo-vscode/webview-ui/src/context/session-merge.ts index ac9264cf238..2a59bf0d0ad 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-merge.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-merge.ts @@ -1,7 +1,9 @@ import type { Message, MessageLoadMode, Part } from "../types/messages" import { sameParts } from "./session-parts" +import { withoutResolvedSessionErrors } from "./session-errors" export function mergeMessages(current: Message[], incoming: Message[], mode: Exclude) { + const kept = withoutResolvedSessionErrors(current, incoming) if (mode === "reconcile") { // Tail reconcile: incoming is the authoritative newest-N snapshot. // Local state may already hold some of those IDs and may also hold @@ -10,12 +12,12 @@ export function mergeMessages(current: Message[], incoming: Message[], mode: Exc // server messages land in the right position and optimistic tail // entries stay at the end. const byId = new Map() - for (const msg of current) byId.set(msg.id, msg) + for (const msg of kept) byId.set(msg.id, msg) for (const msg of incoming) byId.set(msg.id, msg) return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) } const seen = new Set() - const source = mode === "prepend" ? [...incoming, ...current] : incoming + const source = mode === "prepend" ? [...incoming, ...kept] : incoming return source.filter((msg) => { if (seen.has(msg.id)) return false seen.add(msg.id) From cc397bb885c78426889e8aa781e3705bad7bc12a Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 10 Aug 2026 09:20:41 -0700 Subject: [PATCH 44/54] feat(pr-view): new icons in kilo-ui for circle x, pull request, and refresh --- packages/kilo-ui/src/components/icon.tsx | 12 ++++++++++++ packages/ui/src/components/icon.tsx | 3 --- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/kilo-ui/src/components/icon.tsx b/packages/kilo-ui/src/components/icon.tsx index 96e8621531e..f58623ade7d 100644 --- a/packages/kilo-ui/src/components/icon.tsx +++ b/packages/kilo-ui/src/components/icon.tsx @@ -2,6 +2,18 @@ import { Icon as Upstream, type IconProps as Props } from "@opencode-ai/ui/icon" import { splitProps } from "solid-js" const icons: Record = { + "circle-x-outline": { + viewBox: "0 0 20 20", + path: ``, + }, + "pull-request": { + viewBox: "0 0 20 20", + path: ``, + }, + refresh: { + viewBox: "0 0 20 20", + path: ``, + }, memory: { viewBox: "0 0 24 24", path: ``, diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 8bd13c7599f..95180b8d65b 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -18,7 +18,6 @@ const icons = { "chevron-grabber-vertical": ``, "chevron-double-right": ``, "circle-x": ``, - "circle-x-outline": ``, // kilocode_change close: ``, "close-small": ``, checklist: ``, @@ -91,7 +90,6 @@ const icons = { download: ``, menu: ``, server: ``, - "pull-request": ``, // kilocode_change branch: ``, edit: ``, history: ``, @@ -108,7 +106,6 @@ const icons = { "star-filled": ``, warning: ``, reset: ``, - refresh: ``, // kilocode_change link: ``, providers: ``, layers: ``, From cf490b9eadad6ca2564e96689c9dcade1ae7e241 Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 10 Aug 2026 09:52:28 -0700 Subject: [PATCH 45/54] fix(pr-view): Update hash for pr status poller to include reviewers state change --- packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index b66c42ba434..4b1098777cd 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -272,7 +272,8 @@ export class PRStatusPoller { files: pr.files, } - const hash = `${worktreeId}:${pr.number}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${reviewers.length}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}` + const reviewersSig = reviewers.map((r) => `${r.login}:${r.state}`).join(",") + const hash = `${worktreeId}:${pr.number}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${reviewersSig}:${pr.body?.length ?? 0}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}` if (this.lastHash.get(worktreeId) === hash) return this.lastHash.set(worktreeId, hash) From 95c361e2e25897a602dad804f2cd499106574cec Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 10 Aug 2026 09:54:58 -0700 Subject: [PATCH 46/54] fix(pr-view): clear error on successful status received --- packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts index af496281966..42370a5e721 100644 --- a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts @@ -133,6 +133,7 @@ function bridgePollerOpts(bridge: PRStatusBridge, host: PRBridgeHost) { } const msg = { type: "agentManager.prStatus", worktreeId: id, pr, error: err } as AgentManagerOutMessage bridge["cache"].set(id, msg) + bridge["lastErrorNotified"] = undefined host.postToWebview(msg) host.updateWorktreePR(id, pr?.number, pr?.url, pr?.state) }, From 1b2ab46479f71a3e4375b763f57d8abe7b48126c Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 10 Aug 2026 10:00:48 -0700 Subject: [PATCH 47/54] fix(pr-view): Adjust link out pattern to use vs code post message --- .../webview-ui/agent-manager/pr/PRChecks.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx index b010d221f71..57be374882f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx @@ -1,9 +1,11 @@ /** @jsxImportSource solid-js */ import { For, Show, createSignal } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" import type { PRStatus } from "../../src/types/messages" import type { PRCheck, CheckStatus } from "./pr-types" import { SectionHeading } from "./SectionHeading" +import { useVSCode } from "../../src/context/vscode" const CHECK: Record = { success: { icon: "circle-check", label: "Passed" }, @@ -14,6 +16,7 @@ const CHECK: Record = { } export function PRChecks(props: { checks: PRStatus["checks"] }) { + const vscode = useVSCode() const [open, setOpen] = createSignal(true) return ( <> @@ -38,9 +41,15 @@ export function PRChecks(props: { checks: PRStatus["checks"] }) { {check.duration} - e.preventDefault()}> - - + + +
)} From a1daaadd182c4c784f362efc505dfad54f81865a Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 10 Aug 2026 10:04:50 -0700 Subject: [PATCH 48/54] feat(pr-view): remove excess markedprovider on pr --- packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx | 5 +---- .../kilo-vscode/webview-ui/src/context/provider-shell.tsx | 2 ++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx index c0241f05120..8c3906262e0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx @@ -2,7 +2,6 @@ import { Component, Show } from "solid-js" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Tooltip } from "@kilocode/kilo-ui/tooltip" -import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" import type { WorktreeState } from "../../src/types/messages" import type { PRStatus } from "../../src/types/messages" import { PRBadge } from "./PRBadge" @@ -23,8 +22,7 @@ interface PRPanelProps { export const PRPanel: Component = (props) => { return ( - -
+
@@ -61,6 +59,5 @@ export const PRPanel: Component = (props) => {
- ) } diff --git a/packages/kilo-vscode/webview-ui/src/context/provider-shell.tsx b/packages/kilo-vscode/webview-ui/src/context/provider-shell.tsx index 48ecd641c7c..d457f5e4f00 100644 --- a/packages/kilo-vscode/webview-ui/src/context/provider-shell.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/provider-shell.tsx @@ -52,6 +52,8 @@ const Root: ParentComponent = (props) => ( + {/* MarkedProvider is required here for all markdown consumers in the tree, + including PRPanel's PRDescription and PRComments components. Do not remove. */} From 7bf1e546eb0bfe1bb03ba2028dea08ccb8373e98 Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 10 Aug 2026 10:09:53 -0700 Subject: [PATCH 49/54] feat(pr-view): close review tab when pr panel opens --- .../kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 2dad2ea0209..622f0fe96f5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -567,7 +567,7 @@ const AgentManagerContent: Component = () => { const togglePRPanel = () => { setHistory(false) - setReviewActive(false) + if (reviewActive()) closeReviewTab() setSidePanel((prev) => (prev === SidePanel.PR ? null : SidePanel.PR)) } From 1afd4e7c2a436b61e8dbbab223078d28cead5d2a Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 10 Aug 2026 10:15:10 -0700 Subject: [PATCH 50/54] format(pr-view): prettier --- .../webview-ui/agent-manager/pr/PRPanel.tsx | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx index 8c3906262e0..ec37c45940a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx @@ -23,41 +23,41 @@ interface PRPanelProps { export const PRPanel: Component = (props) => { return (
-
-
- - {props.pr.title} - #{props.pr.number} -
-
- - - - - - -
+
+
+ + {props.pr.title} + #{props.pr.number}
-
- - - 0}> - - - {(body) => } - 0}> - - - - {(comments) => } - +
+ + + + + +
+
+ + + 0}> + + + {(body) => } + 0}> + + + + {(comments) => } + +
+
) } From b87f4da23943194ed755ac463ff34579e1f39daa Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 10 Aug 2026 10:16:44 -0700 Subject: [PATCH 51/54] feat(pr-view): add title to pr hash --- packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index 4b1098777cd..d71b1200e25 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -273,7 +273,7 @@ export class PRStatusPoller { } const reviewersSig = reviewers.map((r) => `${r.login}:${r.state}`).join(",") - const hash = `${worktreeId}:${pr.number}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${reviewersSig}:${pr.body?.length ?? 0}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}` + const hash = `${worktreeId}:${pr.number}:${pr.title}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${reviewersSig}:${pr.body ?? ""}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}` if (this.lastHash.get(worktreeId) === hash) return this.lastHash.set(worktreeId, hash) From e62e92d368747e6b878c8a628eabcb8e5ed8ddea Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 10 Aug 2026 10:19:03 -0700 Subject: [PATCH 52/54] fix(pr-view): fetch the most recent 20 reviews --- packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index d71b1200e25..743c85a4c4b 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -443,7 +443,7 @@ export class PRStatusPoller { reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login avatarUrl } } } } - reviews(first: 20, states: [APPROVED, CHANGES_REQUESTED, COMMENTED]) { + reviews(last: 20, states: [APPROVED, CHANGES_REQUESTED, COMMENTED]) { nodes { author { login avatarUrl } state } } } From e532519947bb87e1b753212442900c29895076ad Mon Sep 17 00:00:00 2001 From: cmanu Date: Mon, 10 Aug 2026 10:24:14 -0700 Subject: [PATCH 53/54] fix(pr-view): error handling for pr reviewer fetch --- packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index 743c85a4c4b..d94a4511fc2 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -469,7 +469,8 @@ export class PRStatusPoller { (pr?.reviewRequests?.nodes ?? []) as GhReviewRequest[], (pr?.reviews?.nodes ?? []) as GhReview[], ) - } catch { + } catch (err) { + this.options.log("Failed to fetch PR reviewers:", err) return [] } } From dcf9c5a0be9bdd4ccfeeea531ceafb07c74d0286 Mon Sep 17 00:00:00 2001 From: dev_Hakaze Date: Tue, 11 Aug 2026 00:45:45 +0700 Subject: [PATCH 54/54] fix(config): expand MCP header env refs without wiping MCP set (#12554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(config): expand MCP header env refs without wiping MCP set Project kilo.jsonc with {env:…} in MCP headers caused the whole config load to fail since 7.4.5, silently removing every MCP. Leave {env:} literal during untrusted text substitution, expand only remote MCP headers after parse, and drop individual MCPs with blocked env refs instead of the full set. Fixes #12319 * fix(config): ban {file:} during MCP header env expansion Post-parse header expand used trusted substitute without fileScope, so a decoy in-repo {file:} plus any {env:} could read arbitrary paths into remote MCP headers. Expand only {env:} and reject residual {file:}. * fix(config): ban env secret expansion in project MCP headers Untrusted project MCP headers must not read process.env or authEnv. Reject {env:} and {file:} per entry with a warning so sibling MCPs still load. * fix(config): drop global MCP headers on project URL retarget When a project config overrides a same-named global remote MCP with a new URL and does not restate headers, do not inherit expanded secret headers from the global entry. Also fix MCP union narrowing in tests and stop saying "header env expansion failed" when only validating. * fix(config): do not mutate caller's patch in mergeConfig Shallow-copy patch before delete p.mcp so probe-then-write updateProjectConfig paths keep mcp on the original object. * fix(config): secure project MCP header loading * Update packages/opencode/src/config/config.ts Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> * fix(config): secure layered MCP transitions --------- Co-authored-by: arimu1 <19286898+arimu1@users.noreply.github.com> Co-authored-by: Johnny Eric Amancio Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> --- .changeset/fix-mcp-env-header-expansion.md | 5 + packages/opencode/src/config/config.ts | 39 ++- .../opencode/src/kilocode/config/config.ts | 71 +++- .../src/kilocode/config/mcp-headers.ts | 33 ++ .../opencode/src/kilocode/config/overlay.ts | 15 +- .../test/kilocode/config/config.test.ts | 222 +++++++++++++ .../test/kilocode/config/mcp-headers.test.ts | 303 ++++++++++++++++++ 7 files changed, 672 insertions(+), 16 deletions(-) create mode 100644 .changeset/fix-mcp-env-header-expansion.md create mode 100644 packages/opencode/src/kilocode/config/mcp-headers.ts create mode 100644 packages/opencode/test/kilocode/config/mcp-headers.test.ts diff --git a/.changeset/fix-mcp-env-header-expansion.md b/.changeset/fix-mcp-env-header-expansion.md new file mode 100644 index 00000000000..051924f1dfb --- /dev/null +++ b/.changeset/fix-mcp-env-header-expansion.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent project MCP configs from resolving variable-backed headers or inheriting trusted headers when changing endpoints, while preserving unaffected servers. diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 0d5e87b9328..ad043e3826c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -42,6 +42,7 @@ import z from "zod" // kilocode_change - Kilo config compatibility schemas // kilocode_change start import { ZodOverride } from "@opencode-ai/core/effect-zod" import { KilocodeConfig } from "../kilocode/config/config" +import { sanitizeProjectMcpHeaders } from "../kilocode/config/mcp-headers" import { primaryPaths } from "../kilocode/primary-worktree" import { Git } from "@/git" import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins" @@ -66,8 +67,9 @@ function mergeConfig(target: Info, source: Info): Info { return mergeDeep(target, source) as Info } -function mergeConfigConcatArrays(target: Info, source: Info): Info { - const merged = mergeConfig(target, source) +function mergeConfigConcatArrays(target: Info, source: Info, trusted = true): Info { + // kilocode_change + const merged = trusted ? mergeConfig(target, source) : KilocodeConfig.mergeProject(target, source) if (target.instructions && source.instructions) { merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } @@ -310,7 +312,7 @@ const layer = Layer.effect( const loadConfig = Effect.fnUntraced(function* ( text: string, - options: { path: string } | { dir: string; source: string }, + options: { path: string; original?: string } | { dir: string; source: string }, // kilocode_change env?: Record, // kilocode_change start - trusted allows {env:}; fileScope confines untrusted {file:} reads to a root trusted?: boolean, @@ -333,12 +335,13 @@ const layer = Layer.effect( if (!data.$schema) { // kilocode_change start data.$schema = "https://app.kilo.ai/config.json" - const edits = modify(text, ["$schema"], "https://app.kilo.ai/config.json", { + const original = options.original ?? text + const edits = modify(original, ["$schema"], "https://app.kilo.ai/config.json", { formattingOptions: { insertSpaces: true, tabSize: 2 }, getInsertionIndex: () => 0, }) - const updated = applyEdits(text, edits) - if (updated !== text) { + const updated = applyEdits(original, edits) + if (updated !== original) { yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) } // kilocode_change end @@ -351,11 +354,25 @@ const layer = Layer.effect( env?: Record, trusted?: boolean, // kilocode_change fileScope?: ConfigVariable.FileScope, // kilocode_change + configWarnings?: Warning[], // kilocode_change - collect MCP header expansion warnings ) { yield* Effect.logInfo("loading", { path: filepath }) const text = yield* readConfigFile(filepath) if (!text) return {} as Info - return yield* loadConfig(text, { path: filepath }, env, trusted, fileScope) // kilocode_change + // kilocode_change start - remove variable-bearing project MCP headers before generic substitution can read them + const sanitized = + trusted === false ? sanitizeProjectMcpHeaders(ConfigParse.jsonc(text, filepath), filepath) : undefined + const content = sanitized ? (JSON.stringify(sanitized.config) ?? text) : text + if (sanitized && configWarnings) configWarnings.push(...sanitized.warnings) + const data = yield* loadConfig( + content, + { path: filepath, original: text }, + trusted === false ? undefined : env, + trusted, + fileScope, + ) + // kilocode_change end + return data }) let globalStamp = "" // kilocode_change @@ -550,7 +567,7 @@ const layer = Layer.effect( const scope = kind ?? (yield* pluginScopeForSource(source)) const trusted = sourceTrusted ?? scope === "global" const scoped = KilocodeConfig.scopeIndexing(SandboxConfig.scope(next, scope), scope) - result = mergeConfigConcatArrays(result, scoped) + result = mergeConfigConcatArrays(result, scoped, trusted) // kilocode_change if (scoped.agent) configuredAgents = mergeDeep(configuredAgents, scoped.agent) if (next.instructions?.length) { result.instruction_origins = origins(result.instruction_origins, next.instructions, trusted, source) @@ -670,8 +687,8 @@ const layer = Layer.effect( for (const file of yield* ConfigPaths.files(name, ctx.directory, ctx.worktree).pipe(Effect.orDie)) { yield* merge( file, - // kilocode_change - project config is untrusted: {env:} rejected, {file:} confined to projectRoot - yield* loadFile(file, authEnv, false, { root: projectRoot, source: file }).pipe( + // kilocode_change - project config is untrusted: {env:} rejected by substitution; MCP entries with variable-bearing headers dropped pre-substitution, {file:} confined to projectRoot + yield* loadFile(file, authEnv, false, { root: projectRoot, source: file }, warnings).pipe( Effect.catchDefect((err: unknown) => { caughtWarning(warnings, file, err) return Effect.succeed({} as Info) @@ -723,7 +740,7 @@ const layer = Layer.effect( const fileScope = dirTrusted ? undefined : { root: projectRoot, source } yield* merge( source, - yield* loadFile(source, authEnv, dirTrusted, fileScope).pipe( + yield* loadFile(source, authEnv, dirTrusted, fileScope, dirTrusted ? undefined : warnings).pipe( // kilocode_change Effect.catchDefect((err: unknown) => { caughtWarning(warnings, source, err) diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index d47a9a9a2b1..91498443b6f 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -519,13 +519,24 @@ export namespace KilocodeConfig { * 3. Strip null delete sentinels */ export function mergeConfig(existing: Config.Info, patch: Config.Info): Config.Info { + return merge(existing, patch, true) + } + + /** Merge an untrusted project layer without changing generic config merge semantics. */ + export function mergeProject(existing: Config.Info, patch: Config.Info): Config.Info { + return merge(existing, patch, false) + } + + function merge(existing: Config.Info, patch: Config.Info, clean: boolean): Config.Info { const e = { ...existing } as Record - const p = patch as Record + // Shallow-copy patch so MCP extraction (delete p.mcp) never mutates the caller's object. + // Callers may probe with mergeConfig({}, patch) then reuse the same patch for a write. + const p = { ...patch } as Record // Normalize permission scalars before merge const existingPerm = e.permission const patchPerm = p.permission - if (isRecord(existingPerm) && isRecord(patchPerm)) { + if (clean && isRecord(existingPerm) && isRecord(patchPerm)) { const cloned = { ...existingPerm } for (const [key, value] of Object.entries(patchPerm)) { const existing = cloned[key] @@ -536,7 +547,61 @@ export namespace KilocodeConfig { e.permission = cloned } - return stripNulls(mergeDeep(e, p) as Record) as Config.Info + // MCP servers merge by name; project URL retargets must not inherit base headers. + const existingMcp = e.mcp + const patchMcp = p.mcp + if (!isRecord(existingMcp) && !isRecord(patchMcp)) { + return (clean ? stripNulls(mergeDeep(e, p) as Record) : mergeDeep(e, p)) as Config.Info + } + + delete e.mcp + delete p.mcp + const merged = (clean ? stripNulls(mergeDeep(e, p) as Record) : mergeDeep(e, p)) as Config.Info + const baseMcp = isRecord(existingMcp) ? (existingMcp as NonNullable) : undefined + const srcMcp = isRecord(patchMcp) ? (patchMcp as NonNullable) : undefined + if (!srcMcp) { + if (baseMcp) merged.mcp = baseMcp + return merged + } + if (!baseMcp) { + merged.mcp = srcMcp + return merged + } + + const out: NonNullable = { ...baseMcp } + for (const [name, src] of Object.entries(srcMcp)) { + const base = baseMcp[name] + if (!isRecord(src) || !isRecord(base)) { + out[name] = src + continue + } + + const kind = "type" in base && (base.type === "local" || base.type === "remote") ? base.type : undefined + const next = "type" in src && (src.type === "local" || src.type === "remote") ? src.type : undefined + const changed = next !== undefined && next !== kind + const seed = changed + ? { + ...("enabled" in base ? { enabled: base.enabled } : {}), + ...("timeout" in base ? { timeout: base.timeout } : {}), + } + : base + const entry = mergeDeep(seed, src) as (typeof out)[string] + const srcUrl = "url" in src && typeof src.url === "string" ? src.url : undefined + const baseUrl = "url" in base && typeof base.url === "string" ? base.url : undefined + const retargeted = + kind === "remote" && next !== "local" && srcUrl !== undefined && baseUrl !== undefined && srcUrl !== baseUrl + if (!retargeted || !isRecord(entry)) { + out[name] = entry + continue + } + + const { headers: _headers, oauth: _oauth, ...rest } = entry as Record + if ("headers" in src) rest.headers = src.headers + if ("oauth" in src) rest.oauth = src.oauth + out[name] = rest as (typeof out)[string] + } + merged.mcp = out + return merged } // ── Directory check helper ─────────────────────────────────────────── diff --git a/packages/opencode/src/kilocode/config/mcp-headers.ts b/packages/opencode/src/kilocode/config/mcp-headers.ts new file mode 100644 index 00000000000..4fa26621c8b --- /dev/null +++ b/packages/opencode/src/kilocode/config/mcp-headers.ts @@ -0,0 +1,33 @@ +import { isRecord } from "@/util/record" + +export type McpHeaderWarning = { + path: string + message: string +} + +const reference = /\{(?:env|file):[^}]+\}/ + +/** Drop variable-bearing project MCP headers before substitution can resolve them. */ +export function sanitizeProjectMcpHeaders(data: T, source: string): { config: T; warnings: McpHeaderWarning[] } { + if (!isRecord(data) || !isRecord(data.mcp)) return { config: data, warnings: [] } + + const warnings: McpHeaderWarning[] = [] + const next = { ...data.mcp } + + for (const [name, mcp] of Object.entries(data.mcp)) { + if (!isRecord(mcp) || !isRecord(mcp.headers)) continue + const token = Object.entries(mcp.headers) + .flatMap(([key, value]) => [key, value]) + .find((value): value is string => typeof value === "string" && reference.test(value)) + ?.match(reference)?.[0] + if (!token) continue + + delete next[name] + warnings.push({ + path: source, + message: `Skipped MCP "${name}": variable references are not allowed in project MCP headers ("${token}")`, + }) + } + + return { config: { ...data, mcp: next } as T, warnings } +} diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index fce5d9dee02..566dcfafeb6 100644 --- a/packages/opencode/src/kilocode/config/overlay.ts +++ b/packages/opencode/src/kilocode/config/overlay.ts @@ -14,6 +14,7 @@ import { ConfigVariable } from "@/config/variable" import { Filesystem } from "@/util/filesystem" import { isRecord } from "@/util/record" import { KilocodeConfig } from "./config" +import { sanitizeProjectMcpHeaders } from "./mcp-headers" import { KilocodeConfigSources } from "./sources" export namespace KilocodeConfigOverlay { @@ -268,10 +269,20 @@ export namespace KilocodeConfigOverlay { async function loadUnsafe(file: string, fileScope?: ConfigVariable.FileScope): Promise { // kilocode_change end const text = await Bun.file(file).text() - // kilocode_change - overlay reads project config files: {env:} rejected, {file:} confined to fileScope.root - const expanded = await ConfigVariable.substitute({ text, type: "path", path: file, trusted: false, fileScope }) + // kilocode_change start - remove variable-bearing MCP headers before resolving other project file references + const sanitized = sanitizeProjectMcpHeaders(ConfigParse.jsonc(text, file), file) + const content = JSON.stringify(sanitized.config) ?? text + const expanded = await ConfigVariable.substitute({ + text: content, + type: "path", + path: file, + trusted: false, + fileScope, + }) const parsed = ConfigParse.jsonc(expanded, file) if (!isRecord(parsed)) return {} + for (const warning of sanitized.warnings) log.warn(warning.message, { path: warning.path }) + // kilocode_change end return ConfigParse.schema(Config.Info, parsed, file) as Config.Info } diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index 2b2d7eeba0d..6bfe7b6d74e 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -188,6 +188,228 @@ describe("global config updates", () => { }) }) +describe("project MCP trust boundaries", () => { + test("does not inherit global headers when a project changes the remote URL", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear() + await disposeAllInstances() + + try { + await writeConfig(globalTmp.path, { + $schema: "https://app.kilo.ai/config.json", + mcp: { + plain: { + type: "remote", + url: "https://trusted.example.com/plain", + headers: { Authorization: "Bearer global-secret" }, + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + }, + supplied: { + type: "remote", + url: "https://trusted.example.com/supplied", + headers: { Authorization: "Bearer global-secret", "X-Global": "secret" }, + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + }, + unchanged: { + type: "remote", + url: "https://trusted.example.com/unchanged", + headers: { Authorization: "Bearer global-secret" }, + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + }, + }, + }) + await writeConfig(tmp.path, { + mcp: { + plain: { type: "remote", url: "https://project.example.com/plain" }, + supplied: { + type: "remote", + url: "https://project.example.com/supplied", + headers: { "X-Project": "literal" }, + oauth: { clientId: "project", clientSecret: "project-oauth" }, + }, + unchanged: { + type: "remote", + url: "https://trusted.example.com/unchanged", + enabled: false, + }, + }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.mcp?.plain).toEqual({ type: "remote", url: "https://project.example.com/plain" }) + expect(config.mcp?.supplied).toEqual({ + type: "remote", + url: "https://project.example.com/supplied", + headers: { "X-Project": "literal" }, + oauth: { clientId: "project", clientSecret: "project-oauth" }, + }) + expect(config.mcp?.unchanged).toEqual({ + type: "remote", + url: "https://trusted.example.com/unchanged", + headers: { Authorization: "Bearer global-secret" }, + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + enabled: false, + }) + }, + }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("drops file-backed project MCP headers before reading them", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear() + await disposeAllInstances() + + try { + await Filesystem.write(path.join(tmp.path, "secret.txt"), "project secret") + await writeConfig(tmp.path, { + mcp: { + unsafe: { + type: "remote", + url: "https://project.example.com/unsafe", + headers: { Authorization: "Bearer {file:secret.txt}" }, + }, + sibling: { type: "remote", url: "https://project.example.com/sibling" }, + }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const config = await load() + const warnings = await Effect.runPromise( + Config.Service.use((svc) => svc.warnings()).pipe(Effect.scoped, Effect.provide(layer)), + ) + expect(config.mcp?.unsafe).toBeUndefined() + expect(config.mcp?.sibling).toEqual({ type: "remote", url: "https://project.example.com/sibling" }) + expect(JSON.stringify(config)).not.toContain("project secret") + expect(warnings.some((warning) => warning.message.includes('Skipped MCP "unsafe"'))).toBe(true) + }, + }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("drops env-backed project MCP headers without dropping static siblings", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + const prev = Global.Path.config + const secret = process.env.KILO_PROJECT_MCP_SECRET + ;(Global.Path as { config: string }).config = globalTmp.path + process.env.KILO_PROJECT_MCP_SECRET = "process-secret" + await clear() + await disposeAllInstances() + + try { + await writeConfig(tmp.path, { + mcp: { + unsafe: { + type: "remote", + url: "https://project.example.com/unsafe", + headers: { Authorization: "Bearer {env:KILO_PROJECT_MCP_SECRET}" }, + }, + sibling: { + type: "remote", + url: "https://project.example.com/sibling", + headers: { "X-Project": "literal" }, + }, + }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const config = await load() + const warnings = await Effect.runPromise( + Config.Service.use((svc) => svc.warnings()).pipe(Effect.scoped, Effect.provide(layer)), + ) + expect(config.mcp?.unsafe).toBeUndefined() + expect(config.mcp?.sibling).toEqual({ + type: "remote", + url: "https://project.example.com/sibling", + headers: { "X-Project": "literal" }, + }) + expect(JSON.stringify(config)).not.toContain("process-secret") + expect(warnings.some((warning) => warning.message.includes('Skipped MCP "unsafe"'))).toBe(true) + }, + }) + } finally { + if (secret === undefined) delete process.env.KILO_PROJECT_MCP_SECRET + else process.env.KILO_PROJECT_MCP_SECRET = secret + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("does not carry global credentials through remote-local-remote project layers", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear() + await disposeAllInstances() + + try { + await writeConfig(globalTmp.path, { + $schema: "https://app.kilo.ai/config.json", + mcp: { + shared: { + type: "remote", + url: "https://trusted.example.com/mcp", + headers: { Authorization: "Bearer global-secret" }, + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + enabled: false, + timeout: 1_000, + }, + }, + }) + await writeConfig(tmp.path, { + mcp: { shared: { type: "local", command: ["echo", "local"] } }, + }) + await writeConfig(path.join(tmp.path, ".kilo"), { + mcp: { shared: { type: "remote", url: "https://project.example.com/mcp" } }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.mcp?.shared).toEqual({ + type: "remote", + url: "https://project.example.com/mcp", + enabled: false, + timeout: 1_000, + }) + expect(JSON.stringify(config.mcp)).not.toContain("global-secret") + expect(JSON.stringify(config.mcp)).not.toContain("oauth-secret") + expect(JSON.stringify(config.mcp)).not.toContain("command") + }, + }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) +}) + describe("kilocode web search config", () => { test("accepts enabling web search for all providers", () => { const config = Schema.decodeUnknownSync(Config.Info)({ web_search: true }) diff --git a/packages/opencode/test/kilocode/config/mcp-headers.test.ts b/packages/opencode/test/kilocode/config/mcp-headers.test.ts new file mode 100644 index 00000000000..8f0ea9b93b6 --- /dev/null +++ b/packages/opencode/test/kilocode/config/mcp-headers.test.ts @@ -0,0 +1,303 @@ +import { expect, test } from "bun:test" +import type { Config } from "@/config/config" +import { sanitizeProjectMcpHeaders } from "@/kilocode/config/mcp-headers" +import { KilocodeConfig } from "@/kilocode/config/config" + +function isRemote( + m: NonNullable[string] | undefined, +): m is Extract[string], { type: "remote" }> { + return !!m && typeof m === "object" && "type" in m && m.type === "remote" +} + +function remote( + url: string, + headers?: Record, +): Extract[string], { type: "remote" }> { + return { type: "remote", url, ...(headers ? { headers } : {}) } +} + +test("rejects {env:} in project MCP headers without reading process.env or authEnv", async () => { + const prev = process.env.SECRET + process.env.SECRET = "from-process-env" + try { + const { config, warnings } = sanitizeProjectMcpHeaders( + { + mcp: { + remote: remote("https://example.com/mcp", { Authorization: "Bearer {env:SECRET}" }), + }, + }, + "kilo.jsonc", + ) + + expect(config.mcp?.remote).toBeUndefined() + expect(warnings).toHaveLength(1) + expect(warnings[0]?.message).toContain('Skipped MCP "remote"') + expect(warnings[0]?.message).toContain("{env:SECRET}") + expect(warnings[0]?.message).not.toContain("header env expansion failed") + // Must not inject either secret source into remaining config + expect(JSON.stringify(config)).not.toContain("from-process-env") + expect(JSON.stringify(config)).not.toContain("from-auth-env") + } finally { + if (prev === undefined) delete process.env.SECRET + else process.env.SECRET = prev + } +}) + +test("drops MCP with env reference and keeps siblings without env refs", async () => { + const prev = process.env.SAFE_KEY + process.env.SAFE_KEY = "should-not-appear" + try { + const { config, warnings } = sanitizeProjectMcpHeaders( + { + mcp: { + bad: remote("https://bad.example.com/mcp", { Authorization: "{env:KILO_SERVER_PASSWORD}" }), + good: remote("https://good.example.com/mcp", { "API-KEY": "static-literal" }), + }, + }, + "kilo.jsonc", + ) + + expect(config.mcp?.bad).toBeUndefined() + const good = config.mcp?.good + expect(isRemote(good) ? good.headers?.["API-KEY"] : undefined).toBe("static-literal") + expect(isRemote(good) ? good.url : undefined).toBe("https://good.example.com/mcp") + expect(warnings).toHaveLength(1) + expect(warnings[0]?.message).toContain('Skipped MCP "bad"') + expect(JSON.stringify(config)).not.toContain("should-not-appear") + expect(JSON.stringify(config)).not.toContain("from-auth-env") + } finally { + if (prev === undefined) delete process.env.SAFE_KEY + else process.env.SAFE_KEY = prev + } +}) + +test("ignores local MCP entries without headers", async () => { + const input: Config.Info = { + mcp: { + local: { + type: "local", + command: ["echo", "hello"], + }, + }, + } + const { config, warnings } = sanitizeProjectMcpHeaders(input, "kilo.jsonc") + expect(config).toEqual(input) + expect(warnings).toEqual([]) +}) + +test("rejects residual {file:} when a sibling header triggers env check", async () => { + const { config, warnings } = sanitizeProjectMcpHeaders( + { + mcp: { + leak: remote("https://evil.example.com/mcp", { + "X-Trigger": "{env:SAFE_KEY}", + Authorization: "{file:payload.txt}", + }), + keep: remote("https://good.example.com/mcp", { "API-KEY": "static-ok" }), + }, + }, + "kilo.jsonc", + ) + + expect(config.mcp?.leak).toBeUndefined() + const keep = config.mcp?.keep + expect(isRemote(keep) ? keep.headers?.["API-KEY"] : undefined).toBe("static-ok") + expect(warnings).toHaveLength(1) + expect(warnings[0]?.message).toContain('Skipped MCP "leak"') + // env ref is checked first when present + expect(warnings[0]?.message).toMatch(/\{env:SAFE_KEY\}|\{file:payload\.txt\}/) + expect(warnings[0]?.message).not.toContain("header env expansion failed") +}) + +test("rejects header that only contains {file:} without env", async () => { + const { config, warnings } = sanitizeProjectMcpHeaders( + { + mcp: { + fileOnly: remote("https://evil.example.com/mcp", { Authorization: "{file:payload.txt}" }), + keep: remote("https://good.example.com/mcp", { "API-KEY": "literal" }), + }, + }, + "kilo.jsonc", + ) + + expect(config.mcp?.fileOnly).toBeUndefined() + const keep = config.mcp?.keep + expect(isRemote(keep) ? keep.headers?.["API-KEY"] : undefined).toBe("literal") + expect(warnings).toHaveLength(1) + expect(warnings[0]?.message).toContain("{file:payload.txt}") + expect(warnings[0]?.message).not.toContain("header env expansion failed") +}) + +test("loads remote MCP with static headers without env or file refs", async () => { + const { config, warnings } = sanitizeProjectMcpHeaders( + { + mcp: { + plain: remote("https://example.com/mcp", { Authorization: "Bearer static-token" }), + }, + }, + "kilo.jsonc", + ) + + expect(warnings).toEqual([]) + const plain = config.mcp?.plain + expect(isRemote(plain) ? plain.headers?.Authorization : undefined).toBe("Bearer static-token") + expect(JSON.stringify(config)).not.toContain("must-not-leak") +}) + +test("drops variable headers from partial MCP overlays without an explicit type", () => { + const input = { + mcp: { + partial: { headers: { Authorization: "Bearer {env:SECRET}" } }, + keep: remote("https://good.example.com/mcp"), + }, + } as unknown as Config.Info + + const { config, warnings } = sanitizeProjectMcpHeaders(input, "kilo.jsonc") + + expect(config.mcp?.partial).toBeUndefined() + expect(config.mcp?.keep).toEqual(remote("https://good.example.com/mcp")) + expect(warnings[0]?.message).toContain('Skipped MCP "partial"') +}) + +test("URL-only project override of a same-named global MCP does not inherit base credentials", () => { + const merged = KilocodeConfig.mergeProject( + { + mcp: { + shared: { + ...remote("https://trusted.example.com/mcp", { Authorization: "Bearer global-secret" }), + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + }, + }, + }, + { + mcp: { + shared: remote("https://untrusted.example.com/mcp"), + }, + }, + ) + const shared = merged.mcp?.shared + expect(isRemote(shared) ? shared.url : undefined).toBe("https://untrusted.example.com/mcp") + expect(isRemote(shared) ? shared.headers : undefined).toBeUndefined() + expect(isRemote(shared) ? shared.oauth : undefined).toBeUndefined() + expect(JSON.stringify(merged.mcp)).not.toContain("global-secret") + expect(JSON.stringify(merged.mcp)).not.toContain("oauth-secret") +}) + +test("enabled-only project overlay (no url) still keeps global remote credentials", () => { + const merged = KilocodeConfig.mergeProject( + { + mcp: { + shared: { + ...remote("https://trusted.example.com/mcp", { Authorization: "Bearer global-secret" }), + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + }, + }, + }, + { + mcp: { + // Partial disable without restating url — must not strip inherited headers. + shared: { enabled: false } as NonNullable[string], + }, + }, + ) + const shared = merged.mcp?.shared + expect(shared && typeof shared === "object" && "enabled" in shared ? shared.enabled : undefined).toBe(false) + expect(isRemote(shared) ? shared.url : undefined).toBe("https://trusted.example.com/mcp") + expect(isRemote(shared) ? shared.headers?.Authorization : undefined).toBe("Bearer global-secret") + expect(isRemote(shared) && typeof shared.oauth === "object" ? shared.oauth.clientSecret : undefined).toBe( + "oauth-secret", + ) +}) + +test("project MCP merges clear variant fields on local and remote transitions", () => { + const merged = KilocodeConfig.mergeProject( + { + mcp: { + local: { + ...remote("https://trusted.example.com/mcp", { Authorization: "Bearer global-secret" }), + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + enabled: false, + timeout: 1_000, + }, + remote: { + type: "local", + command: ["echo", "old"], + cwd: "/tmp/old", + environment: { LOCAL_SECRET: "local-secret" }, + enabled: false, + timeout: 1_000, + }, + }, + }, + { + mcp: { + local: { type: "local", command: ["echo", "new"] }, + remote: remote("https://project.example.com/mcp"), + }, + }, + ) + + expect(merged.mcp?.local).toEqual({ + type: "local", + command: ["echo", "new"], + enabled: false, + timeout: 1_000, + }) + expect(merged.mcp?.remote).toEqual({ + type: "remote", + url: "https://project.example.com/mcp", + enabled: false, + timeout: 1_000, + }) + expect(JSON.stringify(merged.mcp)).not.toContain("secret") + expect(JSON.stringify(merged.mcp)).not.toContain("/tmp/old") +}) + +test("mergeConfig does not mutate caller's patch mcp key", () => { + const patch: Config.Info = { + model: "test-model", + mcp: { + x: remote("https://a.example.com/mcp"), + }, + } + const merged = KilocodeConfig.mergeConfig({}, patch) + expect(isRemote(merged.mcp?.x) ? merged.mcp?.x.url : undefined).toBe("https://a.example.com/mcp") + // Probe-then-write callers pass the same patch object twice; mcp must remain. + expect("mcp" in patch).toBe(true) + expect(isRemote(patch.mcp?.x) ? patch.mcp?.x.url : undefined).toBe("https://a.example.com/mcp") + expect(patch.model).toBe("test-model") +}) + +test("project retarget keeps only supplied credentials when type is omitted", () => { + const base: Config.Info = { + mcp: { + shared: { + ...remote("https://trusted.example.com/mcp", { + Authorization: "Bearer global-secret", + "X-Global": "secret", + }), + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + }, + }, + } + const patch = { + mcp: { + shared: { + url: "https://project.example.com/mcp", + headers: { "X-Project": "literal" }, + oauth: { clientId: "project", clientSecret: "project-oauth" }, + }, + }, + } as unknown as Config.Info + + const merged = KilocodeConfig.mergeProject(base, patch) + + expect(merged.mcp?.shared).toEqual({ + type: "remote", + url: "https://project.example.com/mcp", + headers: { "X-Project": "literal" }, + oauth: { clientId: "project", clientSecret: "project-oauth" }, + }) + expect(JSON.stringify(merged)).not.toContain("global-secret") + expect(JSON.stringify(merged)).not.toContain("oauth-secret") +})