feat(agent-manager): match new worktree dialog prompt pills to sidebar (#8713)

The New Worktree dialog prompt input now mirrors the sidebar chat
exactly: mode selector, model selector, thinking effort (reasoning),
and a reset button — in the same order with the same defaults.

- Extract ThinkingSelectorBase from ThinkingSelector for reuse outside
  session context (same pattern as ModelSelectorBase/ModeSwitcherBase)
- Initialize model/variant/agent from session defaults instead of null
- Show reset button only when model differs from config default
- Hide model/thinking/reset pills in compare mode (each session uses
  its model's default variant)
- Extract message-files and continue-worktree helpers from
  KiloProvider to fix pre-existing lint cap violations
- Extract onRequestState from AgentManagerProvider.onMessage to fix
  pre-existing complexity violation
- Remove orphaned open-sessions.ts (dead code flagged by knip)
This commit is contained in:
Marius
2026-04-10 12:00:47 +02:00
committed by GitHub
parent 24589dba3a
commit 2a5c0793a1
12 changed files with 227 additions and 125 deletions
+22 -60
View File
@@ -1,7 +1,5 @@
/* eslint-disable max-lines -- TODO: refactor to reduce file size and remove this disable */
import * as path from "path"
import * as vscode from "vscode"
import { z } from "zod"
import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "./image-preview"
import { isAbsolutePath } from "./path-utils"
import type {
@@ -42,6 +40,8 @@ import { resolveProjectDirectory } from "./project-directory"
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
import { retry } from "./services/cli-backend/retry"
import { slimPart, slimParts } from "./kilo-provider/slim-metadata"
import { handleContinueInWorktree } from "./kilo-provider/continue-worktree"
import { parseMessageFiles } from "./kilo-provider/message-files"
import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
import { childID } from "./kilo-provider/task-session"
import { retryable, backoff, MAX_RETRIES } from "./util/retry"
@@ -527,7 +527,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
*/
private setupWebviewMessageHandler(webview: vscode.Webview): void {
this.webviewMessageDisposable?.dispose()
// eslint-disable-next-line complexity -- TODO: refactor to reduce complexity and remove this disable
this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => {
// Run interceptor if attached (e.g., AgentManagerProvider worktree logic)
if (this.onBeforeMessage) {
@@ -551,17 +550,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.readyResolvers.splice(0).forEach((r) => r())
break
case "sendMessage": {
const files = z
.array(
z.object({
mime: z.string(),
url: z.string().refine((u) => u.startsWith("file://") || u.startsWith("data:")),
filename: z.string().optional(),
}),
)
.optional()
.catch(undefined)
.parse(message.files)
const files = parseMessageFiles(message.files)
await this.handleSendMessage(
message.text,
typeof message.messageID === "string" ? message.messageID : undefined,
@@ -576,17 +565,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
break
}
case "sendCommand": {
const files = z
.array(
z.object({
mime: z.string(),
url: z.string().refine((u) => u.startsWith("file://") || u.startsWith("data:")),
filename: z.string().optional(),
}),
)
.optional()
.catch(undefined)
.parse(message.files)
const files = parseMessageFiles(message.files)
await this.handleSendCommand(
message.command,
message.arguments,
@@ -667,9 +646,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
await handleRefreshProfile(this.authCtx)
break
case "openExternal":
if (message.url) {
vscode.env.openExternal(vscode.Uri.parse(message.url))
}
this.openExternal(message.url)
break
case "openSettingsPanel":
vscode.commands.executeCommand("kilo-code.new.settingsButtonClicked", message.tab)
@@ -684,30 +661,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
vscode.commands.executeCommand("kilo-code.new.showChanges")
break
case "openDiffVirtual":
if (this.diffVirtualProvider && message.diff) {
this.diffVirtualProvider.open(message.diff)
}
this.openDiffVirtual(message.diff)
break
case "continueInWorktree":
if (message.sessionId && this.continueInWorktreeHandler) {
this.continueInWorktreeHandler(message.sessionId, (status: string, detail?: string, error?: string) => {
this.postMessage({ type: "continueInWorktreeProgress", status, detail, error })
}).catch((err: unknown) => {
console.error("[Kilo New] continueInWorktree failed:", err)
this.postMessage({
type: "continueInWorktreeProgress",
status: "error",
error: err instanceof Error ? err.message : String(err),
})
})
} else if (message.sessionId) {
console.error("[Kilo New] continueInWorktree: no handler registered")
this.postMessage({
type: "continueInWorktreeProgress",
status: "error",
error: "Continue in Worktree is not available",
})
}
handleContinueInWorktree({
sessionId: message.sessionId,
handler: this.continueInWorktreeHandler ?? undefined,
post: (msg) => this.postMessage(msg),
})
break
case "retryConnection":
console.log("[Kilo New] KiloProvider: 🔄 Retrying connection...")
@@ -909,16 +870,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
void handleRequestCloudSessionData(this.cloudSessionCtx, message.sessionId)
break
case "importAndSend": {
const files = z
.array(
z.object({
mime: z.string(),
url: z.string().refine((u) => u.startsWith("file://") || u.startsWith("data:")),
}),
)
.optional()
.catch(undefined)
.parse(message.files)
const files = parseMessageFiles(message.files)
void handleImportAndSend(
this.cloudSessionCtx,
message.cloudSessionId,
@@ -1069,6 +1021,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
}
private openExternal(url: unknown): void {
if (typeof url !== "string") return
void vscode.env.openExternal(vscode.Uri.parse(url))
}
private openDiffVirtual(diff: unknown): void {
if (!this.diffVirtualProvider || !diff) return
this.diffVirtualProvider.open(diff as import("./DiffVirtualProvider").DiffVirtualFile)
}
/**
* Initialize connection to the CLI backend server.
* Subscribes to the shared KiloConnectionService.
@@ -381,28 +381,7 @@ export class AgentManagerProvider implements Disposable {
private onStateMessage(m: AgentManagerInMessage): Record<string, unknown> | null | undefined {
if (m.type === "agentManager.requestState") {
void this.stateReady
?.then(() => {
if (!this.state) {
this.pushEmptyState()
return
}
this.pushState()
if (this.cachedWorktreeStats) this.postToWebview(this.cachedWorktreeStats)
if (this.cachedLocalStats) this.postToWebview(this.cachedLocalStats)
this.prBridge.replay()
if (this.state.getSessions().length > 0) {
this.panel?.sessions.refreshSessions()
}
})
.catch((err) => {
this.log("initializeState failed, pushing partial state:", err)
if (!this.state) {
this.pushEmptyState()
return
}
this.pushState()
})
this.onRequestState()
return null
}
@@ -416,7 +395,6 @@ export class AgentManagerProvider implements Disposable {
}
if (m.type === "agentManager.setWorktreeOrder") {
this.state?.setWorktreeOrder(m.order)
this.pushState()
return null
}
if (m.type === "agentManager.setSessionsCollapsed") {
@@ -500,6 +478,41 @@ export class AgentManagerProvider implements Disposable {
}
}
private onRequestState(): void {
void this.stateReady
?.then(() => {
// When the folder is not a git repo (or has no folder open),
// this.state is never created. pushState() silently returns in that
// case, so re-send the empty/non-git state explicitly.
if (!this.state) {
this.pushEmptyState()
return
}
this.pushState()
// Re-send cached stats so the webview gets them even if the poller
// already emitted before the webview was ready to receive messages.
if (this.cachedWorktreeStats) this.postToWebview(this.cachedWorktreeStats)
if (this.cachedLocalStats) this.postToWebview(this.cachedLocalStats)
this.prBridge.replay()
// Refresh sessions after pushState so the webview's sessionsLoaded
// handler is guaranteed to be registered (requestState fires from
// onMount). Without this, the initial refreshSessions() in
// initializeState() can race ahead of webview mount, causing
// sessionsLoaded to never flip to true.
if (this.state.getSessions().length > 0) {
this.panel?.sessions.refreshSessions()
}
})
.catch((err) => {
this.log("initializeState failed, pushing partial state:", err)
if (!this.state) {
this.pushEmptyState()
return
}
this.pushState()
})
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
@@ -1022,7 +1035,7 @@ export class AgentManagerProvider implements Disposable {
}
// Phase 2: Send the initial prompt to all sessions, or clear busy state if no text.
const messages = buildInitialMessages(created, models, { providerID, modelID }, text, agent, files)
const messages = buildInitialMessages(created, models, { providerID, modelID }, text, agent, msg.variant, files)
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]!
if (text) {
@@ -67,6 +67,7 @@ export interface InitialMessage {
providerID?: string
modelID?: string
agent?: string
variant?: string
files?: Array<{ mime: string; url: string }>
}
@@ -81,6 +82,7 @@ export function buildInitialMessages(
fallback: { providerID?: string; modelID?: string },
prompt?: string,
agent?: string,
variant?: string,
files?: Array<{ mime: string; url: string }>,
): InitialMessage[] {
return created.map((entry) => {
@@ -96,6 +98,7 @@ export function buildInitialMessages(
if (prompt) {
msg.text = prompt
msg.agent = agent
msg.variant = variant
msg.files = files
}
return msg
@@ -169,6 +169,7 @@ interface SendInitialMessage {
providerID?: string
modelID?: string
agent?: string
variant?: string
files?: Array<{ mime: string; url: string }>
}
@@ -365,6 +366,7 @@ interface CreateMultiVersionIn {
providerID?: string
modelID?: string
agent?: string
variant?: string
files?: Array<{ mime: string; url: string }>
baseBranch?: string
branchName?: string
@@ -0,0 +1,33 @@
type Progress = (status: string, detail?: string, error?: string) => void
type Ctx = {
sessionId?: string
handler?: (sessionId: string, progress: Progress) => Promise<void>
post: (message: { type: "continueInWorktreeProgress"; status: string; detail?: string; error?: string }) => void
}
export function handleContinueInWorktree(ctx: Ctx): void {
if (ctx.sessionId && ctx.handler) {
ctx
.handler(ctx.sessionId, (status, detail, error) => {
ctx.post({ type: "continueInWorktreeProgress", status, detail, error })
})
.catch((err: unknown) => {
console.error("[Kilo New] continueInWorktree failed:", err)
ctx.post({
type: "continueInWorktreeProgress",
status: "error",
error: err instanceof Error ? err.message : String(err),
})
})
return
}
if (!ctx.sessionId) return
console.error("[Kilo New] continueInWorktree: no handler registered")
ctx.post({
type: "continueInWorktreeProgress",
status: "error",
error: "Continue in Worktree is not available",
})
}
@@ -0,0 +1,11 @@
import { z } from "zod"
const file = z.object({
mime: z.string(),
url: z.string().refine((url) => url.startsWith("file://") || url.startsWith("data:")),
filename: z.string().optional(),
})
export function parseMessageFiles(value: unknown) {
return z.array(file).optional().catch(undefined).parse(value)
}
@@ -313,20 +313,16 @@ describe("Agent Manager Provider — onMessage routing", () => {
* call pushEmptyState() instead — otherwise the webview stays stuck on
* loading skeletons forever.
*/
it("requestState handler calls pushEmptyState when state is falsy", () => {
const text = body("onStateMessage")
const start = text.indexOf('"agentManager.requestState"')
expect(start, "requestState branch must exist").toBeGreaterThan(-1)
const snippet = text.slice(start, start + 700)
expect(snippet, "must call pushEmptyState when state is absent").toContain("pushEmptyState")
expect(snippet, "must guard on this.state being falsy").toMatch(/!this\.state/)
it("requestState handler calls pushEmptyState when this.state is falsy", () => {
// onStateMessage delegates to onRequestState; verify the actual handler
const text = body("onRequestState")
expect(text, "must call pushEmptyState when state is absent").toContain("pushEmptyState")
expect(text, "must guard on this.state being falsy").toMatch(/!this\.state/)
})
it("requestState handler calls pushState when state is truthy", () => {
const text = body("onStateMessage")
const start = text.indexOf('"agentManager.requestState"')
const snippet = text.slice(start, start + 700)
expect(snippet, "must call pushState for the normal path").toContain("this.pushState()")
it("requestState handler calls pushState when this.state is truthy", () => {
const text = body("onRequestState")
expect(text, "must call pushState for the normal path").toContain("this.pushState()")
})
it("worktree diff behavior lives in the cohesive diff controller", () => {
@@ -170,16 +170,11 @@ describe("Extension — KiloProvider handler wiring", () => {
// ---------------------------------------------------------------------------
describe("KiloProvider — continueInWorktree error fallback", () => {
const provider = fs.readFileSync(KILO_PROVIDER_FILE, "utf-8")
const helper = fs.readFileSync(path.join(ROOT, "src/kilo-provider/continue-worktree.ts"), "utf-8")
it("sends error progress when handler is missing", () => {
const caseStart = provider.indexOf('case "continueInWorktree"')
expect(caseStart, "continueInWorktree case must exist").toBeGreaterThan(-1)
const caseEnd = provider.indexOf("break", caseStart)
const block = provider.slice(caseStart, caseEnd)
expect(block, "must have else branch for missing handler").toContain("else if")
expect(block, "must send error status back to webview").toContain('"error"')
expect(block, "must use continueInWorktreeProgress message type").toContain("continueInWorktreeProgress")
expect(helper, "must send error status back to webview").toContain('"error"')
expect(helper, "must use continueInWorktreeProgress message type").toContain("continueInWorktreeProgress")
expect(helper, "must handle missing handler case").toContain("no handler registered")
})
})
@@ -1311,6 +1311,7 @@ const AgentManagerContent: Component = () => {
providerID: ev.providerID,
modelID: ev.modelID,
agent: ev.agent,
variant: ev.variant,
files: ev.files,
})
}
@@ -12,8 +12,10 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useVSCode } from "../src/context/vscode"
import { useServer } from "../src/context/server"
import { useSession } from "../src/context/session"
import { useProvider } from "../src/context/provider"
import { ModelSelectorBase } from "../src/components/shared/ModelSelector"
import { ModeSwitcherBase } from "../src/components/shared/ModeSwitcher"
import { ThinkingSelectorBase } from "../src/components/shared/ThinkingSelector"
import {
MultiModelSelector,
type ModelAllocations,
@@ -60,6 +62,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
const vscode = useVSCode()
const server = useServer()
const session = useSession()
const provider = useProvider()
const [tab, setTab] = createSignal<DialogTab>("new")
@@ -74,7 +77,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
const cached = vscode.getState<Record<string, unknown>>()
const [prompt, setPrompt] = createSignal((cached?.advancedDialogPrompt as string) ?? "")
const [versions, setVersions] = createSignal<VersionCount>(1)
const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(null)
const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(session.selected())
const [compareMode, setCompareMode] = createSignal(false)
const [modelAllocations, setModelAllocations] = createSignal<ModelAllocations>(new Map())
const [agent, setAgent] = createSignal(session.selectedAgent())
@@ -85,6 +88,43 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
const [baseBranchOpen, setBaseBranchOpen] = createSignal(false)
const [compareOpen, setCompareOpen] = createSignal(false)
const [highlightedIndex, setHighlightedIndex] = createSignal(0)
const [variant, setVariant] = createSignal<string | undefined>(session.currentVariant())
// Variant list for the currently selected model
const variants = createMemo(() => {
const sel = model()
if (!sel) return []
const found = provider.findModel(sel)
if (!found?.variants) return []
return Object.keys(found.variants)
})
// Current effective variant — falls back to first available if stored value is invalid
const effectiveVariant = createMemo(() => {
const list = variants()
if (list.length === 0) return undefined
const stored = variant()
return stored && list.includes(stored) ? stored : list[0]
})
// True when the user has changed the model from the session/config default
const overridden = createMemo(() => {
const sel = model()
const cfg = session.selected()
if (!sel || !cfg) return false
return sel.providerID !== cfg.providerID || sel.modelID !== cfg.modelID
})
// Reset variant when model changes and stored variant is not in new list
createEffect(() => {
const list = variants()
if (list.length === 0) {
setVariant(undefined)
return
}
const stored = variant()
if (!stored || !list.includes(stored)) setVariant(list[0])
})
const imageAttach = useImageAttachments()
imageAttach.setFilePathDropHandler((paths) => {
@@ -172,6 +212,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
providerID: sel?.providerID,
modelID: sel?.modelID,
agent: selectedAgent,
variant: isCompare ? undefined : effectiveVariant(),
baseBranch: advanced ? (baseBranch() ?? undefined) : undefined,
branchName: customBranch,
modelAllocations: allocations,
@@ -333,17 +374,32 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
</div>
<div class="prompt-input-hint">
<div class="prompt-input-hint-selectors">
<Show when={session.agents().length > 1}>
<ModeSwitcherBase agents={session.agents()} value={agent()} onSelect={setAgent} />
</Show>
<Show when={!compareMode()}>
<ModelSelectorBase
value={model()}
onSelect={(pid, mid) => setModel(pid && mid ? { providerID: pid, modelID: mid } : null)}
onSelect={(pid, mid) => {
if (pid && mid) setModel({ providerID: pid, modelID: mid })
}}
placement="top-start"
allowClear
clearLabel="Default"
/>
</Show>
<Show when={session.agents().length > 1}>
<ModeSwitcherBase agents={session.agents()} value={agent()} onSelect={setAgent} />
<ThinkingSelectorBase variants={variants()} value={effectiveVariant()} onSelect={setVariant} />
<Show when={overridden()}>
<Tooltip value={t("prompt.action.resetModel")} placement="top">
<Button
variant="ghost"
size="small"
onClick={() => setModel(session.selected())}
aria-label={t("prompt.action.resetModel")}
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
</svg>
</Button>
</Tooltip>
</Show>
</Show>
</div>
<div class="prompt-input-hint-actions" />
@@ -2,6 +2,9 @@
* ThinkingSelector component
* Popover-based dropdown for choosing a thinking effort variant.
* Only rendered when the selected model supports reasoning variants.
*
* ThinkingSelectorBase reusable core that accepts variants/value/onSelect props.
* ThinkingSelector thin wrapper wired to session context for chat usage.
*/
import { Component, createSignal, For, Show } from "solid-js"
@@ -9,26 +12,35 @@ import { Popover } from "@kilocode/kilo-ui/popover"
import { Button } from "@kilocode/kilo-ui/button"
import { useSession } from "../../context/session"
export const ThinkingSelector: Component = () => {
const session = useSession()
// ---------------------------------------------------------------------------
// Reusable base component
// ---------------------------------------------------------------------------
export interface ThinkingSelectorBaseProps {
/** Available variant names (e.g. ["low","medium","high"]) */
variants: string[]
/** Currently selected variant */
value: string | undefined
/** Called when the user picks a variant */
onSelect: (value: string) => void
}
export const ThinkingSelectorBase: Component<ThinkingSelectorBaseProps> = (props) => {
const [open, setOpen] = createSignal(false)
const variants = () => session.variantList()
const current = () => session.currentVariant()
function pick(value: string) {
session.selectVariant(value)
props.onSelect(value)
setOpen(false)
requestAnimationFrame(() => window.dispatchEvent(new Event("focusPrompt")))
}
const triggerLabel = () => {
const v = current()
const label = () => {
const v = props.value
return v ? v.charAt(0).toUpperCase() + v.slice(1) : ""
}
return (
<Show when={variants().length > 0}>
<Show when={props.variants.length > 0}>
<Popover
placement="top-start"
open={open()}
@@ -37,7 +49,7 @@ export const ThinkingSelector: Component = () => {
triggerProps={{ variant: "ghost", size: "small" }}
trigger={
<>
<span class="thinking-selector-trigger-label">{triggerLabel()}</span>
<span class="thinking-selector-trigger-label">{label()}</span>
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style={{ "flex-shrink": "0" }}>
<path d="M8 4l4 5H4l4-5z" />
</svg>
@@ -45,12 +57,12 @@ export const ThinkingSelector: Component = () => {
}
>
<div class="thinking-selector-list" role="listbox">
<For each={variants()}>
<For each={props.variants}>
{(v) => (
<div
class={`thinking-selector-item${current() === v ? " selected" : ""}`}
class={`thinking-selector-item${props.value === v ? " selected" : ""}`}
role="option"
aria-selected={current() === v}
aria-selected={props.value === v}
onClick={() => pick(v)}
>
<span class="thinking-selector-item-name">{v.charAt(0).toUpperCase() + v.slice(1)}</span>
@@ -62,3 +74,19 @@ export const ThinkingSelector: Component = () => {
</Show>
)
}
// ---------------------------------------------------------------------------
// Chat-specific wrapper (backwards-compatible)
// ---------------------------------------------------------------------------
export const ThinkingSelector: Component = () => {
const session = useSession()
return (
<ThinkingSelectorBase
variants={session.variantList()}
value={session.currentVariant()}
onSelect={(value) => session.selectVariant(value)}
/>
)
}
@@ -1118,6 +1118,7 @@ export interface AgentManagerSendInitialMessage {
providerID?: string
modelID?: string
agent?: string
variant?: string
files?: Array<{ mime: string; url: string }>
}
@@ -2004,6 +2005,7 @@ export interface CreateMultiVersionRequest {
providerID?: string
modelID?: string
agent?: string
variant?: string
files?: FileAttachment[]
baseBranch?: string
branchName?: string