Merge branch 'dev' into feat/agent-manager-session-navigation

This commit is contained in:
marius-kilocode
2026-02-18 11:32:30 +01:00
4 changed files with 111 additions and 19 deletions
+4
View File
@@ -103,6 +103,10 @@ New webview features must use **`@kilocode/kilo-ui`** components instead of raw
While the old extension coexists, runtime labels append `(NEW)` — controlled by the flag in [`constants.ts`](src/constants.ts). Static labels in `package.json` must be updated separately. Remove this convention once the old extension is retired.
## Agent Manager
Opens as an editor tab (not the sidebar) and lets users run multiple independent AI sessions in parallel. It has a left sidebar listing active sessions and a right panel showing the chat UI for the selected one. Each session is a standard `kilo serve` session. The extension side uses the same `KiloProvider` wiring as the sidebar; the webview side reuses the same provider chain and `ChatView` component.
## Kilocode Change Markers
This package is entirely Kilo-specific — `kilocode_change` markers are NOT needed in any files under `packages/kilo-vscode/`. The markers are only necessary when modifying shared upstream opencode files.
@@ -2,32 +2,46 @@
* ModeSwitcher component
* Popover-based selector for choosing an agent/mode in the chat prompt area.
* Uses kilo-ui Popover component (Phase 4.5 of UI implementation plan).
*
* ModeSwitcherBase — reusable core that accepts agents/value/onSelect props.
* ModeSwitcher — thin wrapper wired to session context for chat usage.
*/
import { Component, createSignal, For, Show } from "solid-js"
import { Popover } from "@kilocode/kilo-ui/popover"
import { Button } from "@kilocode/kilo-ui/button"
import { useSession } from "../../context/session"
import type { AgentInfo } from "../../types/messages"
export const ModeSwitcher: Component = () => {
const session = useSession()
// ---------------------------------------------------------------------------
// Reusable base component
// ---------------------------------------------------------------------------
export interface ModeSwitcherBaseProps {
/** Available agents to pick from */
agents: AgentInfo[]
/** Currently selected agent name */
value: string
/** Called when the user picks an agent */
onSelect: (name: string) => void
}
export const ModeSwitcherBase: Component<ModeSwitcherBaseProps> = (props) => {
const [open, setOpen] = createSignal(false)
const available = () => session.agents()
const hasAgents = () => available().length > 1
const hasAgents = () => props.agents.length > 1
function pick(name: string) {
session.selectAgent(name)
props.onSelect(name)
setOpen(false)
}
const triggerLabel = () => {
const name = session.selectedAgent()
const agent = available().find((a) => a.name === name)
const agent = props.agents.find((a) => a.name === props.value)
if (agent) {
return agent.name.charAt(0).toUpperCase() + agent.name.slice(1)
}
return name || "Code"
return props.value || "Code"
}
return (
@@ -48,12 +62,12 @@ export const ModeSwitcher: Component = () => {
}
>
<div class="mode-switcher-list" role="listbox">
<For each={available()}>
<For each={props.agents}>
{(agent) => (
<div
class={`mode-switcher-item${agent.name === session.selectedAgent() ? " selected" : ""}`}
class={`mode-switcher-item${agent.name === props.value ? " selected" : ""}`}
role="option"
aria-selected={agent.name === session.selectedAgent()}
aria-selected={agent.name === props.value}
onClick={() => pick(agent.name)}
>
<span class="mode-switcher-item-name">{agent.name.charAt(0).toUpperCase() + agent.name.slice(1)}</span>
@@ -68,3 +82,13 @@ export const ModeSwitcher: Component = () => {
</Show>
)
}
// ---------------------------------------------------------------------------
// Chat-specific wrapper (backwards-compatible)
// ---------------------------------------------------------------------------
export const ModeSwitcher: Component = () => {
const session = useSession()
return <ModeSwitcherBase agents={session.agents()} value={session.selectedAgent()} onSelect={session.selectAgent} />
}
@@ -3,7 +3,7 @@
* Text input with send/abort buttons and ghost-text autocomplete for the chat interface
*/
import { Component, createSignal, onCleanup, Show } from "solid-js"
import { Component, createSignal, createEffect, on, onCleanup, Show, untrack } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useSession } from "../../context/session"
@@ -16,17 +16,40 @@ import { ModeSwitcher } from "./ModeSwitcher"
const AUTOCOMPLETE_DEBOUNCE_MS = 500
const MIN_TEXT_LENGTH = 3
// Per-session input text storage (module-level so it survives remounts)
const drafts = new Map<string, string>()
export const PromptInput: Component = () => {
const session = useSession()
const server = useServer()
const language = useLanguage()
const vscode = useVSCode()
const sessionKey = () => session.currentSessionID() ?? "__new__"
const [text, setText] = createSignal("")
const [ghostText, setGhostText] = createSignal("")
let textareaRef: HTMLTextAreaElement | undefined
let debounceTimer: ReturnType<typeof setTimeout> | undefined
let requestCounter = 0
// Save/restore input text when switching sessions.
// Uses `on()` to track only sessionKey — avoids re-running on every keystroke.
createEffect(
on(sessionKey, (key, prev) => {
if (prev !== undefined && prev !== key) {
drafts.set(prev, untrack(text))
}
const draft = drafts.get(key) ?? ""
setText(draft)
setGhostText("")
if (textareaRef) {
textareaRef.value = draft
// Reset height then adjust
textareaRef.style.height = "auto"
textareaRef.style.height = `${Math.min(textareaRef.scrollHeight, 200)}px`
}
}),
)
const isBusy = () => session.status() === "busy"
const isDisabled = () => !server.isConnected()
@@ -45,6 +68,9 @@ export const PromptInput: Component = () => {
})
onCleanup(() => {
// Persist current draft before unmounting
const current = text()
if (current) drafts.set(sessionKey(), current)
unsubscribe()
if (debounceTimer) {
clearTimeout(debounceTimer)
@@ -150,6 +176,7 @@ export const PromptInput: Component = () => {
session.sendMessage(message, sel?.providerID, sel?.modelID)
setText("")
setGhostText("")
drafts.delete(sessionKey())
// Reset textarea height
if (textareaRef) {
@@ -80,6 +80,7 @@ interface SessionStore {
parts: Record<string, Part[]> // messageID -> parts
todos: Record<string, TodoItem[]> // sessionID -> todos
modelSelections: Record<string, ModelSelection> // sessionID -> model
agentSelections: Record<string, string> // sessionID -> agent name
}
interface SessionContextValue {
@@ -122,10 +123,12 @@ interface SessionContextValue {
totalCost: Accessor<number>
contextUsage: Accessor<ContextUsage | undefined>
// Agent/mode selection
// Agent/mode selection (per-session)
agents: Accessor<AgentInfo[]>
selectedAgent: Accessor<string>
selectAgent: (name: string) => void
getSessionAgent: (sessionID: string) => string
getSessionModel: (sessionID: string) => ModelSelection | null
// Actions
sendMessage: (text: string, providerID?: string, modelID?: string, files?: FileAttachment[]) => void
@@ -177,15 +180,18 @@ export const SessionProvider: ParentComponent = (props) => {
// Agents (modes) loaded from the CLI backend
const [agents, setAgents] = createSignal<AgentInfo[]>([])
const [defaultAgent, setDefaultAgent] = createSignal("code")
const [selectedAgentName, setSelectedAgentName] = createSignal("code")
// Store for sessions, messages, parts, todos, modelSelections
// Pending agent selection for before a session exists (mirrors pendingModelSelection)
const [pendingAgentSelection, setPendingAgentSelection] = createSignal<string | null>(null)
// Store for sessions, messages, parts, todos, modelSelections, agentSelections
const [store, setStore] = createStore<SessionStore>({
sessions: {},
messages: {},
parts: {},
todos: {},
modelSelections: {},
agentSelections: {},
})
// Keep pending selection in sync with provider default until the user
@@ -219,6 +225,15 @@ export const SessionProvider: ParentComponent = (props) => {
return pendingModelSelection()
})
// Per-session agent selection
const selectedAgentName = createMemo<string>(() => {
const sessionID = currentSessionID()
if (sessionID) {
return store.agentSelections[sessionID] ?? defaultAgent()
}
return pendingAgentSelection() ?? defaultAgent()
})
function selectModel(providerID: string, modelID: string) {
const selection: ModelSelection = { providerID, modelID }
const id = currentSessionID()
@@ -239,9 +254,9 @@ export const SessionProvider: ParentComponent = (props) => {
}
setAgents(message.agents)
setDefaultAgent(message.defaultAgent)
// Only override if the user hasn't explicitly selected an agent
if (selectedAgentName() === "code" || !message.agents.some((a) => a.name === selectedAgentName())) {
setSelectedAgentName(message.defaultAgent)
// Initialize pending agent if not yet set by the user
if (!pendingAgentSelection()) {
setPendingAgentSelection(message.defaultAgent)
}
})
@@ -349,6 +364,13 @@ export const SessionProvider: ParentComponent = (props) => {
setPendingWasUserSet(false)
}
// Transfer pending agent selection to the new session
const pendingAgent = pendingAgentSelection()
if (pendingAgent && !store.agentSelections[session.id]) {
setStore("agentSelections", session.id, pendingAgent)
setPendingAgentSelection(null)
}
setCurrentSessionID(session.id)
})
}
@@ -543,6 +565,12 @@ export const SessionProvider: ParentComponent = (props) => {
delete selections[sessionID]
}),
)
setStore(
"agentSelections",
produce((selections) => {
delete selections[sessionID]
}),
)
// Clean up pending questions/errors for the deleted session
const deleted = questions()
.filter((q) => q.sessionID === sessionID)
@@ -567,7 +595,12 @@ export const SessionProvider: ParentComponent = (props) => {
// Actions
function selectAgent(name: string) {
setSelectedAgentName(name)
const id = currentSessionID()
if (id) {
setStore("agentSelections", id, name)
} else {
setPendingAgentSelection(name)
}
}
function sendMessage(text: string, providerID?: string, modelID?: string, files?: FileAttachment[]) {
@@ -688,6 +721,7 @@ export const SessionProvider: ParentComponent = (props) => {
// Reset pending selection to default for the new session
setPendingModelSelection(provider.defaultSelection())
setPendingWasUserSet(false)
setPendingAgentSelection(defaultAgent())
vscode.postMessage({ type: "createSession" })
}
@@ -701,6 +735,7 @@ export const SessionProvider: ParentComponent = (props) => {
setQuestionErrors(new Set<string>())
setPendingModelSelection(provider.defaultSelection())
setPendingWasUserSet(false)
setPendingAgentSelection(defaultAgent())
vscode.postMessage({ type: "clearSession" })
}
@@ -828,6 +863,8 @@ export const SessionProvider: ParentComponent = (props) => {
agents,
selectedAgent: selectedAgentName,
selectAgent,
getSessionAgent: (sessionID: string) => store.agentSelections[sessionID] ?? defaultAgent(),
getSessionModel: (sessionID: string) => store.modelSelections[sessionID] ?? provider.defaultSelection(),
sendMessage,
abort,
compact,