fix(vscode): share model picker expand state across webviews (#11910)

Persist the model selector expand/collapse preference in extension
globalState instead of per-webview state, and broadcast changes through
KiloConnectionService so every live webview (sidebar and Agent Manager
panel) stays in sync.
This commit is contained in:
Johnny Eric Amancio
2026-07-03 14:55:30 +02:00
committed by GitHub
parent c36c293f3c
commit 5c4ce7251a
10 changed files with 96 additions and 18 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Remember the model picker expand/collapse choice across the sidebar and the Agent Manager
+28
View File
@@ -172,6 +172,10 @@ let maxCost = 0
type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile"
type ContextMessage = { contextDirectory?: unknown }
type TypedWebviewMessage = {
type: string
value?: unknown
}
type SandboxSupportClient = {
support: (
parameters: { directory?: string },
@@ -392,6 +396,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private unsubscribeLanguageChange: (() => void) | null = null
private unsubscribeProfileChange: (() => void) | null = null
private unsubscribeFavoritesChange: (() => void) | null = null
private unsubscribeModelSelectorExpanded: (() => void) | null = null
private unsubscribeMigrationComplete: (() => void) | null = null // legacy-migration
private unsubscribeClearPendingPrompts: (() => void) | null = null
private unsubscribeDirectoryProvider: (() => void) | null = null
@@ -924,6 +929,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
) {
return
}
if (await this.handleModelSelectorExpandedMessage(message)) return
this.visibleTaskStreams.handle(message)
switch (message.type) {
case "webviewReady":
@@ -1426,6 +1432,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
}
private async handleModelSelectorExpandedMessage(message: TypedWebviewMessage): Promise<boolean> {
if (message.type === "persistModelSelectorExpanded") {
if (typeof message.value !== "boolean") return true
await this.extensionContext?.globalState.update("modelSelectorExpanded", message.value)
this.connectionService.notifyModelSelectorExpandedChanged(message.value)
return true
}
if (message.type === "requestModelSelectorExpanded") {
const value = this.extensionContext?.globalState.get("modelSelectorExpanded", true) ?? true
this.postMessage({ type: "modelSelectorExpandedLoaded", value })
return true
}
return false
}
private async toggleFavorite(message: {
action: "add" | "remove"
providerID: string
@@ -1472,6 +1493,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.unsubscribeLanguageChange?.()
this.unsubscribeProfileChange?.()
this.unsubscribeFavoritesChange?.()
this.unsubscribeModelSelectorExpanded?.()
this.unsubscribeClearPendingPrompts?.()
this.unsubscribeDirectoryProvider?.()
@@ -1574,6 +1596,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage({ type: "favoritesLoaded", favorites })
})
// Subscribe to model-selector expand/collapse broadcast from other KiloProvider instances
this.unsubscribeModelSelectorExpanded = this.connectionService.onModelSelectorExpandedChanged((value) => {
this.postMessage({ type: "modelSelectorExpandedLoaded", value })
})
// legacy-migration start
// Subscribe to migration-complete broadcast from any KiloProvider instance
this.unsubscribeMigrationComplete = this.connectionService.onMigrationComplete(() => {
@@ -4218,6 +4245,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.unsubscribeLanguageChange?.()
this.unsubscribeProfileChange?.()
this.unsubscribeFavoritesChange?.()
this.unsubscribeModelSelectorExpanded?.()
this.unsubscribeMigrationComplete?.()
this.unsubscribeClearPendingPrompts?.()
this.unsubscribeDirectoryProvider?.()
@@ -15,6 +15,7 @@ type LanguageChangeListener = (locale: string) => void
type ProfileChangeListener = (data: unknown) => void
type MigrationCompleteListener = () => void
type FavoritesChangeListener = (favorites: Array<{ providerID: string; modelID: string }>) => void
type ModelSelectorExpandedListener = (value: boolean) => void
type ClearPendingPromptsListener = () => void
type DirectoryProvider = () => string[]
@@ -70,6 +71,7 @@ export class KiloConnectionService {
private readonly profileChangeListeners: Set<ProfileChangeListener> = new Set()
private readonly migrationCompleteListeners: Set<MigrationCompleteListener> = new Set()
private readonly favoritesChangeListeners: Set<FavoritesChangeListener> = new Set()
private readonly modelSelectorExpandedListeners: Set<ModelSelectorExpandedListener> = new Set()
private readonly clearPendingPromptsListeners: Set<ClearPendingPromptsListener> = new Set()
private readonly directoryProviders: Set<DirectoryProvider> = new Set()
private rootDirectory: string | undefined = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
@@ -442,6 +444,25 @@ export class KiloConnectionService {
}
}
/**
* Subscribe to model-selector expand/collapse changes broadcast from any KiloProvider. Returns unsubscribe function.
*/
onModelSelectorExpandedChanged(listener: ModelSelectorExpandedListener): () => void {
this.modelSelectorExpandedListeners.add(listener)
return () => {
this.modelSelectorExpandedListeners.delete(listener)
}
}
/**
* Broadcast a model-selector expand/collapse change to all subscribed KiloProvider instances.
*/
notifyModelSelectorExpandedChanged(value: boolean): void {
for (const listener of this.modelSelectorExpandedListeners) {
listener(value)
}
}
/**
* Subscribe to clear-pending-prompts broadcast. Returns unsubscribe function.
* Fired after a config save drains all pending permissions/questions so each
@@ -65,6 +65,7 @@ function connection() {
onProfileChanged: () => () => undefined,
onMigrationComplete: () => () => undefined,
onFavoritesChanged: () => () => undefined,
onModelSelectorExpandedChanged: () => () => undefined,
registerDirectoryProvider: () => () => undefined,
getServerInfo: () => ({ port: 12345 }),
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:12345", password: "test" }),
@@ -187,6 +187,7 @@ function createConnection(client: ReturnType<typeof createClient>) {
onProfileChanged: () => () => undefined,
onMigrationComplete: () => () => undefined,
onFavoritesChanged: () => () => undefined,
onModelSelectorExpandedChanged: () => () => undefined,
onClearPendingPrompts: () => () => undefined,
registerDirectoryProvider: () => () => undefined,
getServerInfo: () => ({ port: 12345 }),
@@ -86,6 +86,7 @@ function createConnection(client: ReturnType<typeof createClient>) {
onProfileChanged: () => () => undefined,
onMigrationComplete: () => () => undefined,
onFavoritesChanged: () => () => undefined,
onModelSelectorExpandedChanged: () => () => undefined,
onClearPendingPrompts: () => () => undefined,
registerDirectoryProvider: () => () => undefined,
getServerInfo: () => ({ port: 12345 }),
@@ -155,9 +155,9 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
})
const [open, setOpen] = createSignal(false)
const [expanded, setExpanded] = createSignal(vscode.getModelSelectorExpanded())
// Persist the user's expand/collapse choice so it is restored on reopen.
createEffect(() => vscode.setModelSelectorExpanded(expanded()))
// Shared, host-persisted expand/collapse preference (see VSCodeProvider).
const expanded = vscode.getModelSelectorExpanded
const setExpanded = vscode.setModelSelectorExpanded
const [search, setSearch] = createSignal("")
const [debouncedSearch, setDebouncedSearch] = createSignal("")
const [selectedKey, setSelectedKey] = createSignal(CLEAR_KEY)
@@ -828,13 +828,11 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
aria-expanded={expanded()}
aria-controls={previewID}
onClick={() => {
setExpanded((v) => {
if (v) {
setPreActiveKey(null)
setPreviewKey(null)
}
return !v
})
if (expanded()) {
setPreActiveKey(null)
setPreviewKey(null)
}
setExpanded(!expanded())
requestAnimationFrame(() => {
searchRef?.focus()
scrollRow(preActiveKey() ?? selectedKey(), "nearest")
@@ -3,12 +3,11 @@
* Provides access to the VS Code webview API for posting messages
*/
import { createContext, useContext, onCleanup, ParentComponent } from "solid-js"
import { createContext, useContext, onCleanup, ParentComponent, createSignal } from "solid-js"
import type { VSCodeAPI, WebviewMessage, ExtensionMessage } from "../types/messages"
// Get the VS Code API (only available in webview context)
let vscodeApi: VSCodeAPI | undefined
const EXPANDED = "modelSelectorExpanded"
export function getVSCodeAPI(): VSCodeAPI {
if (!vscodeApi) {
@@ -44,6 +43,11 @@ export const VSCodeProvider: ParentComponent = (props) => {
const api = getVSCodeAPI()
const handlers = new Set<(message: ExtensionMessage) => void>()
// Model-selector expand/collapse preference. Stored in extension globalState
// so it is shared across webviews (sidebar + agent-manager panel); a local
// signal mirrors it for synchronous reads.
const [expanded, setExpanded] = createSignal(true)
// Listen for messages from the extension
const messageListener = (event: MessageEvent) => {
const message = event.data as ExtensionMessage
@@ -51,6 +55,10 @@ export const VSCodeProvider: ParentComponent = (props) => {
}
window.addEventListener("message", messageListener)
handlers.add((message) => {
if (message?.type === "modelSelectorExpandedLoaded") setExpanded(message.value)
})
api.postMessage({ type: "requestModelSelectorExpanded" })
onCleanup(() => {
window.removeEventListener("message", messageListener)
@@ -67,13 +75,10 @@ export const VSCodeProvider: ParentComponent = (props) => {
},
getState: <T,>() => api.getState() as T | undefined,
setState: <T,>(state: T) => api.setState(state),
getModelSelectorExpanded: () => {
const state = api.getState() as Record<string, unknown> | undefined
return state?.[EXPANDED] !== false
},
getModelSelectorExpanded: expanded,
setModelSelectorExpanded: (value: boolean) => {
const state = (api.getState() as Record<string, unknown> | undefined) ?? {}
api.setState({ ...state, [EXPANDED]: value })
setExpanded(value)
api.postMessage({ type: "persistModelSelectorExpanded", value })
},
}
@@ -738,6 +738,12 @@ export interface RecentsLoadedMessage {
recents: ModelSelection[]
}
// Persisted model-selector expand/collapse preference (extension → webview)
export interface ModelSelectorExpandedLoadedMessage {
type: "modelSelectorExpandedLoaded"
value: boolean
}
export interface FavoritesLoadedMessage {
type: "favoritesLoaded"
favorites: ModelSelection[]
@@ -1200,6 +1206,7 @@ export type ExtensionMessage =
| AnacondaDesktopExtensionMessage
| CustomProviderModelsFetchedMessage
| RecentsLoadedMessage
| ModelSelectorExpandedLoadedMessage
| FavoritesLoadedMessage
| ModelSelectionsLoadedMessage
| LanguageChangedMessage
@@ -1067,6 +1067,15 @@ export interface RequestRecentsMessage {
type: "requestRecents"
}
export interface PersistModelSelectorExpandedRequest {
type: "persistModelSelectorExpanded"
value: boolean
}
export interface RequestModelSelectorExpandedMessage {
type: "requestModelSelectorExpanded"
}
export interface ToggleFavoriteRequest {
type: "toggleFavorite"
action: "add" | "remove"
@@ -1349,6 +1358,8 @@ export type WebviewMessage =
| FetchCustomProviderModelsMessage
| PersistRecentsRequest
| RequestRecentsMessage
| PersistModelSelectorExpandedRequest
| RequestModelSelectorExpandedMessage
| ToggleFavoriteRequest
| RequestFavoritesMessage
| PersistModelSelectionRequest