mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge branch 'main' into feat/websearch-config-setting
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a7c800d169ca92674fc9f7c7d83032ca17e7d7937bdcbea5c72cb921a7fdb89e
|
||||
size 51975
|
||||
oid sha256:2a74c7f53a5d9afa743ee49d298ce8fb7e357c5fcc46cad45de353694a7540dc
|
||||
size 49476
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:51adb9e31ce0bc82981b0f20f895ce4ede3f92828546115f929942ef93bf0812
|
||||
size 11204
|
||||
oid sha256:d7fc23fcb7adf483c0b771ef601b23cb365dc7f40033b00fce703b35910aa4fc
|
||||
size 27159
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c5658ed9e5266311c4239b7cd470d2f772d1cc0b3fd6c6b00337d4f3141a3a4d
|
||||
size 11950
|
||||
oid sha256:d11f4004ed3170647d385c14df077d235f5bb9bd6e5dec07557ee2014d553233
|
||||
size 27302
|
||||
|
||||
+2
-2
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user