Merge branch 'dev' into feat/agent-manager-session-navigation
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user