Merge branch 'main' into feat/websearch-config-setting

This commit is contained in:
Joshua Lambert
2026-07-30 06:08:12 -04:00
committed by GitHub
83 changed files with 1889 additions and 331 deletions
@@ -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.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Navigate long conversations from a compact prompt rail that loads earlier history as you scroll.
@@ -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
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a7c800d169ca92674fc9f7c7d83032ca17e7d7937bdcbea5c72cb921a7fdb89e
size 51975
oid sha256:2a74c7f53a5d9afa743ee49d298ce8fb7e357c5fcc46cad45de353694a7540dc
size 49476
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:51adb9e31ce0bc82981b0f20f895ce4ede3f92828546115f929942ef93bf0812
size 11204
oid sha256:d7fc23fcb7adf483c0b771ef601b23cb365dc7f40033b00fce703b35910aa4fc
size 27159
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c5658ed9e5266311c4239b7cd470d2f772d1cc0b3fd6c6b00337d4f3141a3a4d
size 11950
oid sha256:d11f4004ed3170647d385c14df077d235f5bb9bd6e5dec07557ee2014d553233
size 27302
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:095ed97dc46498be6fd75483b24357ac0740b6f2c1544f6c103a1dac360e1a7e
size 11202
oid sha256:f8bd56ba87d0c2bbef8a2325ab2e9f0e956c89e2602ff8ca7415d5fb7c9fa1f3
size 29709
@@ -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<string>()
private toolRequests = new Set<string>()
@@ -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<string, unknown> | 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<void> {
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<string, unknown> | 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()
@@ -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" | "<sessionId>"
* 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"
}
@@ -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
@@ -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<void> | 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<WorktreeDiffEntry[]>
/** 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<WorktreeDiffEntry | null>
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<string, string>()
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<void> {
@@ -144,38 +147,38 @@ export class WorktreeDiffController {
}
}
public async revert(sessionId: string, file: string): Promise<void> {
public async revert(id: string, file: string): Promise<void> {
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<void> {
if (this.controller.currentId !== sessionId) {
await this.activate(sessionId, false, true)
public async request(id: string): Promise<void> {
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<void> {
public async requestFile(id: string, file: string): Promise<void> {
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<void> {
/**
* 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<void> {
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<void> {
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<void> {
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<DiffSourceFetch> {
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<DiffFile | null> {
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<Target | undefined> {
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<Target | undefined> {
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",
@@ -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<WorkspaceBranchesResult | undefined> {
const root = getWorkspaceRoot()
async listWorkspaceBranches(
override: string | undefined,
dir?: string,
): Promise<WorkspaceBranchesResult | undefined> {
const root = dir ?? getWorkspaceRoot()
if (!root) return undefined
const git = this.ensureBranchGit()
@@ -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<FileEntry[]> => {
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()
},
}
}
@@ -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<FileEntry[]> => {
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()
},
}
}
@@ -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<DiffTarget | undefined> => {
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
},
}
+21
View File
@@ -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"
@@ -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")
})
@@ -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")
})
})
@@ -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([])
})
})
@@ -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) => (
<DiffScopeControls
descriptors={review.descriptors()}
currentId={review.id()}
onSelectScope={review.select}
showBase={review.isBranch()}
branches={review.branches()}
branchesLoading={review.loading()}
defaultBranch={review.defaultBranch()}
autoBase={review.autoBase()}
currentBase={review.currentBase()}
isAuto={review.isAuto()}
currentBranch={review.currentBranch()}
onSelectBase={review.selectBase}
compact={compact}
/>
)
// 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")}
</Button>
</Tooltip>
<Tooltip value={t("agentManager.apply.tooltip")} placement="bottom">
<Tooltip
value={
review.scope() === "branch"
? t("agentManager.apply.tooltip")
: t("agentManager.diff.applyBranchOnly")
}
placement="bottom"
>
<Button
size="small"
variant="ghost"
onClick={openApplyDialog}
disabled={!hasChanges() || applyBusy()}
disabled={!hasChanges() || applyBusy() || review.scope() !== "branch"}
>
<Show when={applyBusy()}>
<Spinner class="am-apply-spinner" />
@@ -2782,6 +2813,8 @@ const AgentManagerContent: Component = () => {
loadingFiles={diffFileLoadingForCurrent()}
sessionId={currentDiffSessionId()}
sessionKey={diffSessionKey()}
lead={diffScopeControls(true)}
canRevert={scopeCapabilities(review.scope()).revert}
diffStyle={reviewDiffStyle()}
onDiffStyleChange={setSharedDiffStyle}
markdownRender={markdown.render()}
@@ -2829,6 +2862,9 @@ const AgentManagerContent: Component = () => {
loadingFiles={diffFileLoadingForCurrent()}
sessionId={currentDiffSessionId()}
sessionKey={diffSessionKey()}
lead={diffScopeControls(false)}
canRevert={scopeCapabilities(review.scope()).revert}
canComment={scopeCapabilities(review.scope()).comments}
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
composer={reviewComposer}
@@ -1,4 +1,4 @@
import { type Component, createSignal, createMemo, Show, createEffect, on } from "solid-js"
import { type Component, createSignal, createMemo, Show, createEffect, on, type JSXElement } from "solid-js"
import type { VirtualizerHandle } from "virtua/solid"
import { Diff } from "@kilocode/kilo-ui/diff"
import { Accordion } from "@kilocode/kilo-ui/accordion"
@@ -7,7 +7,6 @@ import { FileIcon } from "@kilocode/kilo-ui/file-icon"
import { DiffChanges } from "@kilocode/kilo-ui/diff-changes"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Button } from "@kilocode/kilo-ui/button"
import { RadioGroup } from "@kilocode/kilo-ui/radio-group"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
@@ -15,6 +14,7 @@ import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pie
import type { WorktreeFileDiff } from "../src/types/messages"
import { KILO_FILE_PATH_MIME } from "../src/utils/path-mentions"
import { useLanguage } from "../src/context/language"
import { DiffStyleSelect } from "../diff-viewer/InlineSelect"
import { useVSCode } from "../src/context/vscode"
import { useServer } from "../src/context/server"
import { useProvider } from "../src/context/provider"
@@ -86,6 +86,10 @@ interface DiffPanelProps {
onRevertFile?: (file: string) => void
revertingFiles?: Set<string>
activeTerminalId?: string
/** Optional leading row rendered under the header (e.g. the scope selector). */
lead?: JSXElement
/** Defaults to true. Hides the per-file Revert action when false. */
canRevert?: boolean
}
export const DiffPanel: Component<DiffPanelProps> = (props) => {
@@ -475,21 +479,18 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
<div class="am-diff-panel" onKeyDown={handleKeyDown} onMouseDown={handleRootMouseDown} tabIndex={-1} ref={rootRef}>
<div class="am-diff-header">
<div class="am-diff-header-main">
<span class="am-diff-header-title">{t("session.review.change.other")}</span>
{/* Scope + base picker replace the static "Changes" title: it names
what you're looking at and is the primary control. Always shown,
so an empty scope can still be switched away from. */}
<Show when={props.lead}>{props.lead}</Show>
<Show when={props.diffs.length > 0}>
<>
<RadioGroup
options={["unified", "split"] as const}
current={props.diffStyle ?? "unified"}
size="small"
value={(style) => style}
label={(style) =>
style === "unified" ? t("ui.sessionReview.diffStyle.unified") : t("ui.sessionReview.diffStyle.split")
}
onSelect={(style) => {
if (!style) return
props.onDiffStyleChange?.(style)
}}
<DiffStyleSelect
value={props.diffStyle ?? "unified"}
onSelect={(style) => props.onDiffStyleChange?.(style)}
unifiedLabel={t("ui.sessionReview.diffStyle.unified")}
splitLabel={t("ui.sessionReview.diffStyle.split")}
title={t("ui.sessionReview.diffStyle.unified")}
/>
<span class="am-diff-header-stats">
<span>{t("session.review.filesChanged", { count: totals().files })}</span>
@@ -636,7 +637,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
/>
</Tooltip>
</Show>
<Show when={props.onRevertFile}>
<Show when={props.onRevertFile && props.canRevert !== false}>
<Tooltip value={t("agentManager.diff.revertFile")} placement="top">
<IconButton
icon="discard"
@@ -43,12 +43,20 @@
.am-review-toolbar-left {
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
flex: 1;
min-width: 0;
overflow: hidden;
}
/* Keep the radio group from being the tallest thing in the row so it matches
the 22px selector chips and the small ghost buttons. The inline scope/base
controls are styled in banners.css (non-am- prefixed, shared with the
standalone diff viewer). */
.am-review-toolbar [data-component="radio-group"] {
font-size: var(--font-size-small);
}
.am-review-toolbar-right {
display: flex;
align-items: center;
@@ -77,7 +85,7 @@
.am-review-toolbar-stats {
display: flex;
align-items: center;
flex: 1 1 auto;
flex: 0 100 auto;
gap: 8px;
font-size: var(--font-size-small);
color: var(--text-weak);
@@ -1717,35 +1717,58 @@ body.am-wt-dragging-active * {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 4px 4px 12px;
gap: 6px;
padding: 4px 4px 4px 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weak-base);
position: relative;
z-index: 20;
background: var(--surface-base);
}
.am-diff-header-title {
font-size: var(--font-size-small);
font-weight: 500;
color: var(--text-weak);
/* Query container for the narrow-panel rules below and in banners.css. The
panel width is user-draggable, so the header adapts to its own width
rather than the viewport. */
container-type: inline-size;
container-name: am-diff-header;
}
.am-diff-header-main {
display: flex;
align-items: center;
gap: 10px;
gap: 6px;
flex: 1;
min-width: 0;
overflow: hidden;
}
.am-diff-header-stats {
display: flex;
align-items: center;
gap: 8px;
flex: 0 1 auto;
min-width: 0;
font-size: var(--font-size-small);
color: var(--text-weak);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Progressive disclosure, least important first. Thresholds are derived from the
measured control widths: scope 76px, base <=191px, diff style 76px, 6px gaps,
plus ~90px of action buttons and 12px padding. Each breakpoint fires while the
remaining set still fits, so nothing is ever clipped under the buttons.
The collapsed-files hint goes before the totals; both are also shown on the
worktree row and the diff toggle button, so no information is lost. */
@container am-diff-header (max-width: 560px) {
.am-diff-header-collapsed {
display: none;
}
}
@container am-diff-header (max-width: 520px) {
.am-diff-header-stats {
display: none;
}
}
.am-diff-header-adds {
@@ -0,0 +1,118 @@
/**
* Diff scope + base branch state for the Agent Manager review surfaces.
*
* Owns the per-context scope selection, the branch picker data for the active
* context, and the message senders that drive both. Extracted from
* AgentManagerApp to keep that file under its line cap; both the side panel
* and the full-screen review tab consume the single instance returned here.
*/
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import type { BranchInfo } from "../src/types/messages"
import { createDiffScope, isDiffScope, scopeDescriptors, type DiffScope } from "./diff-scope-state"
interface VsCode {
postMessage(msg: unknown): void
}
export interface DiffReviewScopeOptions {
/** Current diff context (worktree session id or the LOCAL pseudo-id). */
ctx: Accessor<string | undefined>
/** Whether the diff side panel is open. */
panelOpen: Accessor<boolean>
/** Whether the full-screen review tab is active. */
reviewActive: Accessor<boolean>
/** The id that marks the local pseudo-context (omits the Session scope). */
local: string
vscode: VsCode
}
export function createDiffReviewScope(opts: DiffReviewScopeOptions) {
const scope = createDiffScope(opts.ctx)
// The composite id (ctx#scope) the extension keys diff data by.
const id = createMemo(() => scope.id())
// Branch picker state for the active context (Branch scope only).
const [branches, setBranches] = createSignal<BranchInfo[]>([])
const [loading, setLoading] = createSignal(false)
const [defaultBranch, setDefaultBranch] = createSignal("")
const [autoBase, setAutoBase] = createSignal<string | undefined>(undefined)
const [currentBase, setCurrentBase] = createSignal<string | undefined>(undefined)
const [isAuto, setIsAuto] = createSignal(true)
const [currentBranch, setCurrentBranch] = createSignal<string | undefined>(undefined)
// Scope descriptors for the current context. The `local` pseudo-context and
// contexts without a real session omit the Session scope.
const descriptors = createMemo(() => {
const ctx = opts.ctx()
if (!ctx) return []
return scopeDescriptors(ctx, ctx !== opts.local)
})
const isBranch = () => scope.scope() === "branch"
const select = (next: string) => {
const ctx = opts.ctx()
if (!ctx) return
const value = next.slice(ctx.length + 1)
scope.setScope(isDiffScope(value) ? value : "branch")
}
const selectBase = (branch: string | undefined) => {
const ctx = opts.ctx()
if (!ctx) return
// Optimistic update; the extension echoes authoritative state back.
setCurrentBase(branch ?? autoBase())
setIsAuto(branch === undefined)
opts.vscode.postMessage({ type: "agentManager.setDiffBaseBranch", sessionId: ctx, scope: scope.scope(), branch })
}
// Fetch branch picker data whenever the Branch scope becomes active for the
// current context. The extension owns override state, so ask each time.
createEffect(() => {
if (scope.scope() !== "branch") return
const ctx = opts.ctx()
if (!ctx) return
if (!opts.panelOpen() && !opts.reviewActive()) return
setLoading(true)
opts.vscode.postMessage({ type: "agentManager.requestDiffBranches", sessionId: ctx, scope: scope.scope() })
})
/** Handle the extension's diffBranches push, ignoring stale contexts. */
const onBranches = (ev: {
sessionId: string
branches: BranchInfo[]
defaultBranch: string
autoBase?: string
currentBase?: string
isAuto: boolean
currentBranch?: string
}) => {
if (ev.sessionId === id()) {
setBranches(ev.branches)
setDefaultBranch(ev.defaultBranch)
setAutoBase(ev.autoBase)
setCurrentBase(ev.currentBase)
setIsAuto(ev.isAuto)
setCurrentBranch(ev.currentBranch)
}
setLoading(false)
}
return {
scope: scope.scope,
id,
descriptors,
isBranch,
select,
selectBase,
onBranches,
branches,
loading,
defaultBranch,
autoBase,
currentBase,
isAuto,
currentBranch,
}
}
@@ -0,0 +1,103 @@
/**
* Webview-side diff scope state for Agent Manager.
*
* Mirrors the extension's composite diff id (`ctx#scope`, see
* `src/agent-manager/diff-scope.ts`) and builds the fixed scope descriptor
* list shown in the scope selector. Agent Manager always offers the same four
* scopes per context, so the descriptors are computed client-side rather than
* pushed from the extension.
*/
import { createMemo, createSignal, type Accessor } from "solid-js"
import type { DiffSourceDescriptor } from "../../src/diff/sources/types"
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}`
}
export function parseDiffId(id: string): { ctx: string; scope: DiffScope } {
const idx = id.lastIndexOf(SEP)
const scope = id.slice(idx + SEP.length)
if (idx !== -1 && 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"
}
/**
* The fixed scope descriptors for a context. `workspace` maps to the Branch
* scope to reuse the existing i18n keys (`diffViewer.source.workspace.*`).
* Session scope is only meaningful for a real session context, so it is
* omitted for the `local` pseudo-context and for contexts without a session.
*/
export function scopeDescriptors(ctx: string, hasSession: boolean): DiffSourceDescriptor[] {
const out: DiffSourceDescriptor[] = [
{
id: composeDiffId(ctx, "branch"),
type: "workspace",
group: "Git",
capabilities: { revert: true, comments: true },
},
{ id: composeDiffId(ctx, "staged"), type: "staged", group: "Git", capabilities: { revert: false, comments: true } },
{
id: composeDiffId(ctx, "unstaged"),
type: "unstaged",
group: "Git",
capabilities: { revert: false, comments: true },
},
]
if (hasSession) {
out.push({
id: composeDiffId(ctx, "session"),
type: "session",
group: "Session",
capabilities: { revert: false, comments: true },
})
}
return out
}
/**
* Whether the Branch scope supports revert. Staged/unstaged/session are
* read-only; only the Branch scope can revert files back to the merge base.
*/
export function scopeCapabilities(scope: DiffScope): { revert: boolean; comments: boolean } {
return { revert: scope === "branch", comments: true }
}
/**
* Per-context scope selection. Keeps the last-picked scope per context id so
* switching between worktrees restores each worktree's scope, while a brand
* new context defaults to Branch.
*/
export function createDiffScope(currentCtx: Accessor<string | undefined>) {
const [scopes, setScopes] = createSignal<Record<string, DiffScope>>({})
const scope = createMemo((): DiffScope => {
const ctx = currentCtx()
if (!ctx) return DEFAULT_DIFF_SCOPE
return scopes()[ctx] ?? DEFAULT_DIFF_SCOPE
})
const id = createMemo(() => {
const ctx = currentCtx()
if (!ctx) return undefined
return composeDiffId(ctx, scope())
})
const setScope = (next: DiffScope) => {
const ctx = currentCtx()
if (!ctx) return
setScopes((prev) => ({ ...prev, [ctx]: next }))
}
return { scope, id, setScope }
}
@@ -136,6 +136,8 @@ export const dict = {
"agentManager.diff.revertFile": "استعادة الملف",
"agentManager.diff.revertSuccess": "تم استعادة الملف",
"agentManager.diff.revertError": "فشل الاستعادة",
"agentManager.diff.applyBranchOnly":
"لا يعمل تطبيق التغييرات إلا على فرق الفرع الكامل. انتقل إلى نطاق Branch لتطبيقها.",
"agentManager.open.button": "فتح",
"agentManager.open.tooltip": "فتح Worktree هذا في VS Code",
"agentManager.apply.globalButton": "تطبيق",
@@ -139,6 +139,8 @@ export const dict = {
"agentManager.diff.revertFile": "Reverter arquivo",
"agentManager.diff.revertSuccess": "Arquivo revertido",
"agentManager.diff.revertError": "Falha ao reverter",
"agentManager.diff.applyBranchOnly":
"Aplicar funciona apenas no diff completo da branch. Mude para o escopo Branch para aplicar.",
"agentManager.open.button": "Abrir",
"agentManager.open.tooltip": "Abrir este Worktree no VS Code",
"agentManager.apply.globalButton": "Aplicar",
@@ -139,6 +139,8 @@ export const dict = {
"agentManager.diff.revertFile": "Vrati datoteku",
"agentManager.diff.revertSuccess": "Datoteka vraćena",
"agentManager.diff.revertError": "Vraćanje neuspješno",
"agentManager.diff.applyBranchOnly":
"Primijeni radi samo s kompletnim diffom grane. Prebacite se na opseg Branch da biste primijenili.",
"agentManager.open.button": "Otvori",
"agentManager.open.tooltip": "Otvori ovaj worktree u VS Code-u",
"agentManager.apply.globalButton": "Primijeni",
@@ -140,6 +140,8 @@ export const dict = {
"agentManager.diff.revertFile": "Gendan fil",
"agentManager.diff.revertSuccess": "Fil gendannet",
"agentManager.diff.revertError": "Gendannelse fejlede",
"agentManager.diff.applyBranchOnly":
"Anvend virker kun på hele Branch-diffen. Skift til Branch-området for at anvende.",
"agentManager.open.button": "Åbn",
"agentManager.open.tooltip": "Åbn dette Worktree i VS Code",
"agentManager.apply.globalButton": "Anvend",
@@ -140,6 +140,8 @@ export const dict = {
"agentManager.diff.revertFile": "Datei zurücksetzen",
"agentManager.diff.revertSuccess": "Datei zurückgesetzt",
"agentManager.diff.revertError": "Zurücksetzen fehlgeschlagen",
"agentManager.diff.applyBranchOnly":
"Anwenden funktioniert nur für den vollständigen Branch-Diff. Wechsle zum Bereich Branch, um anzuwenden.",
"agentManager.open.button": "Öffnen",
"agentManager.open.tooltip": "Dieses Worktree in VS Code öffnen",
"agentManager.apply.globalButton": "Anwenden",
@@ -143,6 +143,7 @@ export const dict = {
"agentManager.diff.revertFile": "Revert file",
"agentManager.diff.revertSuccess": "File reverted",
"agentManager.diff.revertError": "Revert failed",
"agentManager.diff.applyBranchOnly": "Apply works on the full branch diff. Switch to the Branch scope to apply.",
"agentManager.open.button": "Open",
"agentManager.open.tooltip": "Open this worktree in VS Code",
"agentManager.apply.globalButton": "Apply",
@@ -139,6 +139,8 @@ export const dict = {
"agentManager.diff.revertFile": "Revertir archivo",
"agentManager.diff.revertSuccess": "Archivo revertido",
"agentManager.diff.revertError": "Error al revertir",
"agentManager.diff.applyBranchOnly":
"Aplicar solo funciona con el diff completo de la rama. Cambia al ámbito Branch para aplicar.",
"agentManager.open.button": "Abrir",
"agentManager.open.tooltip": "Abrir este Worktree en VS Code",
"agentManager.apply.globalButton": "Aplicar",
@@ -143,6 +143,8 @@ export const dict = {
"agentManager.diff.revertFile": "بازگردانی فایل",
"agentManager.diff.revertSuccess": "فایل بازگردانی شد",
"agentManager.diff.revertError": "بازگردانی ناموفق بود",
"agentManager.diff.applyBranchOnly":
"اعمال تغییرات روی اختلاف کامل شاخه انجام می‌شود. برای اعمال، به محدوده Branch بروید.",
"agentManager.open.button": "باز کردن",
"agentManager.open.tooltip": "باز کردن این Worktree در VS Code",
"agentManager.apply.globalButton": "اعمال",
@@ -139,6 +139,8 @@ export const dict = {
"agentManager.diff.revertFile": "Rétablir le fichier",
"agentManager.diff.revertSuccess": "Fichier rétabli",
"agentManager.diff.revertError": "Échec du rétablissement",
"agentManager.diff.applyBranchOnly":
"Appliquer ne fonctionne que sur le diff complet de la branche. Passez à la portée Branch pour appliquer.",
"agentManager.open.button": "Ouvrir",
"agentManager.open.tooltip": "Ouvrir ce worktree dans VS Code",
"agentManager.apply.globalButton": "Appliquer",
@@ -145,6 +145,8 @@ export const dict = {
"agentManager.diff.revertFile": "Ripristina file",
"agentManager.diff.revertSuccess": "File ripristinato",
"agentManager.diff.revertError": "Ripristino non riuscito",
"agentManager.diff.applyBranchOnly":
"Applica funziona solo sul diff completo del branch. Passa all'ambito Branch per applicare.",
"agentManager.open.button": "Apri",
"agentManager.open.tooltip": "Apri questo worktree in VS Code",
"agentManager.apply.globalButton": "Applica",
@@ -138,6 +138,8 @@ export const dict = {
"agentManager.diff.revertFile": "ファイルを元に戻す",
"agentManager.diff.revertSuccess": "ファイルを元に戻しました",
"agentManager.diff.revertError": "元に戻せませんでした",
"agentManager.diff.applyBranchOnly":
"適用はブランチ全体の差分に対してのみ利用できます。適用するにはスコープを Branch に切り替えてください。",
"agentManager.open.button": "開く",
"agentManager.open.tooltip": "このWorktreeをVS Codeで開く",
"agentManager.apply.globalButton": "適用",
@@ -137,6 +137,8 @@ export const dict = {
"agentManager.diff.revertFile": "파일 되돌리기",
"agentManager.diff.revertSuccess": "파일이 되돌려졌습니다",
"agentManager.diff.revertError": "되돌리기 실패",
"agentManager.diff.applyBranchOnly":
"적용은 전체 브랜치 diff에서만 작동합니다. 적용하려면 범위를 Branch로 전환하세요.",
"agentManager.open.button": "열기",
"agentManager.open.tooltip": "이 Worktree를 VS Code에서 열기",
"agentManager.apply.globalButton": "적용",
@@ -144,6 +144,8 @@ export const dict = {
"agentManager.diff.revertFile": "Bestand terugzetten",
"agentManager.diff.revertSuccess": "Bestand teruggezet",
"agentManager.diff.revertError": "Terugzetten mislukt",
"agentManager.diff.applyBranchOnly":
"Toepassen werkt alleen op de volledige branch-diff. Schakel naar het bereik Branch om toe te passen.",
"agentManager.open.button": "Openen",
"agentManager.open.tooltip": "Open deze worktree in VS Code",
"agentManager.apply.globalButton": "Toepassen",
@@ -138,6 +138,7 @@ export const dict = {
"agentManager.diff.revertFile": "Tilbakestill fil",
"agentManager.diff.revertSuccess": "Fil tilbakestilt",
"agentManager.diff.revertError": "Tilbakestilling feilet",
"agentManager.diff.applyBranchOnly": "Bruk fungerer kun på hele Branch-diffen. Bytt til Branch-omfanget for å bruke.",
"agentManager.open.button": "Åpne",
"agentManager.open.tooltip": "Åpne dette Worktree-et i VS Code",
"agentManager.apply.globalButton": "Bruk",
@@ -138,6 +138,8 @@ export const dict = {
"agentManager.diff.revertFile": "Cofnij plik",
"agentManager.diff.revertSuccess": "Plik cofnięty",
"agentManager.diff.revertError": "Cofanie nie powiodło się",
"agentManager.diff.applyBranchOnly":
"Funkcja Zastosuj działa tylko z pełnym diffem brancha. Przełącz się na zakres Branch, aby zastosować.",
"agentManager.open.button": "Otwórz",
"agentManager.open.tooltip": "Otwórz ten Worktree w VS Code",
"agentManager.apply.globalButton": "Zastosuj",
@@ -139,6 +139,8 @@ export const dict = {
"agentManager.diff.revertFile": "Откатить файл",
"agentManager.diff.revertSuccess": "Файл откатан",
"agentManager.diff.revertError": "Ошибка отката",
"agentManager.diff.applyBranchOnly":
"Применение работает только с полным diff ветки. Чтобы применить изменения, переключитесь на область Branch.",
"agentManager.open.button": "Открыть",
"agentManager.open.tooltip": "Открыть этот Worktree в VS Code",
"agentManager.apply.globalButton": "Применить",
@@ -134,6 +134,8 @@ export const dict = {
"agentManager.diff.revertFile": "ย้อนกลับไฟล์",
"agentManager.diff.revertSuccess": "ย้อนกลับไฟล์แล้ว",
"agentManager.diff.revertError": "ย้อนกลับล้มเหลว",
"agentManager.diff.applyBranchOnly":
"นำไปใช้ได้เฉพาะกับ diff ของ Branch ทั้งหมดเท่านั้น สลับไปที่ขอบเขต Branch เพื่อใช้งาน",
"agentManager.open.button": "เปิด",
"agentManager.open.tooltip": "เปิด Worktree นี้ใน VS Code",
"agentManager.apply.globalButton": "นำไปใช้",
@@ -145,6 +145,8 @@ export const dict = {
"agentManager.diff.revertFile": "Dosyayı geri al",
"agentManager.diff.revertSuccess": "Dosya geri alındı",
"agentManager.diff.revertError": "Geri alma başarısız",
"agentManager.diff.applyBranchOnly":
"Uygula yalnızca tam Branch diff'inde çalışır. Uygulamak için Branch kapsamına geçin.",
"agentManager.open.button": "Aç",
"agentManager.open.tooltip": "Bu worktree'yi VS Code'da aç",
"agentManager.apply.globalButton": "Uygula",
@@ -146,6 +146,8 @@ export const dict = {
"agentManager.diff.revertFile": "Скасувати зміни файлу",
"agentManager.diff.revertSuccess": "Файл відновлено",
"agentManager.diff.revertError": "Не вдалося відновити",
"agentManager.diff.applyBranchOnly":
"Застосування працює лише з повним diff гілки. Щоб застосувати зміни, перемкніться на область Branch.",
"agentManager.open.button": "Відкрити",
"agentManager.open.tooltip": "Відкрити це робоче дерево у VS Code",
"agentManager.apply.globalButton": "Застосувати",
@@ -133,6 +133,7 @@ export const dict = {
"agentManager.diff.revertFile": "还原文件",
"agentManager.diff.revertSuccess": "文件已还原",
"agentManager.diff.revertError": "还原失败",
"agentManager.diff.applyBranchOnly": "应用仅适用于完整的分支差异。请切换到 Branch 范围后再应用。",
"agentManager.open.button": "打开",
"agentManager.open.tooltip": "在 VS Code 中打开此 Worktree",
"agentManager.apply.globalButton": "应用",
@@ -133,6 +133,7 @@ export const dict = {
"agentManager.diff.revertFile": "還原檔案",
"agentManager.diff.revertSuccess": "檔案已還原",
"agentManager.diff.revertError": "還原失敗",
"agentManager.diff.applyBranchOnly": "套用僅適用於完整的分支差異。請切換至 Branch 範圍後再套用。",
"agentManager.open.button": "開啟",
"agentManager.open.tooltip": "在 VS Code 中開啟此 Worktree",
"agentManager.apply.globalButton": "套用",
@@ -12,7 +12,9 @@ interface Toast {
}
export function createRevertFile(
diffScopeId: Accessor<string | undefined>,
currentDiffSessionId: Accessor<string | undefined>,
scope: Accessor<string>,
vscode: VsCode,
showToast: (t: Toast) => void,
t: (key: string) => string,
@@ -20,20 +22,21 @@ export function createRevertFile(
const [files, setFiles] = createSignal<Record<string, Set<string>>>({})
const reverting = createMemo(() => {
const sessionId = currentDiffSessionId()
if (!sessionId) return new Set<string>()
return files()[sessionId] ?? new Set<string>()
const id = diffScopeId()
if (!id) return new Set<string>()
return files()[id] ?? new Set<string>()
})
function revert(file: string) {
const id = diffScopeId()
const sessionId = currentDiffSessionId()
if (!sessionId) return
if (!id || !sessionId) return
setFiles((prev) => {
const set = new Set(prev[sessionId] ?? [])
const set = new Set(prev[id] ?? [])
set.add(file)
return { ...prev, [sessionId]: set }
return { ...prev, [id]: set }
})
vscode.postMessage({ type: "agentManager.revertWorktreeFile", sessionId, file })
vscode.postMessage({ type: "agentManager.revertWorktreeFile", sessionId, file, scope: scope() })
}
function onResult(ev: AgentManagerRevertWorktreeFileResultMessage) {
@@ -9,6 +9,7 @@
import { createSignal, type Accessor } from "solid-js"
import { mergeWorktreeDiffs } from "../diff-viewer/diff-state"
import { parseDiffId } from "./diff-scope-state"
import type { useVSCode } from "../src/context/vscode"
import type {
AgentManagerWorktreeDiffFileMessage,
@@ -17,6 +18,16 @@ import type {
WorktreeFileDiff,
} from "../src/types/messages"
/**
* Decompose a composite diff id (`ctx#scope`) into the wire fields the
* extension expects. Bare ids (no scope separator) parse to the default
* branch scope.
*/
function wire(id: string) {
const { ctx, scope } = parseDiffId(id)
return { sessionId: ctx, scope }
}
export function createWorktreeDiffs(vscode: ReturnType<typeof useVSCode>) {
const [diffDatas, setDiffDatas] = createSignal<Record<string, WorktreeFileDiff[]>>({})
const [diffLoading, setDiffLoading] = createSignal(false)
@@ -48,20 +59,20 @@ export function createWorktreeDiffs(vscode: ReturnType<typeof useVSCode>) {
})
}
/** Lazily load a single file's full diff for the current session. */
const requestDiffFile = (sessionId: string, file: string) => {
if (diffFileLoading()[sessionId]?.[file]) return
setDiffFilePending(sessionId, file, true)
vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file })
/** Lazily load a single file's full diff for the given composite diff id. */
const requestDiffFile = (id: string, file: string) => {
if (diffFileLoading()[id]?.[file]) return
setDiffFilePending(id, file, true)
vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", file, ...wire(id) })
}
/** Files the backend flagged as stale in a merged update need a fresh fetch. */
const refreshStaleDiffs = (sessionId: string, files: Set<string>) => {
const loading = diffFileLoading()[sessionId] ?? {}
const refreshStaleDiffs = (id: string, files: Set<string>) => {
const loading = diffFileLoading()[id] ?? {}
for (const file of files) {
if (loading[file]) continue
setDiffFilePending(sessionId, file, true)
vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file })
setDiffFilePending(id, file, true)
vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", file, ...wire(id) })
}
}
@@ -96,7 +96,7 @@ export const BaseBranchPicker: Component<BaseBranchPickerProps> = (props) => {
placement="bottom-start"
flip
trigger={
<button class="am-selector-trigger diff-base-trigger" type="button">
<button class="am-selector-trigger diff-inline-trigger" type="button" title={triggerLabel()}>
<span class="am-selector-left">
<Show when={!props.currentBranch}>
<Icon name="branch" size="small" />
@@ -0,0 +1,80 @@
import { Show, type Component } from "solid-js"
import type { DiffSourceDescriptor } from "../../src/diff/sources/types"
import type { BranchInfo } from "../src/types/messages"
import { useLanguage } from "../src/context/language"
import { InlineSelect, type InlineOption } from "./InlineSelect"
import { BaseBranchPicker } from "./BaseBranchPicker"
interface DiffScopeControlsProps {
descriptors: DiffSourceDescriptor[]
currentId: string | undefined
onSelectScope: (id: string) => void
/** Show the base branch picker (only when the Branch scope is active). */
showBase: boolean
branches: BranchInfo[]
branchesLoading: boolean
defaultBranch: string
autoBase: string | undefined
currentBase: string | undefined
isAuto: boolean
currentBranch: string | undefined
onSelectBase: (branch: string | undefined) => void
/**
* Compact mode for the narrow side panel: drops the `current →` prefix and
* tightens the label caps so the row survives a user-shrunk panel.
*/
compact?: boolean
}
/**
* Scope selector plus base branch picker, sized to sit inline in a diff
* toolbar row. Shared by the Agent Manager side panel and review tab.
*
* Note this deliberately avoids `DiffPickerHeader`: that component is a
* full-width header band (`margin: 8px 12px`) and using it inside a toolbar
* inflates the row height and misaligns against the neighboring buttons.
*/
export const DiffScopeControls: Component<DiffScopeControlsProps> = (props) => {
const { t } = useLanguage()
const options = (): InlineOption<string>[] =>
props.descriptors.map((desc) => ({
value: desc.id,
label: t(`diffViewer.source.${desc.type}.label`),
group: t(desc.group === "Session" ? "diffViewer.group.session" : "diffViewer.group.git"),
}))
// The trigger's tooltip explains what the active scope actually shows,
// reusing the per-scope descriptions the standalone picker already has.
const title = () => {
const active = props.descriptors.find((desc) => desc.id === props.currentId)
if (!active) return ""
return t(`diffViewer.source.${active.type}.tooltip`)
}
return (
<span class="diff-scope-controls" classList={{ "diff-scope-controls-compact": props.compact }}>
<Show when={props.descriptors.length > 0}>
<InlineSelect
options={options()}
value={props.currentId}
onSelect={props.onSelectScope}
title={title()}
compact={props.compact}
/>
</Show>
<Show when={props.showBase}>
<BaseBranchPicker
branches={props.branches}
loading={props.branchesLoading}
defaultBranch={props.defaultBranch}
autoBase={props.autoBase}
currentBase={props.currentBase}
isAuto={props.isAuto}
currentBranch={props.compact ? undefined : props.currentBranch}
onSelect={props.onSelectBase}
/>
</Show>
</span>
)
}
@@ -1,4 +1,4 @@
import { type Component, createSignal, createMemo, createEffect, on, onCleanup, Show } from "solid-js"
import { type Component, createSignal, createMemo, createEffect, on, onCleanup, Show, type JSXElement } from "solid-js"
import type { VirtualizerHandle } from "virtua/solid"
// Styles are imported by the component so every consumer (sidebar diff viewer,
// agent manager, storybook) picks them up automatically. Keep these imports here —
@@ -89,6 +89,8 @@ interface FullScreenDiffViewProps {
canRevert?: boolean
/** Defaults to true. Disables comment creation and "Send all" when false. */
canComment?: boolean
/** Optional leading content rendered first in the toolbar's left group. */
lead?: JSXElement
onClose: () => void
}
@@ -541,6 +543,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
{/* Toolbar */}
<div class="am-review-toolbar">
<div class="am-review-toolbar-left">
<Show when={props.lead}>{props.lead}</Show>
<RadioGroup
options={["unified", "split"] as const}
current={props.diffStyle}
@@ -0,0 +1,127 @@
import { type Component, For, Show, createSignal } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import { DeferredPopover } from "../src/components/shared/DeferredPopover"
export interface InlineOption<T extends string> {
value: T
label: string
/** Secondary text shown right-aligned in the menu row. */
hint?: string
/** Optional group heading; consecutive options sharing a group are grouped. */
group?: string
}
interface InlineSelectProps<T extends string> {
options: InlineOption<T>[]
value: T | undefined
onSelect: (value: T) => void
/** Trigger icon. Omit to keep the trigger as narrow as possible. */
icon?: string
/** Accessible name / tooltip text for the trigger. */
title: string
/** Caps the trigger label width so long values ellipsize instead of pushing. */
compact?: boolean
/** Extra class on the trigger, so narrow-width rules can target one control. */
class?: string
}
/**
* Compact dropdown sized for a diff toolbar row.
*
* Deliberately not kilo-ui's `Select`: that renders an input-sized control
* (32px, base font) which dwarfs the ghost buttons and radio group it sits
* next to. This mirrors the `am-selector-trigger` markup the branch pickers
* use, shrunk via `.diff-inline-trigger`, so every control in the row shares
* one height and font size.
*/
export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
const [open, setOpen] = createSignal(false)
const current = () => props.options.find((opt) => opt.value === props.value)
const label = () => current()?.label ?? ""
const choose = (value: T) => {
props.onSelect(value)
setOpen(false)
}
// Group heading renders only when it differs from the previous option's, so
// callers just order their options by group.
const heading = (index: number) => {
const group = props.options[index]?.group
if (!group) return undefined
if (index === 0) return group
return props.options[index - 1]?.group === group ? undefined : group
}
return (
<DeferredPopover
open={open()}
onOpenChange={setOpen}
placement="bottom-start"
flip
portal={false}
deferDismiss
class="am-dropdown diff-inline-menu"
trigger={
<button
class={`am-selector-trigger diff-inline-trigger${props.class ? ` ${props.class}` : ""}`}
type="button"
title={props.title}
>
<span class="am-selector-left">
<Show when={props.icon}>{(name) => <Icon name={name()} size="small" />}</Show>
<span class="am-selector-value">{label()}</span>
</span>
<span class="am-selector-right">
<Icon name="selector" size="small" />
</span>
</button>
}
>
<div class="am-dropdown-list">
<For each={props.options}>
{(opt, index) => (
<>
<Show when={heading(index())}>{(text) => <div class="diff-inline-group">{text()}</div>}</Show>
<button
class="am-branch-item"
classList={{ "am-branch-item-active": opt.value === props.value }}
type="button"
onClick={() => choose(opt.value)}
>
<span class="am-branch-item-left">
<span class="am-branch-item-name">{opt.label}</span>
</span>
<Show when={opt.hint}>
<span class="am-branch-hint">{opt.hint}</span>
</Show>
</button>
</>
)}
</For>
</div>
</DeferredPopover>
)
}
/** 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) => (
<InlineSelect
options={[
{ value: "unified", label: props.unifiedLabel },
{ value: "split", label: props.splitLabel },
]}
value={props.value}
onSelect={props.onSelect}
title={props.title}
class="diff-style-select"
compact
/>
)
@@ -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<MessageListProps> = (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<MessageListProps> = (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<HTMLElement>(`[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<HTMLElement>(`[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<MessageListProps> = (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<string>()
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<MessageListProps> = (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<MessageListProps> = (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<MessageListProps> = (props) => {
</div>
<PromptRail
items={shown}
entries={entries}
items={items}
active={() => 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())}
/>
<Show when={autoScroll.userScrolled()}>
@@ -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<PromptRailEntry[]>
items: Accessor<PromptRailItem[]>
/** Row key of the item whose turn is currently at the top of the transcript. */
active: Accessor<string | undefined>
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<number>
hasOlder: Accessor<boolean>
loadingOlder: Accessor<boolean>
prepending: Accessor<boolean>
seeking: Accessor<boolean>
}
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<number>()
const [anchor, setAnchor] = createSignal<{ top: number; left: number }>()
const [hover, setHover] = createSignal<string>()
const [focused, setFocused] = createSignal<number>()
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<typeof setTimeout> | 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<HTMLElement>(`[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<number>) => (
<button
type="button"
class="prompt-rail-row"
classList={{ "prompt-rail-row--hover": item.key === hover() }}
data-prompt-index={index()}
aria-label={label(item, index())}
onMouseEnter={() => setHover(item.key)}
onClick={() => props.onSelect(item)}
>
<span class="prompt-rail-row-prompt" data-queued={item.queued || undefined}>
<Show when={item.queued}>
<span class="prompt-rail-row-status">{language.t("session.prompts.queued")} · </span>
</Show>
{item.prompt}
</span>
<Show when={item.answer || !item.prompt}>
<span class="prompt-rail-row-answer">{item.answer || language.t("session.prompts.noAnswer")}</span>
</Show>
</button>
)
return (
<Show when={items().length >= 2}>
<Show when={entries().length >= 2}>
<nav
ref={rail}
class="prompt-rail"
@@ -157,21 +286,26 @@ export function PromptRail(props: PromptRailProps) {
props.onWheel(event.deltaY)
}}
>
<For each={items()}>
{(item, index) => (
<For each={entries()}>
{(entry, index) => (
<button
type="button"
class="prompt-rail-tick"
classList={{
"prompt-rail-tick--active": item.key === props.active(),
"prompt-rail-tick--open": open() && index() === hover(),
"prompt-rail-tick--active": entryActive(entry),
"prompt-rail-tick--open": open() && index() === focused(),
"prompt-rail-tick--overflow": entry.type !== "prompt",
}}
data-queued={item.queued || undefined}
aria-label={label(item, index())}
tabIndex={index() === (hover() ?? 0) ? 0 : -1}
data-queued={(entry.type === "prompt" && entry.item.queued) || undefined}
aria-label={entryLabel(entry)}
tabIndex={index() === (focused() ?? 0) ? 0 : -1}
onMouseEnter={() => openCard(index())}
onFocus={() => openCard(index())}
onClick={() => props.onSelect(item)}
onClick={() => {
if (entry.type === "prompt") props.onSelect(entry.item)
if (entry.type === "history") selectFirst()
if (entry.type === "overflow") openCard(index())
}}
>
<span class="prompt-rail-tick-line" />
</button>
@@ -185,35 +319,84 @@ export function PromptRail(props: PromptRailProps) {
<div
ref={card}
class="prompt-rail-card"
data-virtualized={virtualized() || undefined}
role="dialog"
aria-label={language.t("session.prompts.navLabel")}
style={{ top: `${position().top}px`, left: `${position().left}px` }}
style={{
top: `${position().top}px`,
left: `${position().left}px`,
"--prompt-rail-card-height": `${position().height}px`,
}}
onMouseEnter={cancelClose}
onMouseLeave={closeCard}
onWheel={(event) => {
// A reveal placed the list here, so any wheel from now on is the
// user's. Scrolling up at the very top emits no scroll event, so
// the intent to go further back has to be read from the wheel.
revealing = false
if (event.deltaY < 0) page(offset())
}}
onScroll={() => {
if (card && !virtualized()) page(card.scrollTop)
}}
>
<For each={items()}>
{(item, index) => (
<button
type="button"
class="prompt-rail-row"
classList={{ "prompt-rail-row--hover": index() === hover() }}
onMouseEnter={() => setHover(index())}
onClick={() => props.onSelect(item)}
>
<span class="prompt-rail-row-prompt" data-queued={item.queued || undefined}>
<Show when={item.queued}>
<span class="prompt-rail-row-status">{language.t("session.prompts.queued")} · </span>
</Show>
{item.prompt}
</span>
<Show when={item.answer || !item.prompt}>
<span class="prompt-rail-row-answer">
{item.answer || language.t("session.prompts.noAnswer")}
</span>
</Show>
</button>
)}
</For>
<div class="prompt-rail-card-header">
<span class="prompt-rail-card-title">{language.t("session.prompts.navLabel")}</span>
<div class="prompt-rail-card-actions">
<Tooltip value={language.t("session.prompts.first")} placement="top">
<IconButton
icon="arrow-up"
label={language.t("session.prompts.first")}
aria-label={language.t("session.prompts.first")}
variant="ghost"
size="small"
disabled={props.seeking() || props.loadingOlder()}
onClick={selectFirst}
/>
</Tooltip>
<Tooltip value={language.t("session.prompts.latest")} placement="top">
<IconButton
icon="arrow-down-to-line"
label={language.t("session.prompts.latest")}
aria-label={language.t("session.prompts.latest")}
variant="ghost"
size="small"
onClick={selectLatest}
/>
</Tooltip>
</div>
</div>
<Show when={props.loadingOlder() || props.seeking()}>
<div class="prompt-rail-loading" role="status">
<Spinner />
<span>{language.t("session.messages.loadingEarlier")}</span>
</div>
</Show>
<Show
when={virtualized()}
fallback={
<div class="prompt-rail-list-static">
<For each={items()}>{row}</For>
</div>
}
>
<VList
ref={(handle) => {
list = handle
}}
class="prompt-rail-list"
data={items()}
itemSize={ROW_HEIGHT}
bufferSize={ROW_HEIGHT * 3}
shift={props.prepending()}
onScroll={page}
onScrollEnd={() => {
revealing = false
}}
>
{row}
</VList>
</Show>
</div>
</Portal>
)}
@@ -9,25 +9,40 @@ export interface PromptRailItem {
answer: string
}
export type PromptRailEntry =
| { type: "prompt"; item: PromptRailItem; index: number }
| { type: "overflow"; count: number; index: number }
| { type: "history" }
const PROMPT_LIMIT = 160
const ANSWER_LIMIT = 220
/**
* Height of the tallest card row (padding + a one-line prompt + a two-line
* answer), and the unit the fit cap is measured in. Deliberately the worst
* case rather than an average: "only show what fits" should stay true for a
* card whose rows all wrap, not just for a lucky mix of short ones.
* answer). Sizes the navigator's virtualized rows; the rail's own fit cap is
* measured in tick spacing instead, since a tick is only a hairline.
*/
export const ROW_HEIGHT = 76
/** Vertical padding reserved at the top and bottom of the rail. */
export const RAIL_INSET = 24
/** Natural spacing between ticks, and the tightest they are allowed to pack. */
export const TICK_STEP = 14
export const TICK_MIN = 7
/**
* How many prompts fit the available transcript height. The card and the rail
* always render the same set, so this one number drives both.
* How many ticks fit the available transcript height. Measured in tick
* spacing, not card row height: a tick is a 1.5px line, so the rail holds
* several times more prompts than the navigator can list at once, and
* summarizing at the card's row count would hide prompts that have room to
* show. The complete prompt list lives in the bounded navigator.
*/
export function capacity(height: number): number {
return Math.floor((height - RAIL_INSET) / ROW_HEIGHT)
return Math.floor((height - RAIL_INSET) / TICK_MIN)
}
export function historyAction(before: number, after: number, more: boolean): "stop" | "load" | "jump" {
if (after <= before) return "stop"
return more ? "load" : "jump"
}
// The card never renders markdown — user message text shows literally, and
@@ -92,7 +107,38 @@ export function promptItems(rows: TranscriptRow[]): PromptRailItem[] {
return items
}
export function railItems(items: PromptRailItem[], capacity: number): PromptRailItem[] {
export function railEntries(items: PromptRailItem[], capacity: number, history = false): PromptRailEntry[] {
if (capacity < 1) return []
return items.slice(-capacity)
if (!history && items.length <= capacity) {
return items.map((item, index) => ({ type: "prompt", item, index }))
}
if (capacity === 1) {
if (history) return [{ type: "history" }]
const index = items.length - 1
const item = items[index]
return item ? [{ type: "prompt", item, index }] : []
}
if (capacity === 2) {
const item = items.at(-1)
const latest = item ? [{ type: "prompt" as const, item, index: items.length - 1 }] : []
if (history) return [{ type: "history" }, ...latest]
const first = items[0]
return first ? [{ type: "prompt", item: first, index: 0 }, ...latest] : latest
}
const count = Math.min(items.length, capacity - 2)
const start = items.length - count
const recent = items.slice(start).map((item, offset) => ({
type: "prompt" as const,
item,
index: start + offset,
}))
const prefix: PromptRailEntry[] = history
? [{ type: "history" }]
: items[0]
? [{ type: "prompt", item: items[0], index: 0 }]
: []
const hidden = start - (history ? 0 : 1)
if (hidden < 1) return [...prefix, ...recent]
return [...prefix, { type: "overflow", count: hidden, index: history ? 0 : 1 }, ...recent]
}
@@ -291,7 +291,7 @@ interface SessionContextValue {
createSession: () => void
clearCurrentSession: () => void
loadSessions: () => void
loadOlderMessages: () => void
loadOlderMessages: () => boolean
selectSession: (id: string) => void
deleteSession: (id: string) => void
renameSession: (id: string, title: string) => void
@@ -2560,9 +2560,9 @@ export const SessionProvider: ParentComponent = (props) => {
function loadOlderMessages() {
const id = currentSessionID()
if (!id || !server.isConnected()) return
if (!id || !server.isConnected()) return false
const page = pages[id] ?? emptyPageState
if (!page.hasMore || page.loadingOlder || page.loadingInitial || !page.before) return
if (!page.hasMore || page.loadingOlder || page.loadingInitial || !page.before) return false
patchPage(id, { loadingOlder: true })
vscode.postMessage({
type: "loadMessages",
@@ -2571,6 +2571,7 @@ export const SessionProvider: ParentComponent = (props) => {
before: page.before,
limit: MESSAGE_PAGE_LIMIT,
})
return true
}
// Session whose message fetch was deferred because the backend was offline at
+3 -1
View File
@@ -296,7 +296,6 @@ export const dict = {
"session.tab.review": "مراجعة",
"session.review.filesChanged": "تم تغيير {{count}} ملفات",
"session.review.change.other": "تغييرات",
"session.review.loadingChanges": "جارٍ تحميل التغييرات...",
"session.review.noChanges": "لا توجد تغييرات",
"session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
@@ -714,6 +713,9 @@ export const dict = {
"session.prompts.tick": "المطالبة {{index}} من {{total}}: {{prompt}}",
"session.prompts.noAnswer": "لا توجد استجابة بعد",
"session.prompts.queued": "في قائمة الانتظار",
"session.prompts.first": "أول مطالبة",
"session.prompts.latest": "أحدث مطالبة",
"session.prompts.overflow": "{{count}} مطالبات سابقة",
"session.status.writingResponse": "...جارٍ كتابة الرد",
"session.status.retry": "جارٍ إعادة المحاولة…",
"session.status.working": "...جارٍ العمل",
+3 -1
View File
@@ -306,7 +306,6 @@ export const dict = {
"session.tab.review": "Revisão",
"session.review.filesChanged": "{{count}} Arquivos Alterados",
"session.review.change.other": "Alterações",
"session.review.loadingChanges": "Carregando alterações...",
"session.review.noChanges": "Sem alterações",
"session.messages.loadingEarlier": "Carregando mensagens anteriores...",
@@ -732,6 +731,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} de {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Sem resposta ainda",
"session.prompts.queued": "Na fila",
"session.prompts.first": "Primeiro prompt",
"session.prompts.latest": "Prompt mais recente",
"session.prompts.overflow": "{{count}} prompts anteriores",
"session.status.writingResponse": "Escrevendo resposta…",
"session.status.retry": "Tentando novamente…",
"session.status.working": "Trabalhando…",
+3 -1
View File
@@ -304,7 +304,6 @@ export const dict = {
"session.tab.review": "Pregled",
"session.review.filesChanged": "Izmijenjeno {{count}} datoteka",
"session.review.change.other": "Izmjene",
"session.review.loadingChanges": "Učitavanje izmjena...",
"session.review.noChanges": "Nema izmjena",
@@ -732,6 +731,9 @@ export const dict = {
"session.prompts.tick": "Upit {{index}} od {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Još nema odgovora",
"session.prompts.queued": "Na čekanju",
"session.prompts.first": "Prvi upit",
"session.prompts.latest": "Najnoviji upit",
"session.prompts.overflow": "{{count}} ranijih upita",
"session.status.writingResponse": "Pisanje odgovora…",
"session.status.retry": "Ponovni pokušaj…",
"session.status.working": "Radim…",
+3 -1
View File
@@ -303,7 +303,6 @@ export const dict = {
"session.tab.review": "Gennemgang",
"session.review.filesChanged": "{{count}} Filer ændret",
"session.review.change.other": "Ændringer",
"session.review.loadingChanges": "Indlæser ændringer...",
"session.review.noChanges": "Ingen ændringer",
"session.messages.loadingEarlier": "Indlæser tidligere beskeder...",
@@ -730,6 +729,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} af {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Intet svar endnu",
"session.prompts.queued": "I kø",
"session.prompts.first": "Første prompt",
"session.prompts.latest": "Seneste prompt",
"session.prompts.overflow": "{{count}} tidligere prompter",
"session.status.writingResponse": "Skriver svar…",
"session.status.retry": "Prøver igen…",
"session.status.working": "Arbejder…",
@@ -312,7 +312,6 @@ export const dict = {
"session.tab.review": "Überprüfung",
"session.review.filesChanged": "{{count}} Dateien geändert",
"session.review.change.other": "Änderungen",
"session.review.loadingChanges": "Lade Änderungen...",
"session.review.noChanges": "Keine Änderungen",
"session.messages.loadingEarlier": "Lade frühere Nachrichten...",
@@ -743,6 +742,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} von {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Noch keine Antwort",
"session.prompts.queued": "In Warteschlange",
"session.prompts.first": "Erster Prompt",
"session.prompts.latest": "Neuester Prompt",
"session.prompts.overflow": "{{count}} frühere Prompts",
"session.status.writingResponse": "Antwort wird geschrieben…",
"session.status.retry": "Erneuter Versuch…",
"session.status.working": "Wird bearbeitet…",
@@ -301,7 +301,6 @@ export const dict = {
"session.tab.review": "Review",
"session.review.filesChanged": "{{count}} Files Changed",
"session.review.change.other": "Changes",
"session.review.loadingChanges": "Loading changes...",
"session.review.noChanges": "No changes",
@@ -684,6 +683,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} of {{total}}: {{prompt}}",
"session.prompts.noAnswer": "No response yet",
"session.prompts.queued": "Queued",
"session.prompts.first": "First prompt",
"session.prompts.latest": "Latest prompt",
"session.prompts.overflow": "{{count}} earlier prompts",
"session.status.writingResponse": "Writing response...",
"session.status.retry": "Retrying…",
"session.status.working": "Working...",
+3 -1
View File
@@ -307,7 +307,6 @@ export const dict = {
"session.tab.review": "Revisión",
"session.review.filesChanged": "{{count}} Archivos Cambiados",
"session.review.change.other": "Cambios",
"session.review.loadingChanges": "Cargando cambios...",
"session.review.noChanges": "Sin cambios",
"session.messages.loadingEarlier": "Cargando mensajes anteriores...",
@@ -737,6 +736,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} de {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Aún no hay respuesta",
"session.prompts.queued": "En cola",
"session.prompts.first": "Primera solicitud",
"session.prompts.latest": "Última solicitud",
"session.prompts.overflow": "{{count}} solicitudes anteriores",
"session.status.writingResponse": "Escribiendo respuesta…",
"session.status.retry": "Reintentando…",
"session.status.working": "Trabajando…",
+3
View File
@@ -688,6 +688,9 @@ export const dict = {
"session.prompts.tick": "پرامپت {{index}} از {{total}}: {{prompt}}",
"session.prompts.noAnswer": "هنوز پاسخی وجود ندارد",
"session.prompts.queued": "در صف انتظار",
"session.prompts.first": "اولین پرامپت",
"session.prompts.latest": "آخرین پرامپت",
"session.prompts.overflow": "{{count}} پرامپت قبلی",
"session.status.writingResponse": "در حال نوشتن پاسخ...",
"session.status.retry": "در حال تلاش مجدد…",
"session.status.working": "در حال پردازش...",
+3 -1
View File
@@ -306,7 +306,6 @@ export const dict = {
"session.tab.review": "Revue",
"session.review.filesChanged": "{{count}} fichiers modifiés",
"session.review.change.other": "Modifications",
"session.review.loadingChanges": "Chargement des modifications...",
"session.review.noChanges": "Aucune modification",
"session.messages.loadingEarlier": "Chargement des messages précédents...",
@@ -744,6 +743,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} sur {{total}} : {{prompt}}",
"session.prompts.noAnswer": "Pas encore de réponse",
"session.prompts.queued": "En attente",
"session.prompts.first": "Premier prompt",
"session.prompts.latest": "Dernier prompt",
"session.prompts.overflow": "{{count}} prompts précédents",
"session.status.writingResponse": "Rédaction de la réponse…",
"session.status.retry": "Nouvelle tentative…",
"session.status.working": "En cours…",
+3 -1
View File
@@ -219,7 +219,6 @@ export const dict = {
"ui.approval.source.default": "per impostazione predefinita",
"session.tab.review": "Revisione",
"session.review.filesChanged": "{{count}} file modificati",
"session.review.change.other": "Modifiche",
"session.review.loadingChanges": "Caricamento modifiche...",
"session.review.noChanges": "Nessuna modifica",
"session.messages.loadingEarlier": "Caricamento messaggi precedenti...",
@@ -583,6 +582,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} di {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Nessuna risposta ancora",
"session.prompts.queued": "In coda",
"session.prompts.first": "Primo prompt",
"session.prompts.latest": "Ultimo prompt",
"session.prompts.overflow": "{{count}} prompt precedenti",
"session.status.writingResponse": "Scrittura risposta...",
"session.status.retry": "Riprovo...",
"session.status.working": "Al lavoro...",
+3 -1
View File
@@ -303,7 +303,6 @@ export const dict = {
"session.tab.review": "レビュー",
"session.review.filesChanged": "{{count}} ファイル変更",
"session.review.change.other": "変更",
"session.review.loadingChanges": "変更を読み込み中...",
"session.review.noChanges": "変更なし",
"session.messages.loadingEarlier": "以前のメッセージを読み込み中...",
@@ -724,6 +723,9 @@ export const dict = {
"session.prompts.tick": "プロンプト {{index}}/{{total}}: {{prompt}}",
"session.prompts.noAnswer": "まだ応答がありません",
"session.prompts.queued": "キューに追加済み",
"session.prompts.first": "最初のプロンプト",
"session.prompts.latest": "最新のプロンプト",
"session.prompts.overflow": "{{count}} 件前のプロンプト",
"session.status.writingResponse": "応答を作成中…",
"session.status.retry": "再試行中…",
"session.status.working": "作業中…",
+3 -1
View File
@@ -304,7 +304,6 @@ export const dict = {
"session.tab.review": "검토",
"session.review.filesChanged": "{{count}}개 파일 변경됨",
"session.review.change.other": "변경",
"session.review.loadingChanges": "변경 사항 로드 중...",
"session.review.noChanges": "변경 없음",
"session.messages.loadingEarlier": "이전 메시지 로드 중...",
@@ -725,6 +724,9 @@ export const dict = {
"session.prompts.tick": "프롬프트 {{index}}/{{total}}: {{prompt}}",
"session.prompts.noAnswer": "아직 응답이 없습니다",
"session.prompts.queued": "대기 중",
"session.prompts.first": "첫 번째 프롬프트",
"session.prompts.latest": "최신 프롬프트",
"session.prompts.overflow": "{{count}}개 이전 프롬프트",
"session.status.writingResponse": "응답 작성 중...",
"session.status.retry": "재시도 중…",
"session.status.working": "작업 중...",
+3 -1
View File
@@ -307,7 +307,6 @@ export const dict = {
"session.tab.review": "Beoordelen",
"session.review.filesChanged": "{{count}} bestanden gewijzigd",
"session.review.change.other": "Wijzigingen",
"session.review.loadingChanges": "Wijzigingen laden...",
"session.review.noChanges": "Geen wijzigingen",
@@ -722,6 +721,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} van {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Nog geen reactie",
"session.prompts.queued": "In wachtrij",
"session.prompts.first": "Eerste prompt",
"session.prompts.latest": "Meest recente prompt",
"session.prompts.overflow": "{{count}} eerdere prompts",
"session.status.writingResponse": "Antwoord schrijven...",
"session.status.retry": "Opnieuw proberen...",
"session.status.working": "Bezig...",
+3 -1
View File
@@ -310,7 +310,6 @@ export const dict = {
"session.tab.review": "Gjennomgang",
"session.review.filesChanged": "{{count}} filer endret",
"session.review.change.other": "Endringer",
"session.review.loadingChanges": "Laster endringer...",
"session.review.noChanges": "Ingen endringer",
"session.messages.loadingEarlier": "Laster inn tidligere meldinger...",
@@ -692,6 +691,9 @@ export const dict = {
"session.prompts.tick": "Ledetekst {{index}} av {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Ingen svar ennå",
"session.prompts.queued": "I kø",
"session.prompts.first": "Første ledetekst",
"session.prompts.latest": "Siste ledetekst",
"session.prompts.overflow": "{{count}} tidligere ledetekster",
"session.status.writingResponse": "Skriver svar…",
"session.status.retry": "Prøver på nytt…",
"session.status.working": "Arbeider…",
+3 -1
View File
@@ -304,7 +304,6 @@ export const dict = {
"session.tab.review": "Przegląd",
"session.review.filesChanged": "Zmieniono {{count}} plików",
"session.review.change.other": "Zmiany",
"session.review.loadingChanges": "Ładowanie zmian...",
"session.review.noChanges": "Brak zmian",
"session.messages.loadingEarlier": "Ładowanie wcześniejszych wiadomości...",
@@ -688,6 +687,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} z {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Jeszcze brak odpowiedzi",
"session.prompts.queued": "W kolejce",
"session.prompts.first": "Pierwszy prompt",
"session.prompts.latest": "Ostatni prompt",
"session.prompts.overflow": "{{count}} wcześniejszych promptów",
"session.status.writingResponse": "Pisanie odpowiedzi…",
"session.status.retry": "Ponawianie…",
"session.status.working": "Pracuję…",
+3 -1
View File
@@ -302,7 +302,6 @@ export const dict = {
"session.tab.review": "Обзор",
"session.review.filesChanged": "{{count}} файлов изменено",
"session.review.change.other": "Изменения",
"session.review.loadingChanges": "Загрузка изменений...",
"session.review.noChanges": "Нет изменений",
"session.messages.loadingEarlier": "Загрузка предыдущих сообщений...",
@@ -729,6 +728,9 @@ export const dict = {
"session.prompts.tick": "Промпт {{index}} из {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Ответа пока нет",
"session.prompts.queued": "В очереди",
"session.prompts.first": "Первый запрос",
"session.prompts.latest": "Последний запрос",
"session.prompts.overflow": "{{count}} предыдущих запросов",
"session.status.writingResponse": "Пишу ответ…",
"session.status.retry": "Повторная попытка…",
"session.status.working": "Работаю…",
+3 -1
View File
@@ -301,7 +301,6 @@ export const dict = {
"session.tab.review": "ตรวจสอบ",
"session.review.filesChanged": "{{count}} ไฟล์ที่เปลี่ยนแปลง",
"session.review.change.other": "การเปลี่ยนแปลง",
"session.review.loadingChanges": "กำลังโหลดการเปลี่ยนแปลง...",
"session.review.noChanges": "ไม่มีการเปลี่ยนแปลง",
@@ -721,6 +720,9 @@ export const dict = {
"session.prompts.tick": "พรอมต์ {{index}} จาก {{total}}: {{prompt}}",
"session.prompts.noAnswer": "ยังไม่มีการตอบกลับ",
"session.prompts.queued": "อยู่ในคิว",
"session.prompts.first": "พรอมต์แรก",
"session.prompts.latest": "พรอมต์ล่าสุด",
"session.prompts.overflow": "พรอมต์ก่อนหน้า {{count}} รายการ",
"session.status.writingResponse": "กำลังเขียนคำตอบ...",
"session.status.retry": "กำลังลองใหม่…",
"session.status.working": "กำลังทำงาน...",
+3 -1
View File
@@ -302,7 +302,6 @@ export const dict = {
"session.tab.review": "İnceleme",
"session.review.filesChanged": "{{count}} Dosya Değişti",
"session.review.change.other": "Değişiklik",
"session.review.loadingChanges": "Değişiklikler yükleniyor...",
"session.review.noChanges": "Değişiklik yok",
@@ -717,6 +716,9 @@ export const dict = {
"session.prompts.tick": "Komut {{index}} / {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Henüz yanıt yok",
"session.prompts.queued": "Sırada",
"session.prompts.first": "İlk istem",
"session.prompts.latest": "En son istem",
"session.prompts.overflow": "{{count}} önceki istem",
"session.status.writingResponse": "Yanıt yazılıyor...",
"session.status.retry": "Yeniden deneniyor…",
"session.status.working": "Çalışıyor...",
+3 -1
View File
@@ -306,7 +306,6 @@ export const dict = {
"session.tab.review": "Огляд",
"session.review.filesChanged": "{{count}} файлів змінено",
"session.review.change.other": "Змін",
"session.review.loadingChanges": "Завантаження змін...",
"session.review.noChanges": "Змін немає",
@@ -718,6 +717,9 @@ export const dict = {
"session.prompts.tick": "Запит {{index}} з {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Відповіді ще немає",
"session.prompts.queued": "У черзі",
"session.prompts.first": "Перший запит",
"session.prompts.latest": "Останній запит",
"session.prompts.overflow": "{{count}} попередніх запитів",
"session.status.writingResponse": "Пишу відповідь...",
"session.status.retry": "Повторна спроба…",
"session.status.working": "Працює...",
+3 -1
View File
@@ -292,7 +292,6 @@ export const dict = {
"session.tab.review": "审查",
"session.review.filesChanged": "{{count}} 个文件变更",
"session.review.change.other": "更改",
"session.review.loadingChanges": "正在加载更改...",
"session.review.noChanges": "无更改",
"session.messages.loadingEarlier": "正在加载更早的消息...",
@@ -705,6 +704,9 @@ export const dict = {
"session.prompts.tick": "提示词 {{index}}/{{total}}:{{prompt}}",
"session.prompts.noAnswer": "暂无响应",
"session.prompts.queued": "已排队",
"session.prompts.first": "首个提示",
"session.prompts.latest": "最新提示",
"session.prompts.overflow": "{{count}} 个更早的提示",
"session.status.writingResponse": "正在撰写回复…",
"session.status.retry": "正在重试…",
"session.status.working": "处理中…",
+3 -1
View File
@@ -290,7 +290,6 @@ export const dict = {
"session.tab.review": "審查",
"session.review.filesChanged": "{{count}} 個檔案變更",
"session.review.change.other": "變更",
"session.review.loadingChanges": "正在載入變更...",
"session.review.noChanges": "沒有變更",
"session.messages.loadingEarlier": "正在載入更早的訊息...",
@@ -665,6 +664,9 @@ export const dict = {
"session.prompts.tick": "提示詞 {{index}}/{{total}}:{{prompt}}",
"session.prompts.noAnswer": "尚無回應",
"session.prompts.queued": "已排入佇列",
"session.prompts.first": "第一個提示",
"session.prompts.latest": "最新提示",
"session.prompts.overflow": "{{count}} 個較早的提示",
"session.status.writingResponse": "正在撰寫回覆…",
"session.status.retry": "正在重試…",
"session.status.working": "處理中…",
@@ -262,7 +262,7 @@ export function mockSessionValue(overrides?: {
createSession: noop,
clearCurrentSession: noop,
loadSessions: noop,
loadOlderMessages: noop,
loadOlderMessages: () => false,
selectSession: noop,
deleteSession: noop,
renameSession: noop,
@@ -9,6 +9,7 @@
import type { Meta, StoryObj } from "storybook-solidjs-vite"
import type { AssistantMessage } from "@kilocode/sdk/v2"
import { batch, createSignal } from "solid-js"
import { StoryProviders, defaultMockData, mockSessionValue } from "./StoryProviders"
import { ChatView } from "../components/chat/ChatView"
import { ErrorDisplay } from "../components/chat/ErrorDisplay"
@@ -698,6 +699,7 @@ const renderRailChat = (status: "idle" | "busy" = "idle") => {
...mockSessionValue({ id: SESSION_ID, status }),
messages: () => railMessages,
userMessages: () => railMessages.filter((msg) => msg.role === "user"),
getParts: (id: string) => railParts[id] ?? [],
}
return (
<StoryProviders data={railData} sessionID={SESSION_ID} status={status} noPadding>
@@ -722,10 +724,11 @@ export const PromptRailSidebar: Story = {
// Long session: more prompts than fit the transcript height, so the rail and
// the card both cap to the newest ones that fit.
const manyTurns = Array.from({ length: 40 }, (_, i) =>
const manyTurns = Array.from({ length: 80 }, (_, i) =>
railTurn(100 + i, `Prompt number ${i + 1} in a long running session`, `Answer number ${i + 1}.`),
)
const manyMessages = manyTurns.flatMap((turn) => turn.messages)
const recentMessages = manyTurns.slice(-40).flatMap((turn) => turn.messages)
const manyData = {
...defaultMockData,
message: { [SESSION_ID]: manyMessages },
@@ -735,10 +738,34 @@ const manyData = {
export const PromptRailManyPrompts: Story = {
name: "PromptRail - long session caps to what fits",
render: () => {
const [messages, setMessages] = createSignal(recentMessages)
const [older, setOlder] = createSignal(true)
const [loading, setLoading] = createSignal(false)
const [mutation, setMutation] = createSignal<"prepend">()
const load = () => {
if (!older() || loading()) return false
setLoading(true)
// Paging is a backend round trip, so the story keeps a short delay: the
// navigator's loading row is part of the behavior being shown.
setTimeout(() => {
batch(() => {
setMessages(manyMessages)
setOlder(false)
setMutation("prepend")
setLoading(false)
})
}, 300)
return true
}
const session = {
...mockSessionValue({ id: SESSION_ID, status: "idle" }),
messages: () => manyMessages,
userMessages: () => manyMessages.filter((msg) => msg.role === "user"),
messages,
userMessages: () => messages().filter((msg) => msg.role === "user"),
getParts: (id: string) => manyData.part[id] ?? [],
hasOlderMessages: older,
loadingOlderMessages: loading,
messageMutation: mutation,
loadOlderMessages: load,
}
return (
<StoryProviders data={manyData} sessionID={SESSION_ID} status="idle" noPadding>
@@ -169,6 +169,119 @@
align-items: center;
gap: 6px;
min-width: 0;
flex-shrink: 1;
}
/* ============================================
Inline diff toolbar controls (scope, base, diff style)
============================================
These sit inside a toolbar row next to ghost buttons and a radio group, so
they must not carry header-band margins and must match the small control
height. `.am-selector-trigger` is a 32px full-width input-style control;
everything below shrinks it to a toolbar-sized chip. */
.diff-scope-controls {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-shrink: 1;
}
/* Two-class selector on purpose: `.am-selector-trigger` (agent-manager.css) is
a 32px full-width input-style control and loads after this file, so a single
`.diff-inline-trigger` class would lose the cascade tie and keep the tall
size. */
.am-selector-trigger.diff-inline-trigger {
width: auto;
height: 22px;
gap: 4px;
padding: 0 6px;
font-size: var(--kilo-font-size-12);
/* Tight line box: the inherited 19.5px line-height leaves no room inside a
22px chip and makes inline children look like they burst out of it. */
line-height: 20px;
/* Never squeeze: shrinking these to unreadable slivers looks broken. The
container queries below drop whole controls instead. */
flex-shrink: 0;
min-width: 0;
}
/* The "Default" badge is sized for dropdown rows (19.5px line box + padding =
21.5px), which fills a 22px chip edge to edge. Shrink it so it reads as a
badge inside the control instead of overflowing it. */
.am-selector-trigger.diff-inline-trigger .am-branch-badge {
padding: 0 4px;
line-height: 14px;
border-radius: 2px;
}
.am-selector-trigger.diff-inline-trigger .am-selector-right {
margin-left: 2px;
}
/* Long branch names ellipsize rather than pushing the row wider. The compact
variant (narrow side panel) caps tighter than the full-screen toolbar. */
.am-selector-trigger.diff-inline-trigger .am-selector-value {
max-width: 160px;
}
.diff-scope-controls-compact .am-selector-trigger.diff-inline-trigger .am-selector-value {
max-width: 92px;
}
/* The shared `.am-dropdown` rule pins popover width to the anchor width, which
would make these narrow triggers open an unreadably narrow menu. */
.diff-inline-menu[data-component="popover-content"] {
width: auto;
min-width: 200px;
}
.diff-inline-group {
padding: 6px 10px 2px;
font-size: var(--kilo-font-size-11);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-weaker);
}
/* Inline controls inside the two Agent Manager diff toolbars. They must not add
vertical margin: each toolbar's height is its own padding plus the tallest
control, so a header-band margin here would visibly grow the row (this is
why these rows do not reuse `[data-component="diff-picker-header"]`). */
.am-review-toolbar-left > .diff-scope-controls,
.am-diff-header-main > .diff-scope-controls {
margin: 0;
min-width: 0;
flex-shrink: 0;
}
/* ============================================
Narrow side panel: progressive disclosure
============================================
The user can drag the diff side panel arbitrarily narrow. Flex shrinking
alone clips controls mid-glyph and slides them under the action buttons, so
whole controls are dropped instead, least important first. The scope select
always survives: it is the only way back out of an empty scope.
The container is declared on `.am-diff-header` in agent-manager.css (that
file only permits `am-`-prefixed selectors, so the `diff-`-prefixed rules
live here). */
/* Diff style has a home in the full-screen review, so it goes before base. */
@container am-diff-header (max-width: 470px) {
.am-diff-header-main .diff-style-select {
display: none;
}
}
@container am-diff-header (max-width: 390px) {
.am-diff-header-main .diff-base-picker {
display: none;
}
}
.diff-base-current {
@@ -193,12 +306,6 @@
line-height: 1;
}
.diff-base-trigger {
height: 24px;
font-size: var(--kilo-font-size-12);
padding: 0 8px;
}
/* ============================================
Diff Viewer Notice (e.g. snapshots disabled)
============================================ */
@@ -70,6 +70,17 @@
opacity: 0.5;
}
.prompt-rail-tick--overflow .prompt-rail-tick-line {
width: 6px;
background: repeating-linear-gradient(
to right,
var(--icon-weaker) 0,
var(--icon-weaker) 2px,
transparent 2px,
transparent 4px
);
}
/* Scroll position: subtle, always-on cue. */
.prompt-rail-tick--active .prompt-rail-tick-line {
width: 13px;
@@ -103,7 +114,7 @@
z-index: 1000;
box-sizing: border-box;
width: min(360px, calc(100vw - var(--prompt-rail-width, 16px) - 40px));
max-height: calc(100vh - 24px);
max-height: min(calc(100vh - 24px), var(--prompt-rail-card-height, calc(100vh - 24px)));
overflow-y: auto;
padding: 6px;
border-radius: 12px;
@@ -120,7 +131,79 @@
0 12px 32px -8px rgba(0, 0, 0, 0.4);
animation: prompt-rail-in 0.22s var(--prompt-rail-ease, cubic-bezier(0.22, 1, 0.36, 1));
transform-origin: left center;
scrollbar-width: thin;
}
.prompt-rail-card[data-virtualized] {
display: flex;
flex-direction: column;
height: min(calc(100vh - 24px), var(--prompt-rail-card-height));
overflow: hidden;
background: var(--surface-float-base);
}
.prompt-rail-card-header {
position: sticky;
top: -6px;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 32px;
padding: 4px 5px 4px 11px;
background: color-mix(in srgb, var(--surface-float-base) 88%, transparent);
backdrop-filter: blur(20px) saturate(1.6);
-webkit-backdrop-filter: blur(20px) saturate(1.6);
}
.prompt-rail-card-title {
min-width: 0;
overflow: hidden;
color: var(--text-weak);
font-size: var(--kilo-font-size-11);
font-weight: var(--font-weight-medium, 500);
text-overflow: ellipsis;
white-space: nowrap;
}
.prompt-rail-card-actions {
display: flex;
align-items: center;
flex: none;
gap: 2px;
}
.prompt-rail-loading {
display: flex;
flex: none;
align-items: center;
justify-content: center;
gap: 8px;
padding: 6px 0 8px;
color: var(--text-weaker);
font-size: var(--kilo-font-size-12);
}
.prompt-rail-list {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.prompt-rail-list-static {
min-width: 0;
}
/* A floating popover should not paint an OS scrollbar over its own glass; the
rail already shows where you are in the session. */
.prompt-rail-card,
.prompt-rail-list {
scrollbar-width: none;
}
.prompt-rail-card::-webkit-scrollbar,
.prompt-rail-list::-webkit-scrollbar {
display: none;
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
@@ -877,6 +877,18 @@ export interface AgentManagerRevertWorktreeFileResultMessage {
message: string
}
// Agent Manager: Branch picker data for a diff context (extension → webview)
export interface AgentManagerDiffBranchesMessage {
type: "agentManager.diffBranches"
sessionId: string
branches: BranchInfo[]
defaultBranch: string
autoBase?: string
currentBase?: string
isAuto: boolean
currentBranch?: string
}
// Agent Manager: Worktree git stats push (extension → webview)
export interface AgentManagerWorktreeStatsMessage {
type: "agentManager.worktreeStats"
@@ -1246,6 +1258,7 @@ export type ExtensionMessage =
| AgentManagerWorktreeDiffLoadingMessage
| AgentManagerApplyWorktreeDiffResultMessage
| AgentManagerRevertWorktreeFileResultMessage
| AgentManagerDiffBranchesMessage
| AgentManagerWorktreeStatsMessage
| AgentManagerLocalStatsMessage
| AgentManagerPRStatusMessage
@@ -828,18 +828,21 @@ export interface ImportFromPRRequest {
export interface RequestWorktreeDiffMessage {
type: "agentManager.requestWorktreeDiff"
sessionId: string
scope?: string
}
export interface RequestWorktreeDiffFileMessage {
type: "agentManager.requestWorktreeDiffFile"
sessionId: string
file: string
scope?: string
}
// Agent Manager: Start polling for live diff updates (webview → extension)
export interface StartDiffWatchMessage {
type: "agentManager.startDiffWatch"
sessionId: string
scope?: string
}
// Agent Manager: Stop polling for diff updates (webview → extension)
@@ -847,6 +850,21 @@ export interface StopDiffWatchMessage {
type: "agentManager.stopDiffWatch"
}
// Agent Manager: Request branch picker data for a diff context (webview → extension)
export interface RequestDiffBranchesMessage {
type: "agentManager.requestDiffBranches"
sessionId: string
scope?: string
}
// Agent Manager: Set or clear the base branch override for a diff context (webview → extension)
export interface SetDiffBaseBranchMessage {
type: "agentManager.setDiffBaseBranch"
sessionId: string
scope?: string
branch?: string
}
// Agent Manager: PR messages (webview → extension)
export interface RefreshPRMessage {
type: "agentManager.refreshPR"
@@ -869,6 +887,7 @@ export interface RevertWorktreeFileMessage {
type: "agentManager.revertWorktreeFile"
sessionId: string
file: string
scope?: string
}
// Variant persistence (webview → extension)
@@ -1365,6 +1384,8 @@ export type WebviewMessage =
| RequestWorktreeDiffFileMessage
| StartDiffWatchMessage
| StopDiffWatchMessage
| RequestDiffBranchesMessage
| SetDiffBaseBranchMessage
| RefreshPRMessage
| OpenPRMessage
// legacy-migration start