mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 11:05:31 +08:00
fix(vscode): repair CI failures and address multi-project review findings
CI: - Render the config console's active overlay target from its new object shape instead of passing it to JSX. - Make the config overlay `expected` revision optional in the schema, writer, and handler so clients without a binding write unconditionally instead of receiving a 400, and add the missing PUT /indexing/consent exerciser scenario. Regenerate the SDK for the schema change. - The multi-project Storybook story called useLanguage() outside its provider and rendered nothing; it now uses the story translator. - Scope indexing test select locators by row title, since the tab gained a project selector that shifted positional lookups. Review findings: - Gate worktree creation, promotion, and multi-version creation on the target project's state: waitForStateReady only tracked the active project, so those handlers could mutate a background project's state before it loaded. - Re-check trust and enablement for every project-stamped message instead of resolving contexts through the unchecked map lookup. - Register projects through resolveProjectRoot so a folder inside a linked worktree cannot duplicate an existing project, and fix that helper to issue valid rev-parse commands. - Validate the persisted activeTarget shape before applying it. - Stop throwing from workspace/session directory resolution: it runs eagerly per webview message, where a throw dropped the message. - Evict superseded config bindings per scope and directory. - Guard the multi-project rename against the blur that Escape triggers. - Unregister routes when disabling secondary projects. - Replace the section message substring test with an explicit type set. - Match an open session tab in the project search's current item and scope selection acks by project id. - Filter untrusted projects out of indexing consent, and restore config scope switching plus project-scoped indexing writes that the consent rework had removed. - Round-trip the sessions-collapsed mutation so multi-project bodies, which render purely from pushed state, reflect the toggle.
This commit is contained in:
@@ -41,9 +41,10 @@ and `configUpdateFailed` currently wipes the draft for the failed scope. Any exi
|
||||
sender without a binding (permission dock, model picker, auto-approve, onboarding)
|
||||
now fails or loses the user's unsaved edits.
|
||||
|
||||
- Fix: absent binding id falls back to the legacy revision-less write; keep revision
|
||||
enforcement only where a binding was actually supplied. Keep the draft for scopes
|
||||
that did not complete.
|
||||
- Backend half is done: `expected` is optional in the overlay schema, writer, and
|
||||
handler, so a client without a binding writes unconditionally again instead of
|
||||
getting a 400. The webview half (draft retention on `configUpdateFailed`, and the
|
||||
audit of every `updateConfig` sender) is still open.
|
||||
- Work: the code change is small; the real work is auditing every webview
|
||||
`updateConfig` sender and classifying it.
|
||||
- Also: this change is orthogonal to multi-project. Split it into its own commit
|
||||
@@ -51,6 +52,12 @@ now fails or loses the user's unsaved edits.
|
||||
|
||||
### 1.2 Indexing status read silently revokes consent — P0-3 / P0-4
|
||||
|
||||
Partially addressed: untrusted projects are now filtered out of the consent list
|
||||
(P0-4), and config scope switching plus project-scoped `indexing.enabled` writes
|
||||
were restored after the rework had hardcoded the tab to global scope (the earlier
|
||||
P1-8 gap). The remaining blocker is the read path below.
|
||||
|
||||
|
||||
`fetchAndSendIndexingStatus` issues `PUT /indexing/consent` (a write) on a plain
|
||||
status refresh, defaulting to `enabled: false` for any project not in local
|
||||
`globalState`. On a fresh machine/profile, the first status read turns indexing off
|
||||
|
||||
@@ -13,7 +13,11 @@ export function SourcesRoute() {
|
||||
<ConfigToolbar
|
||||
title="Load Order"
|
||||
description="Load order and editability without exposing secret values."
|
||||
meta={<Tag>{data().overlay.targets.active ?? "Read only"}</Tag>}
|
||||
meta={
|
||||
<Tag>
|
||||
{data().overlay.targets.active.writable ? data().overlay.targets.active.scope : "Read only"}
|
||||
</Tag>
|
||||
}
|
||||
/>
|
||||
|
||||
<div class="table" role="table" aria-label="Config sources">
|
||||
|
||||
@@ -4687,7 +4687,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
private getWorkspaceDirectory(sessionId?: string): string {
|
||||
const routed = this.routeSessionDirectory(sessionId ?? undefined)
|
||||
if (routed === null) throw new Error(`Session ${sessionId} is ambiguous across projects.`)
|
||||
// Ambiguous ids degrade to the legacy resolution instead of throwing: this
|
||||
// runs eagerly per webview message, where a throw would drop the message.
|
||||
if (routed === null)
|
||||
console.warn(`[Kilo New] KiloProvider: session ${sessionId} is ambiguous across projects, using workspace root`)
|
||||
if (routed) return routed
|
||||
return resolveWorkspaceDirectory({
|
||||
sessionID: sessionId,
|
||||
@@ -4698,7 +4701,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
private getSessionDirectory(sessionId: string, session?: Session): string {
|
||||
const routed = this.routeSessionDirectory(sessionId)
|
||||
if (routed === null) throw new Error(`Session ${sessionId} is ambiguous across projects.`)
|
||||
if (routed === null)
|
||||
console.warn(
|
||||
`[Kilo New] KiloProvider: session ${sessionId} is ambiguous across projects, using tracked directory`,
|
||||
)
|
||||
if (routed) return routed
|
||||
return this.sessionDirectories.get(sessionId) ?? session?.directory ?? this.getRootDirectory()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isAbsolutePath } from "../path-utils"
|
||||
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
|
||||
import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager"
|
||||
import { handleSection } from "./section-handler"
|
||||
import { STATE_GATED } from "./state-gate"
|
||||
import {
|
||||
addSessionToLifecycleWorktree,
|
||||
closeLifecycleSession,
|
||||
@@ -67,6 +68,7 @@ import { createProjectWiring } from "./project-wiring"
|
||||
import { ProjectScope } from "./project-scope"
|
||||
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
|
||||
import type { Host, PanelContext, OutputHandle, Disposable } from "./host"
|
||||
|
||||
export class AgentManagerProvider implements Disposable {
|
||||
public static readonly viewType = "kilo-code.new.AgentManagerPanel"
|
||||
private panel: PanelContext | undefined
|
||||
@@ -707,6 +709,10 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
if (m.type === "agentManager.setSessionsCollapsed") {
|
||||
this.state?.setSessionsCollapsed(m.collapsed)
|
||||
// Multi-project bodies render collapsed purely from pushed state, so the
|
||||
// mutation must round-trip; legacy mode is covered by its optimistic
|
||||
// signal and the push is a no-op update.
|
||||
this.pushState()
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.setSidebarCollapsed") {
|
||||
@@ -966,42 +972,20 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
|
||||
private async waitForStateReady(context: string): Promise<void> {
|
||||
const ctx = this.projectScope.current()
|
||||
// A message scoped to a background project must wait for that project's
|
||||
// own initialization; this.stateReady only tracks the active project.
|
||||
if (ctx && ctx.id !== this.contexts.active()?.id) {
|
||||
const result = await initContextState(ctx, (...args) => this.log(...args))
|
||||
if (!result.ok) this.log(`${context}: project ${ctx.id} state did not load`)
|
||||
return
|
||||
}
|
||||
if (!this.stateReady) return
|
||||
await this.stateReady.catch((err) => this.log(`${context}: stateReady rejected, continuing:`, err))
|
||||
}
|
||||
|
||||
private shouldWaitForState(m: AgentManagerInMessage): boolean {
|
||||
switch (m.type) {
|
||||
case "agentManager.deleteWorktree":
|
||||
case "agentManager.removeStaleWorktree":
|
||||
case "agentManager.openLocally":
|
||||
case "agentManager.addSessionToWorktree":
|
||||
case "agentManager.closeSession":
|
||||
case "agentManager.persistSession":
|
||||
case "agentManager.forgetSession":
|
||||
case "agentManager.renameWorktree":
|
||||
case "agentManager.requestBranches":
|
||||
case "agentManager.importFromBranch":
|
||||
case "agentManager.importFromPR":
|
||||
case "agentManager.importExternalWorktree":
|
||||
case "agentManager.importAllExternalWorktrees":
|
||||
case "agentManager.setTabOrder":
|
||||
case "agentManager.setWorktreeOrder":
|
||||
case "agentManager.setSessionsCollapsed":
|
||||
case "agentManager.setSidebarCollapsed":
|
||||
case "agentManager.setReviewDiffStyle":
|
||||
case "agentManager.setDefaultBaseBranch":
|
||||
case "agentManager.createSection":
|
||||
case "agentManager.renameSection":
|
||||
case "agentManager.deleteSection":
|
||||
case "agentManager.setSectionColor":
|
||||
case "agentManager.toggleSectionCollapsed":
|
||||
case "agentManager.moveToSection":
|
||||
case "agentManager.moveSection":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return STATE_GATED.has(m.type)
|
||||
}
|
||||
|
||||
private onToolEvent(event: unknown, directory?: string): void {
|
||||
@@ -1601,7 +1585,9 @@ export class AgentManagerProvider implements Disposable {
|
||||
private messageProject(m: AgentManagerInMessage): ProjectContext | undefined {
|
||||
const pid = (m as { projectId?: unknown }).projectId
|
||||
if (typeof pid !== "string") return this.contexts.active()
|
||||
return this.contexts.get(pid)
|
||||
// Re-check trust and enablement on every project-stamped message: a context
|
||||
// instance can be cached before trust is confirmed, and get() checks neither.
|
||||
return this.contexts.usable(pid)
|
||||
}
|
||||
|
||||
private activateProject(ctx: ProjectContext): void {
|
||||
|
||||
@@ -14,6 +14,21 @@ import * as fs from "fs"
|
||||
import { normalizePath } from "./git-import"
|
||||
import type { SidebarTarget } from "./project-route"
|
||||
|
||||
/** Accept a persisted sidebar target only when its shape matches a known kind. */
|
||||
function validTarget(value: unknown): SidebarTarget | undefined {
|
||||
if (!value || typeof value !== "object") return undefined
|
||||
const target = value as Record<string, unknown>
|
||||
if (typeof target.projectId !== "string") return undefined
|
||||
if (target.kind === "local") return { projectId: target.projectId, kind: "local" }
|
||||
if (target.kind === "worktree" && typeof target.worktreeId === "string") {
|
||||
return { projectId: target.projectId, kind: "worktree", worktreeId: target.worktreeId }
|
||||
}
|
||||
if (target.kind === "session" && typeof target.sessionId === "string") {
|
||||
return { projectId: target.projectId, kind: "session", sessionId: target.sessionId }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export interface Worktree {
|
||||
id: string
|
||||
branch: string
|
||||
@@ -739,7 +754,7 @@ export class WorktreeStateManager {
|
||||
this.reviewDiffStyle = "split"
|
||||
}
|
||||
this.defaultBase = data.defaultBaseBranch
|
||||
this.activeTarget = data.activeTarget
|
||||
this.activeTarget = validTarget(data.activeTarget)
|
||||
this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`)
|
||||
if (pruned > 0 || repaired) {
|
||||
if (pruned > 0) this.log(`Pruned ${pruned} orphaned sessions`)
|
||||
|
||||
@@ -386,6 +386,9 @@ export class ProjectContexts {
|
||||
if (ctx.pinned) continue
|
||||
this.expanded.delete(ctx.id)
|
||||
ctx.suspend()
|
||||
// Match remove()/syncPinned(): drop the routes too, otherwise the shared
|
||||
// route service accumulates entries for every disabled project.
|
||||
this.opts.remove?.(ctx.id)
|
||||
}
|
||||
return pinned
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import simpleGit from "simple-git"
|
||||
import type { AgentManagerInMessage } from "./types"
|
||||
import type { ProjectRegistry } from "./project-registry"
|
||||
import type { ProjectContext, ProjectContexts, ProjectInitResult } from "./project-context"
|
||||
import { projectIdFor, resolveGitRoot, samePath } from "./project-paths"
|
||||
import { projectIdFor, resolveProjectRoot, samePath } from "./project-paths"
|
||||
import type { SidebarTarget } from "./project-route"
|
||||
|
||||
export interface ProjectMessageDeps {
|
||||
@@ -139,7 +139,9 @@ async function addProject(deps: ProjectMessageDeps): Promise<void> {
|
||||
if (disabled(deps)) return
|
||||
const dir = await deps.pickFolder()
|
||||
if (!dir) return
|
||||
const root = await resolveGitRoot(dir, (cwd) => simpleGit(cwd).revparse(["--show-toplevel"]))
|
||||
// resolveProjectRoot (not resolveGitRoot) so a folder inside a linked worktree
|
||||
// registers the primary checkout and cannot duplicate an existing project.
|
||||
const root = await resolveProjectRoot(dir, (cwd, args) => simpleGit(cwd).raw(args))
|
||||
if (!root) {
|
||||
deps.error("The selected folder is not inside a Git repository.")
|
||||
return
|
||||
|
||||
@@ -55,16 +55,6 @@ export function projectIdFor(root: string): string {
|
||||
return `prj-${createHash("sha1").update(root).digest("hex").slice(0, 12)}`
|
||||
}
|
||||
|
||||
/** Resolve the canonical Git top-level for a directory, or undefined when it is not inside a repository. */
|
||||
export async function resolveGitRoot(
|
||||
dir: string,
|
||||
revparse: (cwd: string) => Promise<string>,
|
||||
): Promise<string | undefined> {
|
||||
const top = await revparse(dir).catch(() => undefined)
|
||||
if (!top) return undefined
|
||||
return canonicalizePath(top.trim())
|
||||
}
|
||||
|
||||
/** Resolve linked worktrees to the primary checkout so project-local state is shared by the repository. */
|
||||
export async function resolveProjectRoot(
|
||||
dir: string,
|
||||
@@ -74,12 +64,13 @@ export async function resolveProjectRoot(
|
||||
Promise.resolve()
|
||||
.then(() => git(dir, args))
|
||||
.catch(() => undefined)
|
||||
const top = await run(["--path-format=absolute", "--show-toplevel"])
|
||||
const revparse = (args: string[]) => run(["rev-parse", ...args])
|
||||
const top = await revparse(["--path-format=absolute", "--show-toplevel"])
|
||||
if (!top) return undefined
|
||||
const root = canonicalizePath(top.trim())
|
||||
const [gitdir, common] = await Promise.all([
|
||||
run(["--path-format=absolute", "--git-dir"]),
|
||||
run(["--path-format=absolute", "--git-common-dir"]),
|
||||
revparse(["--path-format=absolute", "--git-dir"]),
|
||||
revparse(["--path-format=absolute", "--git-common-dir"]),
|
||||
])
|
||||
if (!gitdir || !common) return root
|
||||
if (samePath(canonicalizePath(gitdir.trim()), canonicalizePath(common.trim()))) return root
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { WorktreeStateManager } from "./WorktreeStateManager"
|
||||
import type { AgentManagerInMessage } from "./types"
|
||||
|
||||
const SECTION_TYPES = new Set<string>([
|
||||
"agentManager.createSection",
|
||||
"agentManager.renameSection",
|
||||
"agentManager.deleteSection",
|
||||
"agentManager.setSectionColor",
|
||||
"agentManager.toggleSectionCollapsed",
|
||||
"agentManager.moveToSection",
|
||||
"agentManager.moveSection",
|
||||
])
|
||||
|
||||
/** Handle section CRUD messages. Returns true if handled. */
|
||||
export function handleSection(
|
||||
state: WorktreeStateManager | undefined,
|
||||
@@ -9,8 +19,7 @@ export function handleSection(
|
||||
log?: (...args: unknown[]) => void,
|
||||
): boolean {
|
||||
if (!state) {
|
||||
if (m.type.startsWith("agentManager.") && m.type.includes("ection"))
|
||||
log?.(`handleSection: ${m.type} dropped, state missing`)
|
||||
if (SECTION_TYPES.has(m.type)) log?.(`handleSection: ${m.type} dropped, state missing`)
|
||||
return false
|
||||
}
|
||||
if (m.type === "agentManager.createSection") state.addSection(m.name, m.color ?? null, m.worktreeIds)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Messages that mutate or read a project's persisted state and therefore must
|
||||
* wait for that project's state to load before dispatch.
|
||||
*/
|
||||
export const STATE_GATED = new Set<string>([
|
||||
"agentManager.createWorktree",
|
||||
"agentManager.promoteSession",
|
||||
"agentManager.createMultiVersion",
|
||||
"agentManager.deleteWorktree",
|
||||
"agentManager.removeStaleWorktree",
|
||||
"agentManager.openLocally",
|
||||
"agentManager.addSessionToWorktree",
|
||||
"agentManager.closeSession",
|
||||
"agentManager.persistSession",
|
||||
"agentManager.forgetSession",
|
||||
"agentManager.renameWorktree",
|
||||
"agentManager.requestBranches",
|
||||
"agentManager.importFromBranch",
|
||||
"agentManager.importFromPR",
|
||||
"agentManager.importExternalWorktree",
|
||||
"agentManager.importAllExternalWorktrees",
|
||||
"agentManager.setTabOrder",
|
||||
"agentManager.setWorktreeOrder",
|
||||
"agentManager.setSessionsCollapsed",
|
||||
"agentManager.setSidebarCollapsed",
|
||||
"agentManager.setReviewDiffStyle",
|
||||
"agentManager.setDefaultBaseBranch",
|
||||
"agentManager.createSection",
|
||||
"agentManager.renameSection",
|
||||
"agentManager.deleteSection",
|
||||
"agentManager.setSectionColor",
|
||||
"agentManager.toggleSectionCollapsed",
|
||||
"agentManager.moveToSection",
|
||||
"agentManager.moveSection",
|
||||
])
|
||||
@@ -96,8 +96,14 @@ export function indexingConsentStore(context: vscode.ExtensionContext): Indexing
|
||||
}
|
||||
|
||||
export function registeredProjects(context: vscode.ExtensionContext) {
|
||||
return new ProjectRegistry({
|
||||
read: () => context.globalState.get("agentManager.projects"),
|
||||
write: (value) => Promise.resolve(context.globalState.update("agentManager.projects", value)),
|
||||
}).list()
|
||||
return (
|
||||
new ProjectRegistry({
|
||||
read: () => context.globalState.get("agentManager.projects"),
|
||||
write: (value) => Promise.resolve(context.globalState.update("agentManager.projects", value)),
|
||||
})
|
||||
.list()
|
||||
// Consent must never be offered for a repository the user has not trusted:
|
||||
// enabling indexing sends the directory to the backend for reading.
|
||||
.filter((project) => project.trusted)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ export class ConfigBindings {
|
||||
|
||||
create(input: Omit<ConfigBinding, "id">): ConfigBinding {
|
||||
const binding = { ...input, id: randomUUID() }
|
||||
// Only the newest binding per scope+directory can still be saved against,
|
||||
// so drop the superseded ones instead of growing forever on read-only
|
||||
// refreshes (external config edits re-issue bindings without consuming).
|
||||
for (const [id, existing] of this.bindings) {
|
||||
if (existing.scope === binding.scope && existing.directory === binding.directory) this.bindings.delete(id)
|
||||
}
|
||||
this.bindings.set(binding.id, binding)
|
||||
return binding
|
||||
}
|
||||
|
||||
@@ -44,6 +44,15 @@ function field(page: Page, title: string) {
|
||||
return page.locator('[data-slot="settings-row"]', { hasText: title }).locator("input")
|
||||
}
|
||||
|
||||
// Scope select triggers by their row title: the tab gained a project selector,
|
||||
// so positional lookups silently target the wrong control.
|
||||
function selectIn(page: Page, title: string) {
|
||||
return page
|
||||
.locator('[data-slot="settings-row"]', { hasText: title })
|
||||
.locator('[data-slot="select-select-trigger"]')
|
||||
.first()
|
||||
}
|
||||
|
||||
test("provider switch writes to selected provider bucket", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 420, height: 720 })
|
||||
await page.goto(storyUrl(), { waitUntil: "load" })
|
||||
@@ -52,7 +61,7 @@ test("provider switch writes to selected provider bucket", async ({ page }) => {
|
||||
|
||||
const saved = page.getByTestId("indexing-provider-save")
|
||||
|
||||
const trigger = page.locator('[data-component="select"] [data-slot="select-select-trigger"]').first()
|
||||
const trigger = selectIn(page, "Embedding provider")
|
||||
await trigger.click()
|
||||
await page.locator('[data-slot="select-select-item-label"]', { hasText: "Gemini" }).click()
|
||||
|
||||
@@ -123,7 +132,7 @@ test("Kilo exposes only supported embedding model presets", async ({ page }) =>
|
||||
await expect(page.getByText("Embedding model", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByText("Vector dimension", { exact: true })).toBeVisible()
|
||||
|
||||
const preset = page.locator('[data-component="select"] [data-slot="select-select-trigger"]').nth(1)
|
||||
const preset = selectIn(page, "Kilo model preset")
|
||||
await expect(preset).toContainText("Provider Model")
|
||||
|
||||
const dimension = field(page, "Vector dimension").first()
|
||||
|
||||
@@ -223,7 +223,7 @@ describe("Agent Manager Provider Messages", () => {
|
||||
})
|
||||
|
||||
it("state-mutating messages wait for state initialization", () => {
|
||||
const body = getMethodBody("shouldWaitForState")
|
||||
const body = fs.readFileSync(path.join(ROOT, "src/agent-manager/state-gate.ts"), "utf-8")
|
||||
const messages = [
|
||||
"agentManager.setTabOrder",
|
||||
"agentManager.setWorktreeOrder",
|
||||
|
||||
@@ -79,9 +79,9 @@ describe("project-paths", () => {
|
||||
|
||||
it("resolveProjectRoot maps a linked worktree to the primary checkout", async () => {
|
||||
const calls = new Map([
|
||||
["--path-format=absolute --show-toplevel", "/repo/worktree"],
|
||||
["--path-format=absolute --git-dir", "/repo/.git/worktrees/feature"],
|
||||
["--path-format=absolute --git-common-dir", "/repo/.git"],
|
||||
["rev-parse --path-format=absolute --show-toplevel", "/repo/worktree"],
|
||||
["rev-parse --path-format=absolute --git-dir", "/repo/.git/worktrees/feature"],
|
||||
["rev-parse --path-format=absolute --git-common-dir", "/repo/.git"],
|
||||
["worktree list --porcelain -z", "worktree /repo\0HEAD abc\0\0worktree /repo/worktree\0HEAD def\0"],
|
||||
])
|
||||
const root = await resolveProjectRoot("/repo/worktree", async (_cwd, args) => calls.get(args.join(" ")) ?? "")
|
||||
|
||||
@@ -1593,8 +1593,12 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
applyProjectSelection(msg, {
|
||||
managed: (projectId) => projectLive.sessions()[projectId] ?? projectStates()[projectId]?.sessions ?? [],
|
||||
local: selectLocal,
|
||||
worktree: selectWorktree,
|
||||
local: () => selectLocal(),
|
||||
// Act on the worktree only when the applied state already knows it,
|
||||
// otherwise the ack raced ahead of that project's state push.
|
||||
worktree: (projectId, worktreeId) => {
|
||||
if (projectStates()[projectId]?.worktrees.some((wt) => wt.id === worktreeId)) selectWorktree(worktreeId)
|
||||
},
|
||||
session: session.selectSession,
|
||||
managedSession: focusManagedSession,
|
||||
})
|
||||
@@ -2369,6 +2373,7 @@ const AgentManagerContent: Component = () => {
|
||||
sessions={projectSessionsLive()}
|
||||
selectedProject={activeProjectId()}
|
||||
selection={selection() ?? undefined}
|
||||
currentSessionID={session.currentSessionID}
|
||||
bindings={kb()}
|
||||
t={t}
|
||||
onSearchRef={(ref) => (sidebarSearchMenu = ref)}
|
||||
|
||||
@@ -29,6 +29,7 @@ interface Props {
|
||||
sessions: Record<string, ProjectSessionInfo[]>
|
||||
selectedProject?: string
|
||||
selection?: string
|
||||
currentSessionID?: () => string | undefined
|
||||
busy?: (id: string) => boolean
|
||||
bindings: Record<string, string>
|
||||
t: LanguageContextValue["t"]
|
||||
@@ -102,10 +103,21 @@ export const ProjectList: Component<Props> = (props) => {
|
||||
const current = createMemo(() => {
|
||||
const projectId = props.selectedProject
|
||||
if (!projectId) return
|
||||
if (props.selection === LOCAL) return search().find((item) => item.key === `${projectId}:local`)
|
||||
return search().find(
|
||||
const worktree = search().find(
|
||||
(item) => item.projectId === projectId && item.kind === "worktree" && item.worktreeId === props.selection,
|
||||
)
|
||||
if (worktree) return worktree
|
||||
// On LOCAL, an open session tab is the active item when there is one, so the
|
||||
// menu highlights the same row the sidebar does.
|
||||
const session = props.currentSessionID?.()
|
||||
if (session) {
|
||||
const match = search().find(
|
||||
(item) => item.projectId === projectId && item.kind === "session" && item.sessionId === session,
|
||||
)
|
||||
if (match) return match
|
||||
}
|
||||
if (props.selection === LOCAL) return search().find((item) => item.key === `${projectId}:local`)
|
||||
return undefined
|
||||
})
|
||||
const selectSearch = (item: SidebarSearchItem) => {
|
||||
if (!item.projectId) return
|
||||
|
||||
@@ -69,11 +69,22 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
const post = (message: Record<string, unknown>) =>
|
||||
vscode.postMessage({ ...message, projectId: props.project.id } as never)
|
||||
|
||||
// Escape unmounts the focused rename input, which fires a synchronous blur
|
||||
// that would re-commit the cancelled value; this flag swallows that blur.
|
||||
let cancelled = false
|
||||
const commitRename = (worktreeId: string) => {
|
||||
if (cancelled) {
|
||||
cancelled = false
|
||||
return
|
||||
}
|
||||
const label = name().trim()
|
||||
setRenaming(undefined)
|
||||
if (label) post({ type: "agentManager.renameWorktree", worktreeId, label })
|
||||
}
|
||||
const cancelRename = () => {
|
||||
cancelled = true
|
||||
setRenaming(undefined)
|
||||
}
|
||||
|
||||
const renderWorktree = (worktree: NonNullable<Props["state"]>["worktrees"][number]) => (
|
||||
<WorktreeItem
|
||||
@@ -124,7 +135,7 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
}}
|
||||
onRenameInput={setName}
|
||||
onCommitRename={() => commitRename(worktree.id)}
|
||||
onCancelRename={() => setRenaming(undefined)}
|
||||
onCancelRename={cancelRename}
|
||||
onRemoveStale={() => post({ type: "agentManager.removeStaleWorktree", worktreeId: worktree.id })}
|
||||
onCopyPath={() => navigator.clipboard.writeText(worktree.path)}
|
||||
onOpen={() => post({ type: "agentManager.openWorktree", worktreeId: worktree.id })}
|
||||
|
||||
@@ -4,21 +4,23 @@ export function applyProjectSelection(
|
||||
msg: ExtensionMessage,
|
||||
deps: {
|
||||
managed: (projectId: string) => ManagedSessionState[]
|
||||
local: () => void
|
||||
worktree: (worktreeId: string) => void
|
||||
local: (projectId: string) => void
|
||||
worktree: (projectId: string, worktreeId: string) => void
|
||||
session: (sessionId: string) => void
|
||||
managedSession: (worktreeId: string, sessionId: string) => void
|
||||
},
|
||||
): boolean {
|
||||
if (msg.type !== "agentManager.selectionActivated") return false
|
||||
const target = msg.target
|
||||
if (target.kind === "local") deps.local()
|
||||
if (target.kind === "worktree") deps.worktree(target.worktreeId)
|
||||
// Scope by project like the session branch: a selection ack must never act on
|
||||
// another project's data if it lands before that project's state push.
|
||||
if (target.kind === "local") deps.local(target.projectId)
|
||||
if (target.kind === "worktree") deps.worktree(target.projectId, target.worktreeId)
|
||||
if (target.kind === "session") {
|
||||
const session = deps.managed(target.projectId).find((item) => item.id === target.sessionId)
|
||||
if (session?.worktreeId) deps.managedSession(session.worktreeId, target.sessionId)
|
||||
else {
|
||||
deps.local()
|
||||
deps.local(target.projectId)
|
||||
deps.session(target.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Card } from "@kilocode/kilo-ui/card"
|
||||
import { DEFAULT_VECTOR_STORE, isFileExtension, parseFileExtensions } from "@kilocode/kilo-indexing/config"
|
||||
import { formatKiloEmbeddingModelLabel, getKiloEmbeddingModel } from "@kilocode/kilo-indexing/embedding-models"
|
||||
@@ -18,6 +19,8 @@ import SettingsRow from "./SettingsRow"
|
||||
import {
|
||||
indexingConfig,
|
||||
indexingDescription,
|
||||
indexingEnabled,
|
||||
indexingEnabledInherited,
|
||||
indexingInheritance,
|
||||
indexingSource,
|
||||
indexingUpdate,
|
||||
@@ -91,8 +94,75 @@ function providerFields(provider: ProviderId | undefined): Array<{ key: string;
|
||||
return []
|
||||
}
|
||||
|
||||
/** Config scope switcher plus the scope-derived enable switch. */
|
||||
const ScopeRows: Component<{
|
||||
scope: IndexingScope
|
||||
enabled: boolean
|
||||
inherited: boolean
|
||||
t: (key: string) => string
|
||||
tag: () => string | undefined
|
||||
onScope: (next: IndexingScope) => void
|
||||
onEnabled: (next: boolean) => void
|
||||
}> = (props) => {
|
||||
const scope = () => props.scope
|
||||
const language = { t: props.t }
|
||||
const enabled = () => props.enabled
|
||||
const inherited = () => props.inherited
|
||||
const changeScope = props.onScope
|
||||
const saveEnabled = props.onEnabled
|
||||
const tag = props.tag
|
||||
return (
|
||||
<>
|
||||
<SettingsRow
|
||||
title="Configuration scope"
|
||||
description={
|
||||
scope() === "global"
|
||||
? language.t("settings.indexing.globalEnable.description")
|
||||
: language.t("settings.indexing.projectEnable.description")
|
||||
}
|
||||
>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<Button
|
||||
variant={scope() === "global" ? "primary" : "secondary"}
|
||||
size="small"
|
||||
onClick={() => changeScope("global")}
|
||||
>
|
||||
{language.t("settings.config.scope.global")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={scope() === "project" ? "primary" : "secondary"}
|
||||
size="small"
|
||||
onClick={() => changeScope("project")}
|
||||
>
|
||||
{language.t("settings.config.scope.local")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={
|
||||
scope() === "global"
|
||||
? language.t("settings.indexing.globalEnable.title")
|
||||
: language.t("settings.indexing.projectEnable.title")
|
||||
}
|
||||
description={
|
||||
inherited()
|
||||
? `Inherited from global config (${enabled() ? "on" : "off"}) until a project value is saved.`
|
||||
: language.t("settings.indexing.enable.description")
|
||||
}
|
||||
tag={tag}
|
||||
>
|
||||
<Switch checked={enabled()} onChange={saveEnabled} hideLabel>
|
||||
{scope() === "global"
|
||||
? language.t("settings.indexing.globalEnable.title")
|
||||
: language.t("settings.indexing.projectEnable.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const IndexingTab: Component = () => {
|
||||
const { globalConfig, projectConfig, settings, updateGlobalConfig, updateSetting } = useConfig()
|
||||
const { globalConfig, projectConfig, settings, updateGlobalConfig, updateProjectConfig, updateSetting } = useConfig()
|
||||
const indexing = useIndexing()
|
||||
const embeds = useKiloEmbeddingModels()
|
||||
const language = useLanguage()
|
||||
@@ -104,12 +174,14 @@ const IndexingTab: Component = () => {
|
||||
const [tuningDrafts, setTuningDrafts] = createSignal<Record<string, string>>({})
|
||||
const [extensionDrafts, setExtensionDrafts] = createSignal<Record<string, string>>({})
|
||||
const [extensionErrors, setExtensionErrors] = createSignal<Record<string, string>>({})
|
||||
const scope = () => "global" as const
|
||||
const [scope, setScope] = createSignal<IndexingScope>("global")
|
||||
|
||||
const globalCfg = createMemo<IndexingConfig>(() => globalConfig().indexing ?? {})
|
||||
const projectCfg = createMemo<IndexingConfig>(() => projectConfig().indexing ?? {})
|
||||
const raw = createMemo<IndexingConfig>(() => (scope() === "global" ? globalCfg() : projectCfg()))
|
||||
const cfg = createMemo<IndexingConfig>(() => indexingConfig(scope(), globalCfg(), projectCfg()))
|
||||
const enabled = createMemo(() => indexingEnabled(scope(), globalCfg(), projectCfg()))
|
||||
const inherited = createMemo(() => indexingEnabledInherited(scope(), globalCfg(), projectCfg()))
|
||||
const consent = createMemo(() => settings()["indexing.consent"] === true)
|
||||
const projects = createMemo(() => (settings()["indexing.projects"] as Project[] | undefined) ?? [])
|
||||
const projectId = createMemo(() => settings()["indexing.projectId"] as string | undefined)
|
||||
@@ -120,9 +192,20 @@ const IndexingTab: Component = () => {
|
||||
sourceLabel(indexingSource(current, globalCfg(), projectCfg(), paths)) || undefined
|
||||
const description = (value: string, paths: readonly (readonly string[])[]) =>
|
||||
indexingDescription(value, inheritance(paths))
|
||||
const changeScope = (next: IndexingScope) => {
|
||||
// Blur first so a pending field edit commits against the scope it was typed in.
|
||||
const active = document.activeElement
|
||||
if (active instanceof HTMLElement) active.blur()
|
||||
setScope(next)
|
||||
}
|
||||
|
||||
const updateIndexing = (partial: IndexingConfig) => {
|
||||
const patch = { indexing: indexingUpdate(scope(), globalCfg(), projectCfg(), partial) }
|
||||
updateGlobalConfig(patch)
|
||||
if (scope() === "global") {
|
||||
updateGlobalConfig(patch)
|
||||
return
|
||||
}
|
||||
updateProjectConfig(patch)
|
||||
}
|
||||
|
||||
const vectorStore = () => cfg().vectorStore ?? DEFAULT_VECTOR_STORE
|
||||
@@ -158,9 +241,23 @@ const IndexingTab: Component = () => {
|
||||
updateIndexing({ provider: next, model: null, dimension: null })
|
||||
}
|
||||
|
||||
const saveEnabled = (enabled: boolean) => {
|
||||
/** Machine-local consent for the selected project; never written to config. */
|
||||
const saveConsent = (granted: boolean) => {
|
||||
const id = projectId()
|
||||
if (id) vscode.postMessage({ type: "setIndexingConsent", projectId: id, enabled })
|
||||
if (id) vscode.postMessage({ type: "setIndexingConsent", projectId: id, enabled: granted })
|
||||
}
|
||||
|
||||
const saveEnabled = (next: boolean) => {
|
||||
if (next && !cfg().provider && kiloAvailable()) {
|
||||
updateIndexing({
|
||||
enabled: next,
|
||||
provider: "kilo",
|
||||
model: knownKiloModel(cfg().model) ?? (kiloDefault() || null),
|
||||
dimension: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
updateIndexing({ enabled: next })
|
||||
}
|
||||
|
||||
const saveModel = (value: string) => {
|
||||
@@ -257,7 +354,7 @@ const IndexingTab: Component = () => {
|
||||
setExtensionErrors((prev) => Object.fromEntries(Object.entries(prev).filter(([entry]) => entry !== scope())))
|
||||
}
|
||||
|
||||
const content = () => (
|
||||
const content = (_scope: IndexingScope) => (
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "16px" }}>
|
||||
<Card>
|
||||
<SettingsRow title="Project" description={project()?.root ?? "Select a project for indexing consent."}>
|
||||
@@ -282,10 +379,19 @@ const IndexingTab: Component = () => {
|
||||
title={language.t("settings.indexing.enable.title")}
|
||||
description={language.t("settings.indexing.enable.description")}
|
||||
>
|
||||
<Switch checked={consent()} onChange={saveEnabled} hideLabel>
|
||||
<Switch checked={consent()} onChange={saveConsent} hideLabel>
|
||||
{language.t("settings.indexing.enable.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
<ScopeRows
|
||||
scope={scope()}
|
||||
enabled={enabled()}
|
||||
inherited={inherited()}
|
||||
t={language.t}
|
||||
tag={() => tag(scope(), [["enabled"]])}
|
||||
onScope={changeScope}
|
||||
onEnabled={saveEnabled}
|
||||
/>
|
||||
<SettingsRow
|
||||
title={language.t("settings.indexing.showButton.title")}
|
||||
description={language.t("settings.indexing.showButton.description")}
|
||||
@@ -555,7 +661,11 @@ const IndexingTab: Component = () => {
|
||||
</div>
|
||||
)
|
||||
|
||||
return content()
|
||||
return (
|
||||
<Show when={scope()} keyed>
|
||||
{content}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default IndexingTab
|
||||
|
||||
@@ -59,7 +59,8 @@ type PluginSpec = string | [string, Record<string, unknown>]
|
||||
// Merged English dictionary (same merge order as the real LanguageProvider)
|
||||
const dict: Record<string, string> = { ...appEn, ...amEn, ...uiEn, ...kiloEn }
|
||||
|
||||
function t(key: string, params?: Record<string, string | number | boolean | undefined>) {
|
||||
/** Story-local translator. Usable outside the provider tree, unlike useLanguage. */
|
||||
export function t(key: string, params?: Record<string, string | number | boolean | undefined>) {
|
||||
return resolveTemplate(dict[key] ?? key, params)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "storybook-solidjs-vite"
|
||||
import { StoryProviders, defaultMockData, mockSessionValue } from "./StoryProviders"
|
||||
import { StoryProviders, defaultMockData, mockSessionValue, t } from "./StoryProviders"
|
||||
import { FileTree } from "../../diff-viewer/FileTree"
|
||||
import { DiffPanel } from "../../agent-manager/DiffPanel"
|
||||
import { FullScreenDiffView } from "../../diff-viewer/FullScreenDiffView"
|
||||
@@ -1117,7 +1117,6 @@ export const SidebarSearchOpen: Story = {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { ProjectList } from "../../agent-manager/ProjectList"
|
||||
import { useLanguage } from "../context/language"
|
||||
import type {
|
||||
AgentManagerStateMessage,
|
||||
AgentProjectSnapshot,
|
||||
@@ -1209,7 +1208,6 @@ const storyLocal = (branch: string, additions: number, deletions: number, ahead
|
||||
export const MultiProjectSidebar: Story = {
|
||||
name: "Project List — two expanded projects with restored controls",
|
||||
render: () => {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<StoryProviders noPadding>
|
||||
<div style={{ display: "flex", "flex-direction": "column", "max-height": "720px", overflow: "auto" }}>
|
||||
|
||||
@@ -21,17 +21,18 @@ export namespace KilocodeConfigWriter {
|
||||
directory: string
|
||||
worktree?: string
|
||||
scope: KilocodeConfigOverlay.Scope
|
||||
expected: { path: string; revision: string }
|
||||
expected?: { path: string; revision: string }
|
||||
set?: Record<string, unknown>
|
||||
unset?: string[][]
|
||||
write?: typeof Filesystem.write
|
||||
beforeWrite?: () => Promise<void>
|
||||
}): Promise<Result> {
|
||||
const target = await KilocodeConfigOverlay.target(input)
|
||||
if (target.path !== input.expected.path) {
|
||||
const expected = input.expected
|
||||
if (expected && target.path !== expected.path) {
|
||||
return { ok: false, code: "target-changed", message: "The authoritative config target changed.", target }
|
||||
}
|
||||
if (target.revision !== input.expected.revision) {
|
||||
if (expected && target.revision !== expected.revision) {
|
||||
return { ok: false, code: "revision-conflict", message: "The config file changed since it was read.", target }
|
||||
}
|
||||
if (!target.writable) {
|
||||
@@ -48,7 +49,7 @@ export namespace KilocodeConfigWriter {
|
||||
await mkdir(path.dirname(target.path), { recursive: true })
|
||||
await input.beforeWrite?.()
|
||||
const checked = await KilocodeConfigOverlay.target(input)
|
||||
if (checked.path !== input.expected.path || !checked.writable) {
|
||||
if ((expected && checked.path !== expected.path) || !checked.writable) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "target-not-writable",
|
||||
@@ -58,8 +59,8 @@ export namespace KilocodeConfigWriter {
|
||||
}
|
||||
const before = checked.exists ? await Bun.file(checked.path).text() : "{}"
|
||||
if (
|
||||
KilocodeConfigOverlay.revision(checked.path, checked.exists, checked.exists ? before : "") !==
|
||||
input.expected.revision
|
||||
expected &&
|
||||
KilocodeConfigOverlay.revision(checked.path, checked.exists, checked.exists ? before : "") !== expected.revision
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -61,7 +61,9 @@ export const ConfigOverlayPatch = Schema.Struct({
|
||||
scope: Scope,
|
||||
set: Schema.optional(UnknownRecord),
|
||||
unset: Schema.optional(Schema.Array(Schema.Array(Schema.String))),
|
||||
expected: Schema.Struct({ path: Schema.String, revision: Schema.String }),
|
||||
// Optional: clients that did not read a revision (anything but the settings
|
||||
// page) still write unconditionally instead of failing the request.
|
||||
expected: Schema.optional(Schema.Struct({ path: Schema.String, revision: Schema.String })),
|
||||
})
|
||||
export class ConfigOverlayConflictError extends Schema.ErrorClass<ConfigOverlayConflictError>(
|
||||
"ConfigOverlayConflictError",
|
||||
|
||||
@@ -77,7 +77,7 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf
|
||||
set: ctx.payload.set ? { ...ctx.payload.set } : undefined,
|
||||
unset: ctx.payload.unset?.map((item) => [...item]),
|
||||
}
|
||||
const expected = { ...body.expected }
|
||||
const expected = body.expected ? { ...body.expected } : undefined
|
||||
const instance = yield* InstanceState.context
|
||||
const result = yield* flock
|
||||
.withLock(
|
||||
@@ -89,7 +89,7 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf
|
||||
expected,
|
||||
}),
|
||||
),
|
||||
`config:${body.scope}:${expected.path}`,
|
||||
`config:${body.scope}:${expected?.path ?? "target"}`,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -218,6 +218,11 @@ export const kiloScenarios: Scenario[] = [
|
||||
http.protected.get("/indexing/status", "indexing.status").json(200, object),
|
||||
http.protected.get("/indexing/models", "indexing.models").json(200, object),
|
||||
http.protected.get("/indexing/warnings", "indexing.warnings").json(200, array),
|
||||
http.protected
|
||||
.put("/indexing/consent", "indexing.consent")
|
||||
.mutating()
|
||||
.at((ctx) => ({ path: "/indexing/consent", headers: ctx.headers(), body: { enabled: false } }))
|
||||
.json(200, object),
|
||||
http.protected.get("/memory/status", "memory.status").json(200, (body) => {
|
||||
object(body)
|
||||
object(body.state)
|
||||
|
||||
@@ -11240,7 +11240,7 @@ export type ConfigOverlayUpdateData = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
unset?: Array<Array<string>>
|
||||
expected: {
|
||||
expected?: {
|
||||
path: string
|
||||
revision: string
|
||||
}
|
||||
|
||||
@@ -11563,7 +11563,7 @@
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["scope", "expected"],
|
||||
"required": ["scope"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -26122,7 +26122,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start": {
|
||||
"type": "number",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
@@ -26198,11 +26198,11 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start": {
|
||||
"type": "number",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"end": {
|
||||
"type": "number",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"elapsed": {
|
||||
|
||||
Reference in New Issue
Block a user