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:
marius-kilocode
2026-07-28 10:32:05 +02:00
parent 46b7e55f8d
commit 80321b861f
28 changed files with 328 additions and 102 deletions
@@ -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">
+8 -2
View File
@@ -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",
])
+10 -4
View File
@@ -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)
+1 -1
View File
@@ -11240,7 +11240,7 @@ export type ConfigOverlayUpdateData = {
[key: string]: unknown
}
unset?: Array<Array<string>>
expected: {
expected?: {
path: string
revision: string
}
+4 -4
View File
@@ -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": {