diff --git a/.changeset/agent-manager-diff-scope-selector.md b/.changeset/agent-manager-diff-scope-selector.md new file mode 100644 index 00000000000..341e5ee7684 --- /dev/null +++ b/.changeset/agent-manager-diff-scope-selector.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add a scope selector and base branch picker to the Agent Manager diff review. The side panel and full-screen review now let you switch between Branch, Staged, Unstaged, and Session scopes for the selected worktree, and the Branch scope's base branch can be overridden from a picker next to it. Branch stays the default, so existing review behavior is unchanged. diff --git a/.changeset/long-session-prompt-navigation.md b/.changeset/long-session-prompt-navigation.md new file mode 100644 index 00000000000..967dde901f2 --- /dev/null +++ b/.changeset/long-session-prompt-navigation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Navigate long conversations from a compact prompt rail that loads earlier history as you scroll. diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 9651c14b2b2..8710f462fa1 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -159,7 +159,9 @@ Each request can include 1-20 tasks. Each task must include at least one of `pro The companion `agent_manager_models` tool searches models and their supported reasoning variants on demand. Results are grouped by model name (with the offering providers listed for reference) and limited to 20 per call, so the full catalog is never added to the conversation context. -The tool uses the `agent_manager` permission. Approval prompts are scoped to the requested capability, so approving `worktree` does not automatically approve `local`, an overview, or a targeted prompt. Prompting an existing managed session requires an explicit `prompt` approval the first time, even if Agent Manager session creation was previously approved broadly. +The same tool also manages existing sessions. It can return a compact overview of sections, worktrees, and local sessions, send a prompt to one managed session, or stop a managed session. Stopping aborts the session's active work and removes it from the panel, just like closing the session tab. + +The tool uses the `agent_manager` permission. Approval prompts are scoped to the requested capability, so approving `worktree` does not automatically approve `local`, an overview, or a targeted prompt. Prompting an existing managed session requires an explicit `prompt` approval the first time, even if Agent Manager session creation was previously approved broadly. Stopping a session likewise requires an explicit `stop` approval. ## Sections diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png index ac6b5490c01..49c95de8d94 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a7c800d169ca92674fc9f7c7d83032ca17e7d7937bdcbea5c72cb921a7fdb89e -size 51975 +oid sha256:2a74c7f53a5d9afa743ee49d298ce8fb7e357c5fcc46cad45de353694a7540dc +size 49476 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png index df4e9c70689..2ed225e9ec8 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:51adb9e31ce0bc82981b0f20f895ce4ede3f92828546115f929942ef93bf0812 -size 11204 +oid sha256:d7fc23fcb7adf483c0b771ef601b23cb365dc7f40033b00fce703b35910aa4fc +size 27159 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png index 94024986cda..623df732200 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c5658ed9e5266311c4239b7cd470d2f772d1cc0b3fd6c6b00337d4f3141a3a4d -size 11950 +oid sha256:d11f4004ed3170647d385c14df077d235f5bb9bd6e5dec07557ee2014d553233 +size 27302 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png index 07e3e945eff..c2410b066a3 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:095ed97dc46498be6fd75483b24357ac0740b6f2c1544f6c103a1dac360e1a7e -size 11202 +oid sha256:f8bd56ba87d0c2bbef8a2325ab2e9f0e956c89e2602ff8ca7415d5fb7c9fa1f3 +size 29709 diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index e8e668b4b50..886465032fb 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -4,10 +4,12 @@ import type { KiloClient, Session } from "@kilocode/sdk/v2/client" import type { KiloConnectionService } from "../services/cli-backend" import { getErrorMessage } from "../kilo-provider-utils" import { resolveLocalDiffTarget } from "../diff/shared/target" +import { DiffSourceCatalog } from "../diff/sources/catalog" import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings" import { isAbsolutePath } from "../path-utils" import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager" +import { composeDiffId, normalizeScope } from "./diff-scope" import { handleSection } from "./section-handler" import { normalizeBaseBranch } from "./base-branch" import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller" @@ -71,6 +73,7 @@ export class AgentManagerProvider implements Disposable { private orchestration: AgentManagerOrchestrationBridge private gitOps: GitOps private diffs: WorktreeDiffController + private diffCatalog: DiffSourceCatalog private naming: BranchNamingController private staleWorktreeIds = new Set() private toolRequests = new Set() @@ -149,12 +152,13 @@ export class AgentManagerProvider implements Disposable { log: (msg) => this.log(msg), }) const local = createLocalDiff(this.gitOps, (...args) => this.log(...args)) + this.diffCatalog = new DiffSourceCatalog(this.connectionService) this.diffs = new WorktreeDiffController({ getState: () => this.getStateManager(), getRoot: () => this.getRoot(), getStateReady: () => this.stateReady, + catalog: this.diffCatalog, git: this.gitOps, - localDiff: local.summary, localDiffFile: local.file, post: (msg) => this.postToWebview(msg), log: (...args) => this.log(...args), @@ -684,11 +688,11 @@ export class AgentManagerProvider implements Disposable { private onDiffMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type === "agentManager.requestWorktreeDiff") { - void this.diffs.request(m.sessionId) + void this.diffs.request(composeDiffId(m.sessionId, normalizeScope(m.scope))) return null } if (m.type === "agentManager.requestWorktreeDiffFile") { - void this.diffs.requestFile(m.sessionId, m.file) + void this.diffs.requestFile(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.file) return null } if (m.type === "agentManager.applyWorktreeDiff") { @@ -696,23 +700,52 @@ export class AgentManagerProvider implements Disposable { return null } if (m.type === "agentManager.revertWorktreeFile") { - void this.diffs.revert(m.sessionId, m.file) + void this.diffs.revert(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.file) return null } if (m.type === "agentManager.startDiffWatch") { - this.diffs.start(m.sessionId) + this.diffs.start(composeDiffId(m.sessionId, normalizeScope(m.scope))) return null } if (m.type === "agentManager.stopDiffWatch") { this.diffs.stop() return null } + if (m.type === "agentManager.requestDiffBranches") { + void this.sendDiffBranches(m.sessionId, m.scope) + return null + } + if (m.type === "agentManager.setDiffBaseBranch") { + void this.diffs.setBase(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.branch).then(() => { + void this.sendDiffBranches(m.sessionId, m.scope) + }) + return null + } if (m.type === "agentManager.openFile") { this.openWorktreeFile(m.sessionId, m.filePath, m.line, m.column) return null } } + private async sendDiffBranches(sessionId: string, scope?: string): Promise { + const id = composeDiffId(sessionId, normalizeScope(scope)) + const result = await this.diffs.branches(id).catch((err) => { + this.log("Failed to list diff branches:", err instanceof Error ? err.message : String(err)) + return undefined + }) + if (!result) return + this.postToWebview({ + type: "agentManager.diffBranches", + sessionId: id, + branches: result.branches, + defaultBranch: result.defaultBranch, + autoBase: result.autoBase, + currentBase: result.currentBase, + isAuto: result.isAuto, + currentBranch: result.currentBranch, + }) + } + private onBridgeMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type !== "openFile") return undefined @@ -1927,6 +1960,7 @@ export class AgentManagerProvider implements Disposable { this.orchestration.dispose() this.visiblePresence.clear() this.diffs.stop() + this.diffCatalog.dispose() this.naming.dispose() this.statsPoller.stop() this.gitOps.dispose() diff --git a/packages/kilo-vscode/src/agent-manager/diff-scope.ts b/packages/kilo-vscode/src/agent-manager/diff-scope.ts new file mode 100644 index 00000000000..21d2e007935 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/diff-scope.ts @@ -0,0 +1,57 @@ +/** + * Composite diff-source keying for Agent Manager. + * + * Agent Manager keys diff sources by *context* (a session id, or the `local` + * workspace pseudo-context) while the standalone Changes viewer keys by + * *scope* (branch / staged / unstaged / session). To expose scopes in Agent + * Manager we compose the two into a single id the SourceController can build. + * + * ctx = "local" | "" + * scope = "branch" | "staged" | "unstaged" | "session" + * id = `${ctx}#${scope}` + * + * `ctx#branch` is the default and reproduces the pre-scope behavior exactly. + */ + +export type DiffScope = "branch" | "staged" | "unstaged" | "session" + +export const DEFAULT_DIFF_SCOPE: DiffScope = "branch" + +const SEP = "#" + +export function composeDiffId(ctx: string, scope: DiffScope): string { + return `${ctx}${SEP}${scope}` +} + +/** + * Split a composite id back into context and scope. Tolerates a bare context + * id (no separator) by assuming the default branch scope, which keeps the + * pre-scope messages working unchanged. + */ +export function parseDiffId(id: string): { ctx: string; scope: DiffScope } { + const idx = id.lastIndexOf(SEP) + if (idx === -1) return { ctx: id, scope: DEFAULT_DIFF_SCOPE } + const scope = id.slice(idx + SEP.length) + if (isDiffScope(scope)) return { ctx: id.slice(0, idx), scope } + return { ctx: id, scope: DEFAULT_DIFF_SCOPE } +} + +export function isDiffScope(value: string): value is DiffScope { + return value === "branch" || value === "staged" || value === "unstaged" || value === "session" +} + +export function normalizeScope(value: unknown): DiffScope { + return typeof value === "string" && isDiffScope(value) ? value : DEFAULT_DIFF_SCOPE +} + +/** + * Map a scope to the underlying standalone-viewer source id the catalog knows + * how to build. `branch` maps to the workspace source; `session` is handled + * separately because it needs the session id embedded in the source id. + */ +export function scopeToSourceId(scope: DiffScope, ctx: string): string { + if (scope === "staged") return "staged" + if (scope === "unstaged") return "unstaged" + if (scope === "session") return `session:${ctx}` + return "workspace" +} diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index d386e36acc8..d12b481120a 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -286,6 +286,18 @@ interface RevertWorktreeFileResultMessage { message: string } +/** Branch picker data for a context's diff directory. */ +interface DiffBranchesMessage { + type: "agentManager.diffBranches" + sessionId: string + branches: BranchListItem[] + defaultBranch: string + autoBase?: string + currentBase?: string + isAuto: boolean + currentBranch?: string +} + interface PRStatusOutMessage { type: "agentManager.prStatus" worktreeId: string @@ -324,6 +336,7 @@ export type AgentManagerOutMessage = | WorktreeDiffMessage | WorktreeDiffFileMessage | RevertWorktreeFileResultMessage + | DiffBranchesMessage | PRStatusOutMessage | ActionOutMessage | RunStatusMessage @@ -517,6 +530,7 @@ interface ImportFromPRIn { interface RequestWorktreeDiffIn { type: "agentManager.requestWorktreeDiff" sessionId: string + scope?: string } interface ApplyWorktreeDiffIn { @@ -529,11 +543,13 @@ interface RequestWorktreeDiffFileIn { type: "agentManager.requestWorktreeDiffFile" sessionId: string file: string + scope?: string } interface StartDiffWatchIn { type: "agentManager.startDiffWatch" sessionId: string + scope?: string } interface StopDiffWatchIn { @@ -544,6 +560,20 @@ interface RevertWorktreeFileIn { type: "agentManager.revertWorktreeFile" sessionId: string file: string + scope?: string +} + +interface RequestDiffBranchesIn { + type: "agentManager.requestDiffBranches" + sessionId: string + scope?: string +} + +interface SetDiffBaseBranchIn { + type: "agentManager.setDiffBaseBranch" + sessionId: string + scope?: string + branch?: string } interface RefreshPRIn { @@ -809,6 +839,8 @@ export type AgentManagerInMessage = | StartDiffWatchIn | StopDiffWatchIn | RevertWorktreeFileIn + | RequestDiffBranchesIn + | SetDiffBaseBranchIn | RefreshPRIn | OpenPRIn | OpenSessionsIn diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index 75d538cc35c..dff233ca5cc 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -1,12 +1,13 @@ import { SourceController } from "../diff/SourceController" import { resolveLocalDiffTarget } from "../diff/shared/target" import { WorktreeDiffReverter, type StatusResolver } from "../diff/shared/reverter" -import type { DiffFile } from "../diff/types" -import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "../diff/sources/types" +import type { DiffFile, PanelContext } from "../diff/types" +import type { DiffSource } from "../diff/sources/types" +import type { DiffSourceCatalog } from "../diff/sources/catalog" import type { ApplyConflict, GitOps } from "./GitOps" import { shouldStopDiffPolling } from "./delete-worktree" -import { Semaphore } from "./semaphore" import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager" +import { parseDiffId, scopeToSourceId } from "./diff-scope" import type { AgentManagerOutMessage, WorktreeDiffEntry } from "./types" const LOCAL_DIFF_ID = "local" as const @@ -19,14 +20,11 @@ export interface WorktreeDiffControllerContext { getState: () => WorktreeStateManager | undefined getRoot: () => string | undefined getStateReady: () => Promise | undefined - /** - * In-process diff paths deliberately bypass the SDK client to keep git spawns - * out of the Bun `kilo serve` process (see oven-sh/bun#18265). - */ + /** Builds the underlying per-scope diff sources (workspace/staged/unstaged/session). */ + catalog: DiffSourceCatalog + /** Shared git ops, injected into sources so they don't spawn their own channels. */ git: GitOps - /** In-process diff summary (replaces client.worktree.diffSummary). */ - localDiff: (dir: string, base: string) => Promise - /** In-process single-file diff (replaces client.worktree.diffFile). */ + /** In-process single-file diff (replaces client.worktree.diffFile). Used by revert. */ localDiffFile: (dir: string, base: string, file: string) => Promise post: (msg: AgentManagerOutMessage) => void log: (...args: unknown[]) => void @@ -34,13 +32,14 @@ export interface WorktreeDiffControllerContext { export class WorktreeDiffController { private readonly controller: SourceController - private readonly details = new Semaphore(3) private target: Target | undefined private applying: string | undefined + /** Ephemeral per-context base override, keyed by context id. */ + private baseOverrides = new Map() constructor(private readonly ctx: WorktreeDiffControllerContext) { this.controller = new SourceController( - (id) => this.source(id), + (id, ctx) => this.source(id, ctx), () => [], (msg) => this.ctx.post(msg as AgentManagerOutMessage), { @@ -80,7 +79,11 @@ export class WorktreeDiffController { } public shouldStopForWorktree(path: string, sessions: ManagedSession[]): boolean { - return shouldStopDiffPolling(path, sessions, this.target, this.controller.currentId) + // Pass the parsed context id, not the composite id, so the orphaned-session + // check matches real session ids. + const current = this.controller.currentId + const ctxId = current ? parseDiffId(current).ctx : undefined + return shouldStopDiffPolling(path, sessions, this.target, ctxId) } public async apply(worktreeId: string, value?: unknown): Promise { @@ -144,38 +147,38 @@ export class WorktreeDiffController { } } - public async revert(sessionId: string, file: string): Promise { + public async revert(id: string, file: string): Promise { if (!file) return - if (this.controller.currentId !== sessionId) { - const result = await this.revertFile(sessionId, file) - this.postRevertResult(sessionId, file, result) + if (this.controller.currentId !== id) { + const result = await this.revertFile(id, file) + this.postRevertResult(id, file, result) return } await this.controller.revertFile(file) } - public async request(sessionId: string): Promise { - if (this.controller.currentId !== sessionId) { - await this.activate(sessionId, false, true) + public async request(id: string): Promise { + if (this.controller.currentId !== id) { + await this.activate(id, false, true) return } this.target = undefined await this.controller.refresh() } - public async requestFile(sessionId: string, file: string): Promise { + public async requestFile(id: string, file: string): Promise { if (!file) return - if (this.controller.currentId !== sessionId) { - this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: null }) + if (this.controller.currentId !== id) { + this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId: id, file, diff: null }) return } await this.controller.requestFile(file) } - public start(sessionId: string): void { - if (this.controller.isPolling && this.controller.currentId === sessionId) return - this.ctx.log(`Starting diff polling for session ${sessionId}`) - void this.activate(sessionId, true, true) + public start(id: string): void { + if (this.controller.isPolling && this.controller.currentId === id) return + this.ctx.log(`Starting diff polling for ${id}`) + void this.activate(id, true, true) } public stop(): void { @@ -183,92 +186,113 @@ export class WorktreeDiffController { this.target = undefined } - private async activate(sessionId: string, poll: boolean, fetch: boolean): Promise { + /** + * Set or clear an ephemeral base override for a context (worktree or local), + * then re-activate the current source so it refetches against the new base. + * Passing undefined clears the override and falls back to the recorded parent. + */ + public async setBase(id: string, branch: string | undefined): Promise { + const { ctx } = parseDiffId(id) + if (branch) this.baseOverrides.set(ctx, branch) + else this.baseOverrides.delete(ctx) this.target = undefined - this.controller.setContext({ workspaceRoot: this.ctx.getRoot() }) - await this.controller.activate(sessionId, { poll, fetch }) + await this.controller.reactivate() } - private async resolve(sessionId: string): Promise<{ directory: string; baseBranch: string } | undefined> { - if (sessionId === LOCAL_DIFF_ID) return await this.resolveLocal() + /** Branch picker data for a context's directory, using any active override. */ + public async branches(id: string) { + await this.ready("stateReady rejected, continuing diff branches resolve:") + const { ctx } = parseDiffId(id) + const target = await this.resolve(ctx) + if (!target) return undefined + return await this.ctx.catalog.listWorkspaceBranches(this.baseOverrides.get(ctx), target.directory) + } + + private async activate(id: string, poll: boolean, fetch: boolean): Promise { + this.target = undefined + await this.ready("stateReady rejected, continuing diff activate:") + const { ctx } = parseDiffId(id) + const resolved = await this.resolve(ctx) + this.target = resolved ? { sessionId: id, ...resolved } : undefined + this.controller.setContext({ + workspaceRoot: this.ctx.getRoot(), + dir: resolved?.directory, + // The resolved base already bakes in any ephemeral override (see + // resolve()), so pass it as the explicit base and leave + // baseBranchOverride unset to avoid double resolution. + baseBranch: resolved?.baseBranch, + // Agent Manager always knows its intended directory (LOCAL resolves to + // the root). Never fall back to the workspace root for an unresolvable + // worktree context — return an empty diff instead. + strictDir: true, + git: this.ctx.git, + log: (...args) => this.ctx.log(...args), + }) + await this.controller.activate(id, { poll, fetch }) + } + + private async resolve(ctxId: string): Promise<{ directory: string; baseBranch: string } | undefined> { + if (ctxId === LOCAL_DIFF_ID) return await this.resolveLocal() const state = this.ctx.getState() if (!state) { - this.ctx.log(`resolveDiffTarget: no state manager for session ${sessionId}`) + this.ctx.log(`resolveDiffTarget: no state manager for context ${ctxId}`) return undefined } - const session = state.getSession(sessionId) + const session = state.getSession(ctxId) if (!session) { this.ctx.log( - `resolveDiffTarget: session ${sessionId} not found in state (${state.getSessions().length} total sessions)`, + `resolveDiffTarget: session ${ctxId} not found in state (${state.getSessions().length} total sessions)`, ) return undefined } if (!session.worktreeId) { - this.ctx.log(`resolveDiffTarget: session ${sessionId} has no worktreeId (local session)`) + this.ctx.log(`resolveDiffTarget: session ${ctxId} has no worktreeId (local session)`) return undefined } const worktree = state.getWorktree(session.worktreeId) if (!worktree) { - this.ctx.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${sessionId}`) + this.ctx.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${ctxId}`) return undefined } - return { directory: worktree.path, baseBranch: remoteRef(worktree) } + const base = this.baseOverrides.get(ctxId) ?? remoteRef(worktree) + return { directory: worktree.path, baseBranch: base } } private async resolveLocal(): Promise<{ directory: string; baseBranch: string } | undefined> { - return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), this.ctx.getRoot()) + const root = this.ctx.getRoot() + if (!root) return undefined + const override = this.baseOverrides.get(LOCAL_DIFF_ID) + if (override) { + return { directory: root, baseBranch: override } + } + return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), root) } private async ready(msg: string): Promise { await this.ctx.getStateReady()?.catch((err) => this.ctx.log(msg, err)) } - private source(sessionId: string): DiffSource { - const descriptor: DiffSourceDescriptor = { - id: sessionId, - type: "workspace", - group: "Git", - capabilities: { revert: true, comments: true }, - } - + /** + * Build the active source for a composite id by delegating to the catalog. + * The composite id (ctx#scope) is preserved as the descriptor id so the + * webview keys diff data by context+scope. Context resolution (dir/base) + * already happened in activate() and is carried by the PanelContext. + */ + private source(id: string, panelCtx: PanelContext): DiffSource { + const { ctx, scope } = parseDiffId(id) + const built = this.ctx.catalog.build(scopeToSourceId(scope, ctx), panelCtx) return { - descriptor, - fetch: () => this.fetch(sessionId), - fetchFile: (file) => this.fetchFile(sessionId, file), - revert: (file) => this.revertFile(sessionId, file), + ...built, + descriptor: { ...built.descriptor, id }, } } - private async fetch(sessionId: string): Promise { - await this.ready("stateReady rejected, continuing diff resolve:") - const target = await this.ensureTarget(sessionId) - if (!target) return { diffs: [], stopPolling: true } - - const files = await this.ctx.localDiff(target.directory, target.baseBranch) - this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`) - return { diffs: files as AgentManagerDiffFile[] } - } - - private async fetchFile(sessionId: string, file: string): Promise { - await this.ready("stateReady rejected, continuing diff detail resolve:") - return this.details.run(async () => { - const target = await this.ensureTarget(sessionId) - if (!target) return null - - try { - return (await this.ctx.localDiffFile(target.directory, target.baseBranch, file)) as AgentManagerDiffFile | null - } catch (error) { - this.ctx.log("Failed to fetch worktree diff file:", error) - return null - } - }) - } - - private async revertFile(sessionId: string, file: string): Promise<{ ok: boolean; message: string }> { + private async revertFile(id: string, file: string): Promise<{ ok: boolean; message: string }> { await this.ready("stateReady rejected, continuing revert resolve:") - const target = await this.resolveTarget(sessionId) + const { ctx } = parseDiffId(id) + const target = await this.resolve(ctx) if (!target) return { ok: false, message: "Could not resolve diff target" } try { @@ -285,19 +309,6 @@ export class WorktreeDiffController { } } - private async ensureTarget(sessionId: string): Promise { - if (this.controller.currentId !== sessionId) return undefined - if (this.target?.sessionId === sessionId) return this.target - return await this.resolveTarget(sessionId) - } - - private async resolveTarget(sessionId: string): Promise { - const target = await this.resolve(sessionId) - if (!target) return undefined - this.target = { sessionId, ...target } - return this.target - } - private postRevertResult(sessionId: string, file: string, result: { ok: boolean; message: string }): void { this.ctx.post({ type: "agentManager.revertWorktreeFileResult", diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts index fa36aaecf2d..8eb2d0ffdfb 100644 --- a/packages/kilo-vscode/src/diff/sources/catalog.ts +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -90,12 +90,17 @@ export class DiffSourceCatalog implements vscode.Disposable { } build(id: string, ctx: PanelContext): DiffSource { + const opts = { dir: () => ctx.dir, strictDir: ctx.strictDir, git: ctx.git, log: ctx.log } if (id === WORKSPACE_SOURCE_ID) { - return createWorktreeDiffSource({ baseBranchOverride: ctx.baseBranchOverride }) + return createWorktreeDiffSource({ + ...opts, + baseBranchOverride: ctx.baseBranchOverride, + baseBranch: ctx.baseBranch, + }) } - if (id === STAGED_SOURCE_ID) return createStagedDiffSource() - if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource() + if (id === STAGED_SOURCE_ID) return createStagedDiffSource(opts) + if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource(opts) if (id.startsWith(TURN_PREFIX)) { const [sessionId, messageId] = id.slice(TURN_PREFIX.length).split(":") @@ -108,14 +113,22 @@ export class DiffSourceCatalog implements vscode.Disposable { if (id.startsWith(SESSION_PREFIX)) { const sessionId = id.slice(SESSION_PREFIX.length) if (!sessionId) throw new Error(`DiffSourceCatalog.build: empty session id in "${id}"`) - return createSessionDiffSource(sessionId, this.sessionFetch, ctx.workspaceRoot, this.checkSnapshotsEnabled) + return createSessionDiffSource( + sessionId, + this.sessionFetch, + ctx.dir ?? ctx.workspaceRoot, + this.checkSnapshotsEnabled, + ) } throw new Error(`DiffSourceCatalog.build: unknown source id "${id}"`) } - async listWorkspaceBranches(override: string | undefined): Promise { - const root = getWorkspaceRoot() + async listWorkspaceBranches( + override: string | undefined, + dir?: string, + ): Promise { + const root = dir ?? getWorkspaceRoot() if (!root) return undefined const git = this.ensureBranchGit() diff --git a/packages/kilo-vscode/src/diff/sources/staged.ts b/packages/kilo-vscode/src/diff/sources/staged.ts index d21a508902b..8cd250743cb 100644 --- a/packages/kilo-vscode/src/diff/sources/staged.ts +++ b/packages/kilo-vscode/src/diff/sources/staged.ts @@ -34,17 +34,39 @@ function stamp(entry: FileEntry, before: string, after: string): FileEntry { return { ...entry, stamp: `${entry.status}:${before}:${after}` } } +export interface StagedDiffSourceOptions { + /** + * Resolve the directory to diff. Defaults to the VS Code workspace root. + * Agent Manager passes a worktree path so the source diffs inside the + * worktree rather than the main checkout. + */ + dir?: () => string | undefined + /** + * When true, a `dir` that resolves to undefined yields an empty diff rather + * than falling back to the workspace root. + */ + strictDir?: boolean + /** Shared GitOps / log so sources don't each spawn their own channel. */ + git?: GitOps + log?: (...args: unknown[]) => void +} + /** * Diff between the git index and HEAD — what `git diff --cached` would show. * Polls on the standard interval; revert isn't supported (use `git reset` from * a real git client). Read-only view. */ -export function createStagedDiffSource(): DiffSource { - const output = vscode.window.createOutputChannel("Kilo Diff: Staged") - const log = (...args: unknown[]) => appendOutput(output, "StagedDiffSource", ...args) - const git = new GitOps({ log }) +export function createStagedDiffSource(opts: StagedDiffSourceOptions = {}): DiffSource { + const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Staged") + const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "StagedDiffSource", ...args)) + const git = opts.git ?? new GitOps({ log }) - const root = (): string | undefined => getWorkspaceRoot() + const root = (): string | undefined => { + const dir = opts.dir?.() + if (dir) return dir + if (opts.strictDir) return undefined + return getWorkspaceRoot() + } const listEntries = async (dir: string): Promise => { const [nameStatus, numstat, raw] = await Promise.all([ @@ -150,8 +172,10 @@ export function createStagedDiffSource(): DiffSource { }, dispose(): void { - git.dispose() - output.dispose() + // Only dispose resources we own (created here). Injected git/log are + // owned by the caller. + if (!opts.git) git.dispose() + output?.dispose() }, } } diff --git a/packages/kilo-vscode/src/diff/sources/unstaged.ts b/packages/kilo-vscode/src/diff/sources/unstaged.ts index b4d0c996161..5fcb03793d2 100644 --- a/packages/kilo-vscode/src/diff/sources/unstaged.ts +++ b/packages/kilo-vscode/src/diff/sources/unstaged.ts @@ -40,17 +40,39 @@ function stamp(entry: FileEntry, before: string, after: string): FileEntry { return { ...entry, stamp: `${entry.status}:${before}:${after}` } } +export interface UnstagedDiffSourceOptions { + /** + * Resolve the directory to diff. Defaults to the VS Code workspace root. + * Agent Manager passes a worktree path so the source diffs inside the + * worktree rather than the main checkout. + */ + dir?: () => string | undefined + /** + * When true, a `dir` that resolves to undefined yields an empty diff rather + * than falling back to the workspace root. + */ + strictDir?: boolean + /** Shared GitOps / log so sources don't each spawn their own channel. */ + git?: GitOps + log?: (...args: unknown[]) => void +} + /** * Diff between the working tree and the index — what `git diff` shows for * tracked files, plus untracked files (treated as fully-added). Read-only; * polls on the standard interval. */ -export function createUnstagedDiffSource(): DiffSource { - const output = vscode.window.createOutputChannel("Kilo Diff: Unstaged") - const log = (...args: unknown[]) => appendOutput(output, "UnstagedDiffSource", ...args) - const git = new GitOps({ log }) +export function createUnstagedDiffSource(opts: UnstagedDiffSourceOptions = {}): DiffSource { + const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Unstaged") + const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "UnstagedDiffSource", ...args)) + const git = opts.git ?? new GitOps({ log }) - const root = (): string | undefined => getWorkspaceRoot() + const root = (): string | undefined => { + const dir = opts.dir?.() + if (dir) return dir + if (opts.strictDir) return undefined + return getWorkspaceRoot() + } const listTracked = async (dir: string): Promise => { const [nameStatus, numstat, raw] = await Promise.all([ @@ -192,8 +214,10 @@ export function createUnstagedDiffSource(): DiffSource { }, dispose(): void { - git.dispose() - output.dispose() + // Only dispose resources we own (created here). Injected git/log are + // owned by the caller. + if (!opts.git) git.dispose() + output?.dispose() }, } } diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts index 1f3d8ee25a4..89e750a1c87 100644 --- a/packages/kilo-vscode/src/diff/sources/worktree.ts +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -23,6 +23,28 @@ export interface WorktreeDiffSourceOptions { * the current branch — only the comparison target changes. Reset on dispose. */ baseBranchOverride?: string + /** + * Resolve the directory to diff. Defaults to the VS Code workspace root. + * Agent Manager passes a worktree path so the source diffs inside the + * worktree rather than the main checkout. + */ + dir?: () => string | undefined + /** + * When true, a `dir` that resolves to undefined yields an empty diff rather + * than falling back to the workspace root. Prevents an unresolvable + * worktree context from silently diffing the main checkout. + */ + strictDir?: boolean + /** + * Explicit base branch to diff against. When set, the source skips + * auto-resolution (tracking → default) and diffs against this ref directly. + * Agent Manager passes the worktree's recorded parent so a worktree always + * compares against its own base even when the workspace default differs. + */ + baseBranch?: string + /** Shared GitOps / log so sources don't each spawn their own channel. */ + git?: GitOps + log?: (...args: unknown[]) => void } /** @@ -32,9 +54,16 @@ export interface WorktreeDiffSourceOptions { * extension host — no `kilo serve` round-trip. */ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): DiffSource { - const output = vscode.window.createOutputChannel("Kilo Diff: Workspace") - const log = (...args: unknown[]) => appendOutput(output, "WorktreeDiffSource", ...args) - const git = new GitOps({ log }) + const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Workspace") + const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "WorktreeDiffSource", ...args)) + const git = opts.git ?? new GitOps({ log }) + + const root = (): string | undefined => { + const dir = opts.dir?.() + if (dir) return dir + if (opts.strictDir) return undefined + return getWorkspaceRoot() + } // Cached between fetches so repeated polling doesn't re-resolve the base // branch every tick. Reset only on dispose (when the source is swapped out). @@ -42,22 +71,32 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): const resolveTarget = async (): Promise => { if (target) return target + if (opts.baseBranch) { + const dir = root() + if (!dir) { + log("Local diff: no directory (explicit base mode)") + return + } + target = { directory: dir, baseBranch: opts.baseBranch } + log(`Local diff: using explicit base=${opts.baseBranch} dir=${dir}`) + return target + } if (opts.baseBranchOverride) { - const root = getWorkspaceRoot() - if (!root) { + const dir = root() + if (!dir) { log("Local diff: no workspace root (override mode)") return } - const resolved = await resolveOverrideRef(git, root, opts.baseBranchOverride, log) + const resolved = await resolveOverrideRef(git, dir, opts.baseBranchOverride, log) if (!resolved) { log(`Local diff: override base="${opts.baseBranchOverride}" could not be resolved, falling back to auto`) } else { - target = { directory: root, baseBranch: resolved } + target = { directory: dir, baseBranch: resolved } log(`Local diff: using override base=${resolved}`) return target } } - target = await resolveLocalDiffTarget(git, log, getWorkspaceRoot()) + target = await resolveLocalDiffTarget(git, log, root()) return target } @@ -109,8 +148,10 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): }, dispose(): void { - git.dispose() - output.dispose() + // Only dispose resources we own (created here). Injected git/log are + // owned by the caller. + if (!opts.git) git.dispose() + output?.dispose() target = undefined }, } diff --git a/packages/kilo-vscode/src/diff/types.ts b/packages/kilo-vscode/src/diff/types.ts index 913d4560a31..3ff72904e13 100644 --- a/packages/kilo-vscode/src/diff/types.ts +++ b/packages/kilo-vscode/src/diff/types.ts @@ -10,6 +10,27 @@ export interface PanelContext { hidePicker?: boolean /** User-picked base branch for the workspace source. Undefined = auto. */ baseBranchOverride?: string + /** + * Explicit directory to diff inside, overriding the workspace root lookup. + * Agent Manager passes a worktree path so its sources operate in the + * worktree rather than the main checkout. + */ + dir?: string + /** + * When true, a source whose `dir` resolves to undefined returns an empty + * diff instead of falling back to the workspace root. Agent Manager sets + * this so an unresolvable worktree context never silently diffs the main + * checkout. + */ + strictDir?: boolean + /** + * Explicit base ref for the workspace source, skipping auto-resolution. + * Agent Manager passes the worktree's recorded parent ref. + */ + baseBranch?: string + /** Shared GitOps / log injected by Agent Manager to avoid per-source channels. */ + git?: import("../agent-manager/GitOps").GitOps + log?: (...args: unknown[]) => void } export type DiffImageError = "too-large" | "unreadable" 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 ef69f9f3171..36c1c1c766f 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -572,7 +572,9 @@ describe("Agent Manager Provider — onMessage routing", () => { expect(text).toContain("class WorktreeDiffController") expect(text).toContain("buildWorktreePatch") expect(text).toContain("revertFile") - expect(text).toContain("diffSummary") + // Summary/detail diff data comes from the shared DiffSourceCatalog sources + // (workspace/staged/unstaged/session), not a bespoke in-controller pipeline. + expect(text).toContain("catalog.build") expect(text).toContain("shouldStopDiffPolling") expect(providerText).toContain("this.diffs") }) diff --git a/packages/kilo-vscode/tests/unit/diff-scope.test.ts b/packages/kilo-vscode/tests/unit/diff-scope.test.ts new file mode 100644 index 00000000000..7714ae4fa24 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-scope.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "bun:test" +import { + composeDiffId, + parseDiffId, + isDiffScope, + normalizeScope, + scopeToSourceId, + DEFAULT_DIFF_SCOPE, +} from "../../src/agent-manager/diff-scope" + +describe("diff-scope composite ids", () => { + it("round-trips context and scope", () => { + expect(parseDiffId(composeDiffId("local", "branch"))).toEqual({ ctx: "local", scope: "branch" }) + expect(parseDiffId(composeDiffId("ses_abc", "staged"))).toEqual({ ctx: "ses_abc", scope: "staged" }) + expect(parseDiffId(composeDiffId("ses_abc", "unstaged"))).toEqual({ ctx: "ses_abc", scope: "unstaged" }) + expect(parseDiffId(composeDiffId("ses_abc", "session"))).toEqual({ ctx: "ses_abc", scope: "session" }) + }) + + it("parses session ids containing no separator as default branch scope", () => { + expect(parseDiffId("ses_abc")).toEqual({ ctx: "ses_abc", scope: DEFAULT_DIFF_SCOPE }) + }) + + it("treats an unknown trailing segment as part of the context, not a scope", () => { + // A session id that happens to contain '#' but not a valid scope keeps the + // full id as context and falls back to branch. + expect(parseDiffId("ses_a#bogus")).toEqual({ ctx: "ses_a#bogus", scope: DEFAULT_DIFF_SCOPE }) + }) + + it("isDiffScope guards the closed enum", () => { + expect(isDiffScope("branch")).toBe(true) + expect(isDiffScope("staged")).toBe(true) + expect(isDiffScope("unstaged")).toBe(true) + expect(isDiffScope("session")).toBe(true) + expect(isDiffScope("turn")).toBe(false) + expect(isDiffScope("")).toBe(false) + }) + + it("normalizeScope falls back to branch for unknown input", () => { + expect(normalizeScope("staged")).toBe("staged") + expect(normalizeScope("nope")).toBe("branch") + expect(normalizeScope(undefined)).toBe("branch") + expect(normalizeScope(42)).toBe("branch") + }) + + it("maps scopes to catalog source ids", () => { + expect(scopeToSourceId("branch", "ses_abc")).toBe("workspace") + expect(scopeToSourceId("staged", "ses_abc")).toBe("staged") + expect(scopeToSourceId("unstaged", "ses_abc")).toBe("unstaged") + expect(scopeToSourceId("session", "ses_abc")).toBe("session:ses_abc") + expect(scopeToSourceId("branch", "local")).toBe("workspace") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/prompt-rail.test.ts b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts index d9854b52261..18c800e1bba 100644 --- a/packages/kilo-vscode/tests/unit/prompt-rail.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from "bun:test" import { messageTurns } from "../../webview-ui/src/context/session-queue" import { transcriptRows } from "../../webview-ui/src/context/transcript-rows" import type { Message, Part, TextPart } from "../../webview-ui/src/types/messages" -import { capacity, previewText, promptItems, railItems } from "../../webview-ui/src/components/chat/prompt-rail" +import { + capacity, + historyAction, + previewText, + promptItems, + railEntries, +} from "../../webview-ui/src/components/chat/prompt-rail" const base = { sessionID: "session", @@ -157,18 +163,38 @@ describe("promptItems", () => { }) describe("capacity", () => { - it("counts how many worst-case rows fit the transcript height", () => { - expect(capacity(24 + 76 * 5)).toBe(5) - expect(capacity(100)).toBe(1) + it("counts how many ticks fit the transcript height", () => { + expect(capacity(24 + 7 * 5)).toBe(5) + expect(capacity(31)).toBe(1) + }) + + it("fits far more ticks than the navigator lists rows", () => { + // A tick is a hairline, so a sidebar-height transcript holds a whole + // session's prompts rather than the handful of card rows that fit. + expect(capacity(724)).toBe(100) }) it("returns nothing usable for unmeasured or tiny transcripts", () => { expect(capacity(0)).toBeLessThan(1) - expect(capacity(99)).toBeLessThan(1) + expect(capacity(30)).toBeLessThan(1) }) }) -describe("railItems", () => { +describe("historyAction", () => { + it("loads the next page only after the previous page made progress", () => { + expect(historyAction(80, 160, true)).toBe("load") + }) + + it("jumps after the final page", () => { + expect(historyAction(160, 200, false)).toBe("jump") + }) + + it("stops instead of retrying a page that made no progress", () => { + expect(historyAction(160, 160, true)).toBe("stop") + }) +}) + +describe("railEntries", () => { const items = Array.from({ length: 5 }, (_, i) => ({ key: `k${i}`, turn: `t${i}`, @@ -178,15 +204,36 @@ describe("railItems", () => { })) it("passes through when everything fits", () => { - expect(railItems(items, 5)).toEqual(items) - expect(railItems(items, 10)).toEqual(items) + expect(railEntries(items, 5)).toEqual(items.map((item, index) => ({ type: "prompt", item, index }))) + expect(railEntries(items, 10)).toEqual(items.map((item, index) => ({ type: "prompt", item, index }))) }) - it("keeps the newest items when capacity is smaller", () => { - expect(railItems(items, 2)).toEqual(items.slice(-2)) + it("keeps the first and latest prompts at minimal capacity", () => { + expect(railEntries(items, 2)).toEqual([ + { type: "prompt", item: items[0], index: 0 }, + { type: "prompt", item: items[4], index: 4 }, + ]) + }) + + it("summarizes hidden loaded prompts between the first and recent prompts", () => { + expect(railEntries(items, 4)).toEqual([ + { type: "prompt", item: items[0], index: 0 }, + { type: "overflow", count: 2, index: 1 }, + { type: "prompt", item: items[3], index: 3 }, + { type: "prompt", item: items[4], index: 4 }, + ]) + }) + + it("reserves the first entry for unloaded history", () => { + expect(railEntries(items, 4, true)).toEqual([ + { type: "history" }, + { type: "overflow", count: 3, index: 0 }, + { type: "prompt", item: items[3], index: 3 }, + { type: "prompt", item: items[4], index: 4 }, + ]) }) it("returns nothing at zero capacity", () => { - expect(railItems(items, 0)).toEqual([]) + expect(railEntries(items, 0)).toEqual([]) }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 8397077e5d8..a17c947bd97 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -23,6 +23,7 @@ import type { AgentManagerWorktreeDiffMessage, AgentManagerWorktreeDiffFileMessage, AgentManagerWorktreeDiffLoadingMessage, + AgentManagerDiffBranchesMessage, AgentManagerApplyWorktreeDiffResultMessage, AgentManagerWorktreeStatsMessage, AgentManagerLocalStatsMessage, @@ -136,6 +137,9 @@ import { } from "./section-helpers" import { sectionAwareDetector } from "./section-dnd" import { ConstrainDragXAxis } from "./constrain-drag-x" +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" @@ -1308,6 +1312,10 @@ const AgentManagerContent: Component = () => { diffs.onWorktreeDiffLoading(msg as AgentManagerWorktreeDiffLoadingMessage) } + if (msg.type === "agentManager.diffBranches") { + review.onBranches(msg as AgentManagerDiffBranchesMessage) + } + if (msg.type === "agentManager.applyWorktreeDiffResult") { apply.onApplyResult(msg as AgentManagerApplyWorktreeDiffResultMessage) } @@ -1379,15 +1387,47 @@ const AgentManagerContent: Component = () => { const currentDiffSessionId = createMemo(selectedDiffSessionId) - // Start/stop diff watch when panel opens/closes, review tab opens, or session changes + // Diff scope + base branch state, shared by the side panel and review tab. + const review = createDiffReviewScope({ + ctx: currentDiffSessionId, + panelOpen: diffOpen, + reviewActive, + local: LOCAL, + vscode, + }) + // The composite id (ctx#scope) the extension keys diff data by. + const diffScopeId = review.id + + // Shared scope + base-picker controls for the side panel and review tab. + const diffScopeControls = (compact: boolean) => ( + + ) + + // Start/stop diff watch when panel opens/closes, review tab opens, scope + // changes, or session changes. createEffect(() => { const panel = diffOpen() - const review = reviewActive() + const active = reviewActive() + const scope = review.scope() - if (panel || review) { + if (panel || active) { const id = currentDiffSessionId() if (id) { - vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id }) + vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id, scope }) return } vscode.postMessage({ type: "agentManager.stopDiffWatch" }) @@ -1432,33 +1472,17 @@ const AgentManagerContent: Component = () => { tabFocus.restore() } - // Data for the review tab: use local diff data for local context, - // current session for selected worktree context, or first available in that worktree. + // Data for the review tab / side panel: keyed by the composite diff id + // (ctx#scope) the extension pushes, so each scope keeps its own file set and + // switching back to a fetched scope is instant. const reviewDiffs = createMemo(() => { const data = diffDatas() - const sel = selection() - const id = session.currentSessionID() - if (sel === LOCAL) return data[LOCAL] ?? [] - if (id && data[id]) { - const current = managedSessions().find((s) => s.id === id) - if (sel && current?.worktreeId === sel) return data[id]! - } - if (!sel) return [] - const ids = managedSessions() - .filter((s) => s.worktreeId === sel) - .map((s) => s.id) - for (const sid of ids) { - if (data[sid]) return data[sid]! - } - return [] + const key = diffScopeId() + if (!key) return [] + return data[key] ?? [] }) - const diffSessionKey = createMemo(() => { - const sel = selection() - if (sel === LOCAL) return `local:${LOCAL}` - if (sel === null) return `session:${session.currentSessionID() ?? ""}` - return `worktree:${sel}` - }) + const diffSessionKey = createMemo(() => diffScopeId() ?? "") const setSharedDiffStyle = (style: "unified" | "split") => { if (reviewDiffStyle() === style) return @@ -1467,14 +1491,14 @@ const AgentManagerContent: Component = () => { } const requestDiffFile = (file: string) => { - const sessionId = currentDiffSessionId() - if (!sessionId) return - diffs.requestDiffFile(sessionId, file) + const id = diffScopeId() + if (!id) return + diffs.requestDiffFile(id, file) } - const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(currentDiffSessionId)) + const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(diffScopeId)) - const revertCtl = createRevertFile(currentDiffSessionId, vscode, showToast, t) + const revertCtl = createRevertFile(diffScopeId, currentDiffSessionId, () => review.scope(), vscode, showToast, t) const handleConfigureSetupScript = () => { vscode.postMessage({ type: "agentManager.configureSetupScript" }) @@ -2475,12 +2499,19 @@ const AgentManagerContent: Component = () => { {t("agentManager.open.button")} - + + } + > +
+ + {(opt, index) => ( + <> + {(text) =>
{text()}
}
+ + + )} +
+
+ + ) +} + +/** Compact unified/split picker. Replaces the wide radio group in tight rows. */ +export const DiffStyleSelect: Component<{ + value: "unified" | "split" + onSelect: (value: "unified" | "split") => void + unifiedLabel: string + splitLabel: string + title: string +}> = (props) => ( + +) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index d2daa6389b1..e5aa67b906d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -71,7 +71,7 @@ import { type TranscriptRow, } from "../../context/transcript-rows" import { PromptRail } from "./PromptRail" -import { capacity, promptItems, railItems, type PromptRailItem } from "./prompt-rail" +import { capacity, historyAction, promptItems, railEntries, type PromptRailItem } from "./prompt-rail" import { onTimelineHighlight, type TimelineHighlight } from "../../utils/timeline/highlight" import { useTranscriptSearch, type SearchMatch } from "../../context/transcript-search" import { applyTranscriptHighlights, clearTranscriptHighlights } from "./transcript-search-highlight" @@ -909,8 +909,8 @@ export const MessageList: Component = (props) => { // entirely to the precise per-occurrence check in paintHighlights, // which only scrolls when the exact match actually needs it. if (!mounted) { - const index = keys().indexOf(match.key) - if (index >= 0) { + const index = indexes().get(match.key) + if (index !== undefined) { virtualizer()?.scrollToIndex(index, { align: "center" }) } } @@ -946,23 +946,65 @@ export const MessageList: Component = (props) => { const tail = createMemo(() => partition().direct.map((row) => row.key)) const lookup = createMemo(() => new Map(partition().direct.map((row) => [row.key, row]))) const keys = createMemo(() => partition().virtual.map((row) => row.key)) + const indexes = createMemo(() => new Map(keys().map((key, index) => [key, index]))) const fingerprint = createMemo(() => rowFingerprint(keys())) + const [pending, setPending] = createSignal<{ sid: string; key: string }>() + // Scrolls the transcript to a row by key. Virtualized rows jump through // the virtualizer; direct/live/queued rows are mounted, so they use // scrollIntoView. Pauses auto-follow first so the jump isn't snapped back. const jump = (key: string) => { autoScroll.pause() - const index = keys().indexOf(key) - if (index >= 0) { - virtualizer()?.scrollToIndex(index, { align: "start" }) + const index = indexes().get(key) + if (index !== undefined) { + const handle = virtualizer() + if (handle) { + setPending(undefined) + handle.scrollToIndex(index, { align: "start" }) + return + } + const sid = session.currentSessionID() + if (sid) setPending({ sid, key }) return } const el = scrollEl() const target = el?.querySelector(`[data-row-key="${CSS.escape(key)}"]`) - target?.scrollIntoView({ block: "start" }) + if (target) { + setPending(undefined) + target.scrollIntoView({ block: "start" }) + return + } + const sid = session.currentSessionID() + if (sid) setPending({ sid, key }) } + // Keep unresolved targets by stable row key. Virtual rows resolve once + // Virtua installs its handle; direct/live rows resolve once Solid mounts + // their DOM node. + createEffect(() => { + const target = pending() + if (!target) return + if (target.sid !== session.currentSessionID()) { + setPending(undefined) + return + } + const index = indexes().get(target.key) + const handle = virtualizer() + if (index !== undefined && handle) { + setPending(undefined) + autoScroll.pause() + handle.scrollToIndex(index, { align: "start" }) + return + } + const el = scrollEl() + const row = el?.querySelector(`[data-row-key="${CSS.escape(target.key)}"]`) + if (!row) return + setPending(undefined) + autoScroll.pause() + row.scrollIntoView({ block: "start" }) + }) + // Clicking a bar in the task timeline scrolls the transcript to that message. // Jumps land instantly (no smooth animation): while pinned at the bottom, a // smooth scroll's initial frames sit within createAutoScroll's near-bottom @@ -985,12 +1027,67 @@ export const MessageList: Component = (props) => { const items = createMemo(() => promptItems(rows())) // Until the transcript is measured there is no height to cap against, and // rendering every prompt would spill ticks past the rail on long sessions. - const shown = createMemo(() => railItems(items(), capacity(height()))) + const entries = createMemo(() => railEntries(items(), capacity(height()), session.hasOlderMessages())) const [activeTurn, setActiveTurn] = createSignal() - const railActiveKey = createMemo(() => shown().find((item) => item.turn === activeTurn())?.key) + const railActiveKey = createMemo(() => items().find((item) => item.turn === activeTurn())?.key) + + const [seek, setSeek] = createSignal<{ sid: string; count: number }>() + let paging = false + + const first = () => { + const item = items()[0] + if (!session.hasOlderMessages()) { + if (item) jump(item.key) + return + } + const sid = session.currentSessionID() + if (!sid || session.loadingOlderMessages()) return + setSeek({ sid, count: session.messages().length }) + if (!session.loadOlderMessages()) setSeek(undefined) + } + + // Loading the first prompt is deliberate and progressive: each completed + // prepend advances the existing page cursor, while hover/open remains free + // of network and full-history work. Stop if a request makes no progress so + // backend failures cannot turn into a retry loop. + createEffect(() => { + const loading = session.loadingOlderMessages() + const target = seek() + if (!target) { + paging = loading + return + } + if (target.sid !== session.currentSessionID()) { + paging = false + setSeek(undefined) + return + } + if (loading) { + paging = true + return + } + if (!paging) return + paging = false + const count = session.messages().length + const action = historyAction(target.count, count, session.hasOlderMessages()) + if (action === "stop") { + const item = items()[0] + setSeek(undefined) + if (item) jump(item.key) + return + } + if (action === "load") { + setSeek({ sid: target.sid, count }) + if (!session.loadOlderMessages()) setSeek(undefined) + return + } + const item = items()[0] + setSeek(undefined) + if (item) jump(item.key) + }) const trackActive = () => { - const list = shown() + const list = items() if (list.length === 0) return setActiveTurn(undefined) const handle = virtualizer() const offset = handle?.scrollOffset @@ -998,6 +1095,11 @@ export const MessageList: Component = (props) => { const row = partition().virtual[handle.findItemIndex(offset)] if (row) return setActiveTurn(row.turn) } + const el = scrollEl() + if (handle && el && el.scrollHeight > el.clientHeight + 1) { + const row = partition().virtual[0] + if (row) return setActiveTurn(row.turn) + } setActiveTurn(list.at(-1)?.turn) } let activeFrame: number | undefined @@ -1014,7 +1116,7 @@ export const MessageList: Component = (props) => { // Re-derive the active turn whenever the transcript changes so the rail // reflects a newly started turn even before any scrolling happens. createEffect(() => { - shown() + items() partition() scheduleActive() }) @@ -1263,14 +1365,25 @@ export const MessageList: Component = (props) => { railActiveKey()} onSelect={(item: PromptRailItem) => jump(item.key)} + onFirst={first} + onLatest={() => { + const item = items().at(-1) + if (item) jump(item.key) + }} + onLoadOlder={() => session.loadOlderMessages()} onWheel={(deltaY: number) => { const el = scrollEl() if (el) el.scrollTop += deltaY }} height={height} + hasOlder={session.hasOlderMessages} + loadingOlder={session.loadingOlderMessages} + prepending={() => session.messageMutation() === "prepend"} + seeking={() => Boolean(seek())} /> diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx index 555cc337a02..857e4fa634e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx @@ -2,50 +2,84 @@ /** * PromptRail component - * Thin vertical tick rail on the left edge of the transcript, one tick per - * user prompt. Hovering/focusing the rail opens a floating card listing the - * prompts with a short answer preview each; clicking jumps the transcript. + * Thin vertical summary rail on the left edge of the transcript. Hovering or + * focusing opens a bounded navigator for every loaded prompt; clicking jumps + * the virtualized transcript without mounting the intervening rows. */ +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Spinner } from "@kilocode/kilo-ui/spinner" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js" import { Portal } from "solid-js/web" +import { VList, type VListHandle } from "virtua/solid" import { useLanguage } from "../../context/language" -import { RAIL_INSET, ROW_HEIGHT, type PromptRailItem } from "./prompt-rail" +import { RAIL_INSET, ROW_HEIGHT, TICK_MIN, TICK_STEP, type PromptRailEntry, type PromptRailItem } from "./prompt-rail" interface PromptRailProps { + entries: Accessor items: Accessor /** Row key of the item whose turn is currently at the top of the transcript. */ active: Accessor onSelect: (item: PromptRailItem) => void + onFirst: () => void + onLatest: () => void + onLoadOlder: () => void /** Forwards wheel events so scrolling over a tick scrolls the transcript. */ onWheel: (deltaY: number) => void /** Transcript height, used to spread the ticks. */ height: Accessor + hasOlder: Accessor + loadingOlder: Accessor + prepending: Accessor + seeking: Accessor } const CLOSE_DELAY = 120 -const TICK_STEP = 14 const EDGE = 12 const GAP = 8 +const VIRTUAL_LIMIT = 30 +const CARD_CHROME = 44 +const NEAR_TOP = 200 export function PromptRail(props: PromptRailProps) { const language = useLanguage() const [open, setOpen] = createSignal(false) - const [hover, setHover] = createSignal() - const [anchor, setAnchor] = createSignal<{ top: number; left: number }>() + const [hover, setHover] = createSignal() + const [focused, setFocused] = createSignal() + const [anchor, setAnchor] = createSignal<{ top: number; left: number; height: number }>() let rail: HTMLElement | undefined let card: HTMLDivElement | undefined + let list: VListHandle | undefined let timer: ReturnType | undefined + let frame: number | undefined + let revealing = false const items = createMemo(() => props.items()) + const entries = createMemo(() => props.entries()) + const virtualized = createMemo(() => items().length > VIRTUAL_LIMIT) // Ticks are spread over the available height, tightening as prompts pile up - // but never growing past their natural step. + // but never growing past their natural step nor packing tighter than a tick + // can still be aimed at. const step = createMemo(() => { - const count = items().length + const count = entries().length if (count === 0) return TICK_STEP - return Math.min(TICK_STEP, Math.floor((props.height() - RAIL_INSET) / count)) + return Math.max(TICK_MIN, Math.min(TICK_STEP, Math.floor((props.height() - RAIL_INSET) / count))) }) + // Reaching the top of the navigator pages older history in, the same way the + // transcript itself loads earlier messages when scrolled near its top. Opening + // the card scrolls the hovered prompt into view, which would otherwise look + // like a scroll to the top and fetch on hover, so programmatic reveals are + // excluded and only scrolling the user drove pages. + const offset = () => (virtualized() ? (list?.scrollOffset ?? 0) : (card?.scrollTop ?? 0)) + + const page = (value: number) => { + if (revealing || value > NEAR_TOP) return + if (!props.hasOlder() || props.loadingOlder() || props.seeking()) return + props.onLoadOlder() + } + // Centers the card on the tick group so each row sits beside its own tick, // then keeps it inside the transcript and the viewport. The rail spans the // transcript exactly (top/bottom 0), so its own rect doubles as those bounds @@ -55,13 +89,17 @@ export function PromptRail(props: PromptRailProps) { const place = () => { if (!rail) return const rect = rail.getBoundingClientRect() - const height = card?.offsetHeight ?? Math.min(items().length * ROW_HEIGHT + EDGE, rect.height) + const limit = Math.max(0, Math.min(window.innerHeight - EDGE * 2, rect.height - 8)) + if (limit === 0) return + const estimate = Math.min(items().length * ROW_HEIGHT + CARD_CHROME, limit) + const height = virtualized() ? limit : (card?.offsetHeight ?? estimate) const min = Math.max(EDGE, rect.top + 4) const max = Math.min(window.innerHeight - EDGE, rect.bottom - 4) - height const center = rect.top + rect.height / 2 - height / 2 setAnchor({ top: max < min ? min : Math.min(Math.max(center, min), max), left: rect.right + GAP, + height: limit, }) } @@ -70,11 +108,35 @@ export function PromptRail(props: PromptRailProps) { timer = undefined } + const reveal = (index: number) => { + if (frame !== undefined) cancelAnimationFrame(frame) + revealing = true + frame = requestAnimationFrame(() => { + frame = undefined + if (virtualized()) { + list?.scrollToIndex(index, { align: "center" }) + return + } + const row = card?.querySelector(`[data-prompt-index="${index}"]`) + if (!row || !card) return + card.scrollTop = Math.max(0, row.offsetTop - card.clientHeight / 2 + row.offsetHeight / 2) + }) + } + + const entryItem = (entry: PromptRailEntry) => { + if (entry.type === "prompt") return entry.item + return items()[entry.type === "overflow" ? entry.index : 0] + } + const openCard = (index: number) => { cancelClose() - setHover(index) + const entry = entries()[index] + const item = entry && entryItem(entry) + setFocused(index) + setHover(item?.key) place() setOpen(true) + if (item) reveal(items().findIndex((candidate) => candidate.key === item.key)) } const closeCard = () => { @@ -86,6 +148,9 @@ export function PromptRail(props: PromptRailProps) { } onCleanup(cancelClose) + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + }) // Resizing the panel moves the rail out from under an open card. createEffect(() => { @@ -103,9 +168,20 @@ export function PromptRail(props: PromptRailProps) { onCleanup(() => cancelAnimationFrame(frame)) }) + let seeking = false + createEffect(() => { + const next = props.seeking() + if (seeking && !next && !props.hasOlder()) { + const item = items()[0] + setHover(item?.key) + if (item) reveal(0) + } + seeking = next + }) + const onKeyDown = (event: KeyboardEvent) => { - const list = items() - const current = hover() ?? 0 + const values = entries() + const current = focused() ?? 0 if (event.key === "Escape") { event.preventDefault() cancelClose() @@ -115,19 +191,22 @@ export function PromptRail(props: PromptRailProps) { } if (event.key === "Enter" || event.key === " ") { event.preventDefault() - const item = list[current] - if (item) props.onSelect(item) + const entry = values[current] + if (!entry) return + if (entry.type === "prompt") props.onSelect(entry.item) + if (entry.type === "history") props.onFirst() + if (entry.type === "overflow") openCard(current) return } const next = event.key === "ArrowDown" - ? Math.min(list.length - 1, current + 1) + ? Math.min(values.length - 1, current + 1) : event.key === "ArrowUp" ? Math.max(0, current - 1) : event.key === "Home" ? 0 : event.key === "End" - ? list.length - 1 + ? values.length - 1 : undefined if (next === undefined) return event.preventDefault() @@ -139,8 +218,58 @@ export function PromptRail(props: PromptRailProps) { const label = (item: PromptRailItem, index: number) => language.t("session.prompts.tick", { index: index + 1, total: items().length, prompt: item.prompt }) + const entryLabel = (entry: PromptRailEntry) => { + if (entry.type === "prompt") return label(entry.item, entry.index) + if (entry.type === "history") return language.t("session.prompts.first") + return language.t("session.prompts.overflow", { count: entry.count }) + } + + const entryActive = (entry: PromptRailEntry) => { + if (entry.type === "prompt") return entry.item.key === props.active() + if (entry.type === "history") return false + const index = items().findIndex((item) => item.key === props.active()) + return index >= entry.index && index < entry.index + entry.count + } + + const selectFirst = () => { + const item = items()[0] + setHover(item?.key) + if (item) reveal(0) + props.onFirst() + } + + const selectLatest = () => { + const index = items().length - 1 + const item = items()[index] + setHover(item?.key) + if (item) reveal(index) + props.onLatest() + } + + const row = (item: PromptRailItem, index: Accessor) => ( + + ) + return ( - = 2}> + = 2}>