feat(vscode): add always-visible diff badge to sidebar (#7795)

Add a live worktree diff stats badge to the sidebar action row that
shows current file changes (+additions -deletions). The badge uses
GitStatsPoller for consistent polling with the Agent Manager, pauses
when the sidebar is hidden, and collapses to an icon with a dot
indicator when the sidebar is narrow.
This commit is contained in:
Marius
2026-03-27 17:18:16 +01:00
committed by GitHub
parent a9b1612533
commit b91627e1ff
6 changed files with 128 additions and 18 deletions
+44
View File
@@ -32,6 +32,9 @@ import {
resolveWorkspaceDirectory,
type SessionRefreshContext,
} from "./kilo-provider-utils"
import { GitOps } from "./agent-manager/GitOps"
import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller"
import { getWorkspaceRoot } from "./review-utils"
import { MarketplaceService } from "./services/marketplace"
import { resolveProjectDirectory } from "./project-directory"
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
@@ -147,6 +150,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private chatAutocomplete: ChatTextAreaAutocomplete | null = null
private projectDirectory: string | null | undefined
private slimEditMetadata = true
/** Worktree diff stats poller for the sidebar badge — reuses GitStatsPoller (local stats only) */
private statsPoller: GitStatsPoller | null = null
private cachedStats: unknown = null
/** Optional interceptor called before the standard message handler.
* Return null to consume the message, or return a (possibly transformed) message. */
@@ -266,6 +272,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
data: profileData,
})
// Re-send cached worktree stats so the badge renders immediately after webview reload.
if (this.cachedStats) this.postMessage(this.cachedStats)
// Seed session status map so the Settings panel knows about already-running sessions.
// Must run after webview is ready (postMessage is a no-op before that).
void this.seedSessionStatusMap()
@@ -303,6 +312,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Handle messages from webview (shared handler)
this.setupWebviewMessageHandler(webviewView.webview)
// Pause stats polling when sidebar is hidden, resume when visible
webviewView.onDidChangeVisibility(() => {
this.statsPoller?.setEnabled(webviewView.visible)
})
// Initialize connection to CLI backend
this.initializeConnection()
}
@@ -1004,6 +1018,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
])
this.sendNotificationSettings()
// Start polling worktree diff stats for the sidebar badge
this.startStatsPolling()
console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully")
} catch (error) {
console.error("[Kilo New] KiloProvider: ❌ Failed to initialize connection:", error)
@@ -2666,11 +2683,38 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return this.marketplace
}
// ── Worktree stats polling (sidebar diff badge) ──────────────────
private startStatsPolling(): void {
this.statsPoller?.stop()
const git = new GitOps({ log: () => {} })
this.statsPoller = new GitStatsPoller({
getWorktrees: () => [],
getWorkspaceRoot: () => getWorkspaceRoot(),
getClient: () => this.connectionService.getClient(),
git,
onStats: () => {},
onLocalStats: (stats: LocalStats) => {
const msg = {
type: "worktreeStatsLoaded" as const,
files: stats.files,
additions: stats.additions,
deletions: stats.deletions,
}
this.cachedStats = msg
this.postMessage(msg)
},
log: () => {},
})
this.statsPoller.setEnabled(true)
}
/**
* Dispose of the provider and clean up subscriptions.
* Does NOT kill the server — that's the connection service's job.
*/
dispose(): void {
this.statsPoller?.stop()
this.unsubscribeEvent?.()
this.unsubscribeState?.()
this.unsubscribeNotificationDismiss?.()
@@ -183,17 +183,30 @@ export const ChatView: Component<ChatViewProps> = (props) => {
</Button>
</Tooltip>
</Show>
<Show when={isSidebar() && session.summary()?.files}>
<Tooltip value="View file changes" placement="top" class="session-diff-wrapper">
<Show when={isSidebar()}>
<Tooltip
value={
session.worktreeStats()?.files
? `${session.worktreeStats()!.files} file${session.worktreeStats()!.files > 1 ? "s" : ""} changed · +${session.worktreeStats()!.additions} -${session.worktreeStats()!.deletions}`
: "No file changes"
}
placement="top"
class="session-diff-wrapper"
>
<button
class="session-diff-badge"
classList={{
"session-diff-badge--empty": !session.worktreeStats()?.files,
"session-diff-badge--has-changes": !!session.worktreeStats()?.files,
}}
onClick={() => vscode.postMessage({ type: "openChanges" })}
aria-label={language.t("command.session.show.changes")}
>
<Icon name="layers" size="small" />
<span class="session-diff-files">{session.summary()!.files}f</span>
<span class="session-diff-add">+{session.summary()!.additions}</span>
<span class="session-diff-del">-{session.summary()!.deletions}</span>
<Show when={session.worktreeStats()?.files}>
<span class="session-diff-add">+{session.worktreeStats()!.additions}</span>
<span class="session-diff-del">-{session.worktreeStats()!.deletions}</span>
</Show>
</button>
</Tooltip>
</Show>
@@ -154,6 +154,9 @@ interface SessionContextValue {
revertedCount: Accessor<number>
summary: Accessor<SessionInfo["summary"]>
// Live worktree diff stats (polled from CLI backend)
worktreeStats: Accessor<{ files: number; additions: number; deletions: number } | undefined>
// Actions
revertSession: (messageID: string) => void
unrevertSession: () => void
@@ -266,6 +269,11 @@ export const SessionProvider: ParentComponent = (props) => {
// Cloud session preview state
const [cloudPreviewId, setCloudPreviewId] = createSignal<string | null>(null)
// Live worktree diff stats from extension polling
const [worktreeStats, setWorktreeStats] = createSignal<
{ files: number; additions: number; deletions: number } | undefined
>()
// Tracks optimistic messageIDs that haven't been confirmed by the server yet.
// Prevents handleMessagesLoaded from wiping them when it replaces the array.
const pendingOptimistic = new Map<string, Set<string>>()
@@ -636,6 +644,10 @@ export const SessionProvider: ParentComponent = (props) => {
})
console.error("[Kilo New] Cloud session import failed:", message.error)
break
case "worktreeStatsLoaded":
setWorktreeStats({ files: message.files, additions: message.additions, deletions: message.deletions })
break
}
})
@@ -1650,6 +1662,7 @@ export const SessionProvider: ParentComponent = (props) => {
revert,
revertedCount,
summary,
worktreeStats,
revertSession,
unrevertSession,
sendMessage,
@@ -176,6 +176,7 @@ export function mockSessionValue(overrides?: {
revert: () => undefined,
revertedCount: () => 0,
summary: () => undefined,
worktreeStats: () => undefined,
revertSession: noop,
unrevertSession: noop,
variantList: () => [],
@@ -174,21 +174,23 @@
/* New Task Button */
.new-task-button-wrapper {
padding: 8px 12px 0;
container-type: inline-size;
}
.session-actions-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
/* All direct children share extra space equally, but start from their content size */
.session-actions-row > * {
flex: 1 1 0;
min-width: fit-content;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
/* Tooltip wrapper divs must fill their flex space, and buttons inside must fill the wrapper */
/* Tooltip wrappers fill their flex slot, buttons fill the tooltip wrapper */
.session-actions-row > [data-component="tooltip-trigger"] {
display: flex;
}
@@ -197,15 +199,15 @@
flex: 1;
}
/* Diff stats tooltip wrapper — don't grow, push right */
/* Diff wrapper also stretches evenly (same flex as buttons) */
.session-diff-wrapper {
flex: 0 0 auto !important;
margin-left: auto;
display: flex !important;
}
/* Diff stats badge — matches Agent Manager diff toggle style */
/* Diff stats badge — fills its flex slot, content centered */
.session-diff-badge {
display: inline-flex;
display: flex;
flex: 1;
align-items: center;
justify-content: center;
gap: 4px;
@@ -219,6 +221,11 @@
font-variant-numeric: tabular-nums;
cursor: pointer;
white-space: nowrap;
position: relative;
}
.session-diff-badge--empty {
opacity: 0.5;
}
.session-diff-badge:hover {
@@ -227,10 +234,8 @@
.session-diff-badge [data-component="icon"] {
opacity: 0.7;
}
.session-diff-files {
color: var(--vscode-descriptionForeground);
position: relative;
flex-shrink: 0;
}
.session-diff-add {
@@ -241,6 +246,31 @@
color: #f87171;
}
/* Collapse diff stats to icon-only when truly narrow */
@container (max-width: 300px) {
.session-diff-add,
.session-diff-del {
display: none;
}
.session-diff-badge {
padding: 4px 6px;
overflow: hidden;
}
/* Dot indicator on the icon when collapsed and there are changes */
.session-diff-badge--has-changes [data-component="icon"]::after {
content: "";
position: absolute;
top: -2px;
right: -2px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #34d399;
}
}
/* ============================================
Message List
============================================ */
@@ -963,6 +963,14 @@ export interface AgentManagerLocalStatsMessage {
stats: LocalGitStats
}
// Sidebar: Live worktree diff stats (extension → webview)
export interface WorktreeStatsLoadedMessage {
type: "worktreeStatsLoaded"
files: number
additions: number
deletions: number
}
// Set the model for a session (extension → webview, used during multi-version creation)
export interface AgentManagerSetSessionModelMessage {
type: "agentManager.setSessionModel"
@@ -1294,6 +1302,7 @@ export type ExtensionMessage =
| RecentsLoadedMessage
| LanguageChangedMessage
| ContinueInWorktreeProgressMessage
| WorktreeStatsLoadedMessage
// ============================================
// Messages FROM webview TO extension