Files
kilocode/packages/kilo-vscode/webview-ui/src/context/session.tsx
T

3077 lines
105 KiB
TypeScript

/**
* Session context
* Manages session state, messages, and handles SSE events from the extension.
* Also owns global (extension-lifetime) model selection (provider context is catalog-only).
*/
import {
createContext,
useContext,
createSignal,
createMemo,
createEffect,
on,
onMount,
onCleanup,
batch,
untrack,
} from "solid-js"
import type { ParentComponent, Accessor } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useVSCode } from "./vscode"
import { useServer } from "./server"
import { useProvider } from "./provider"
import { useConfig } from "./config"
import { useLanguage } from "./language"
import { createCostAlertHandler } from "./cost-alert"
import { showToast } from "@kilocode/kilo-ui/toast"
import type {
SessionInfo,
SessionModelUsage,
SessionUpdate,
Message,
Part,
PartDelta,
SessionStatus,
SessionStatusInfo,
SessionCloseReason,
PermissionRequest,
QuestionRequest,
SuggestionRequest,
TodoItem,
ModelSelection,
ContextUsage,
AgentInfo,
SkillInfo,
ExtensionMessage,
FileAttachment,
SendMessageFailedMessage,
McpStatusEntry,
MessageLoadMode,
ToolPart,
} from "../types/messages"
import { removeSessionPermissions, upsertPermission } from "./permission-queue"
import {
computeStatus,
calcContextUsage,
buildFamilyCosts,
buildFamilyParentsFromTools,
buildFamilyLabelsFromTools,
buildCostBreakdown,
buildSessionToolParts,
childID,
reconcileSessionToolParts,
removeSessionToolPart,
removeSessionToolPartsForMessage,
upsertSessionToolPart,
} from "./session-utils"
import { Identifier } from "../utils/id"
import { resolveModelSelection } from "./model-selection"
import { getAgentModel } from "./session-model-store"
import { resolveMessagePrefs } from "./session-preferences"
import { errorIDs } from "./session-errors"
import { PartStash } from "./part-stash"
import { mergeParts, sameParts } from "./session-parts"
import { state as todoState } from "./todo-revert"
import { getAgentVariant, getVariant, sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store"
import { KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "../../../src/shared/provider-model"
import { reviewMetadata, type ReviewMessageData } from "../../../src/shared/review-comments"
import { visibleMessages as filterVisibleMessages } from "./session-queue"
import { clearSessionDraftDiscarded, deleteDraftsForSession } from "../utils/draft-store"
import { createAbortState } from "./abort-state"
import { clearIfOn, createCloudPrune } from "./session-cloud-prune"
import { isSameSessionTree } from "./model-usage"
import { createDraftAgentSeed } from "./session-agent"
const RECENT_LIMIT = 5
const MESSAGE_PAGE_LIMIT = 80
/** Remove ids from a Set immutably, returning the original when nothing changed. */
function dropSet(prev: Set<string>, ids: Iterable<string>): Set<string> {
const next = new Set(prev)
for (const id of ids) next.delete(id)
return next.size === prev.size ? prev : next
}
type MessageMutation = Exclude<MessageLoadMode, "focus"> | "append" | "update"
interface MessagePageState {
loadingInitial: boolean
loadingOlder: boolean
before?: string
hasMore: boolean
lastMutation?: MessageMutation
}
const emptyPageState: MessagePageState = {
loadingInitial: false,
loadingOlder: false,
hasMore: false,
}
// Store structure for messages and parts
interface SessionStore {
sessions: Record<string, SessionInfo>
messages: Record<string, Message[]> // sessionID -> messages
parts: Record<string, Part[]> // messageID -> parts
toolParts: Record<string, ToolPart[]> // sessionID -> compact per-session tool index
todos: Record<string, TodoItem[]> // sessionID -> todos
modelSelections: Record<string, ModelSelection | null> // agentName -> model (global, extension-lifetime)
sessionOverrides: Record<string, ModelSelection> // sessionID -> per-session model override (compare mode)
agentSelections: Record<string, string> // sessionID -> agent name
variantSelections: Record<string, string> // session/agent scoped variant key -> variant name
recentModels: ModelSelection[]
favoriteModels: ModelSelection[]
modelUsage: Record<string, { requestID: string; data?: SessionModelUsage }>
}
interface SessionContextValue {
// Current session
currentSessionID: Accessor<string | undefined>
currentSession: Accessor<SessionInfo | undefined>
setCurrentSessionID: (id: string | undefined) => void
// All sessions (sorted most recent first)
sessions: Accessor<SessionInfo[]>
// Session status
status: Accessor<SessionStatus>
statusInfo: Accessor<SessionStatusInfo>
closeReason: Accessor<SessionCloseReason | undefined>
statusText: Accessor<string | undefined>
busySince: Accessor<number | undefined>
submitting: Accessor<boolean>
isSubmitting: (id: string) => boolean
loading: Accessor<boolean>
loadingOlderMessages: Accessor<boolean>
hasOlderMessages: Accessor<boolean>
messageMutation: Accessor<MessageMutation | undefined>
// Messages for current session
messages: Accessor<Message[]>
// Messages for current session with soft-reverted turns hidden
visibleMessages: Accessor<Message[]>
// User messages for current session (role === "user")
userMessages: Accessor<Message[]>
// All messages keyed by sessionID (includes child sessions)
allMessages: () => Record<string, Message[]>
// All parts keyed by messageID (includes child sessions)
allParts: () => Record<string, Part[]>
// All session statuses keyed by sessionID (for DataBridge)
allStatusMap: () => Record<string, SessionStatusInfo>
// Parts for a specific message
getParts: (messageID: string) => Part[]
// Tool parts for a specific session, maintained incrementally for streaming views
getSessionToolParts: (sessionID: string) => ToolPart[]
getSessionToolCount: (sessionID: string) => number
// Hidden after model changes so switching models can clear stale provider errors
// without removing messages and their checkpoint restore actions.
isErrorHidden: (messageID: string) => boolean
// Move stashed parts into the reactive store for the given message IDs.
// Called by VscodeSessionTurn when the virtualizer renders a turn.
hydrateParts: (messageIDs: string[]) => void
// Todos for current session
todos: Accessor<TodoItem[]>
// Pending permission requests (unscoped — all tracked sessions)
permissions: Accessor<PermissionRequest[]>
respondingPermissions: Accessor<Set<string>>
// Pending question requests (unscoped — all tracked sessions)
questions: Accessor<QuestionRequest[]>
questionErrors: Accessor<Set<string>>
suggestions: Accessor<SuggestionRequest[]>
suggestionErrors: Accessor<Set<string>>
respondingSuggestions: Accessor<Set<string>>
// Scoped permissions/questions — filtered to a session's family (self + subagents)
scopedPermissions: (sessionID: string | undefined) => PermissionRequest[]
scopedQuestions: (sessionID: string | undefined) => QuestionRequest[]
scopedSuggestions: (sessionID: string | undefined) => SuggestionRequest[]
// Model selection (global, extension-lifetime)
selected: (sessionID?: string) => ModelSelection | null
configModel: (sessionID?: string) => ModelSelection | null
modelForAgent: (agent: string) => ModelSelection | null
configModelForAgent: (agent: string) => ModelSelection | null
selectModel: (providerID: string, modelID: string, sessionID?: string) => void
hasModelOverride: (sessionID?: string) => boolean
clearModelOverride: (sessionID?: string) => void
// Cost and context usage for the current session
costBreakdown: Accessor<Array<{ label: string; cost: number }>>
contextUsage: Accessor<ContextUsage | undefined>
modelUsage: Accessor<SessionModelUsage | undefined>
// Skills loaded from the CLI backend
skills: Accessor<SkillInfo[]>
refreshSkills: () => void
removeSkill: (location: string) => void
// Agent/mode selection (per-session)
agents: Accessor<AgentInfo[]>
allAgents: Accessor<AgentInfo[]>
removeAgent: (name: string) => void
removeMcp: (name: string) => void
// MCP server status (runtime connect/disconnect)
mcpStatus: Accessor<Record<string, McpStatusEntry>>
mcpLoading: Accessor<string | null>
connectMcp: (name: string) => void
disconnectMcp: (name: string) => void
authenticateMcp: (name: string) => void
selectedAgent: (sessionID?: string) => string
selectAgent: (name: string, sessionID?: string) => void
getSessionAgent: (sessionID: string) => string
setSessionModel: (sessionID: string, providerID: string, modelID: string) => void
setSessionAgent: (sessionID: string, name: string) => void
setSessionVariant: (sessionID: string, providerID: string, modelID: string, value: string, agent?: string) => void
// Thinking variant for the selected model
variantList: (sessionID?: string) => string[]
currentVariant: (sessionID?: string) => string | undefined
variantForAgent: (agent: string, model: ModelSelection | null) => string | undefined
selectVariant: (value: string, sessionID?: string) => void
// Model favorites
favoriteModels: Accessor<ModelSelection[]>
toggleFavorite: (providerID: string, modelID: string) => void
// Revert/undo state for the current session
revert: Accessor<SessionInfo["revert"]>
revertedCount: Accessor<number>
summary: Accessor<SessionInfo["summary"]>
// Live worktree diff stats (polled from CLI backend)
worktreeStats: Accessor<{ files: number; additions: number; deletions: number } | undefined>
// Actions
revertSession: (messageID: string, partID?: string) => void
unrevertSession: () => void
deleteQueuedMessage: (sessionID: string, messageID: string) => void
sendMessage: (
text: string,
providerID?: string,
modelID?: string,
files?: FileAttachment[],
draftID?: string,
context?: string,
review?: ReviewMessageData,
origin?: string | null,
) => void
sendCommand: (
command: string,
args: string,
providerID?: string,
modelID?: string,
files?: FileAttachment[],
draftID?: string,
context?: string,
origin?: string | null,
) => void
abort: () => void
compact: () => void
respondToPermission: (
permissionId: string,
response: "once" | "always" | "reject",
approvedAlways: string[],
deniedAlways: string[],
) => void
replyToQuestion: (requestID: string, answers: string[][]) => void
rejectQuestion: (requestID: string) => void
closeQuestion: (requestID: string) => void
acceptSuggestion: (requestID: string, index: number) => void
dismissSuggestion: (requestID: string) => void
createSession: () => void
clearCurrentSession: () => void
loadSessions: () => void
loadOlderMessages: () => boolean
selectSession: (id: string) => void
deleteSession: (id: string) => void
renameSession: (id: string, title: string) => void
exportSessionTranscript: (id: string) => void
syncSession: (sessionID: string) => void
// Cloud session preview
cloudPreviewId: Accessor<string | null>
selectCloudSession: (cloudSessionId: string) => void
draftSessionID: Accessor<string | undefined>
setDraftSessionID: (id: string | undefined) => void
userClearedSession: Accessor<boolean>
}
export const SessionContext = createContext<SessionContextValue>()
export const SessionProvider: ParentComponent = (props) => {
const vscode = useVSCode()
const server = useServer()
const provider = useProvider()
const { config } = useConfig()
const language = useLanguage()
// Current session ID
const [currentSessionID, setCurrentSessionID] = createSignal<string | undefined>()
const [draftSessionID, setDraftSessionID] = createSignal<string | undefined>()
const [userClearedSession, setUserClearedSession] = createSignal(false)
// Per-session status map — keyed by sessionID
const [statusMap, setStatusMap] = createStore<Record<string, SessionStatusInfo>>({})
const [closeMap, setCloseMap] = createStore<Record<string, SessionCloseReason | undefined>>({})
const [busySinceMap, setBusySinceMap] = createStore<Record<string, number>>({})
const [submissionMap, setSubmissionMap] = createStore<Record<string, number>>({})
const pendingSubmissions = new Map<string, string>()
const aborts = createAbortState()
const idle: SessionStatusInfo = { type: "idle" }
// Derived accessors for the current session (backwards compatible)
const statusInfo = () => {
const id = currentSessionID()
return id ? (statusMap[id] ?? idle) : idle
}
const status = () => statusInfo().type as SessionStatus
const closeReason = () => {
const id = currentSessionID()
return id ? closeMap[id] : undefined
}
const clearClose = (id: string) =>
setCloseMap(
produce((map) => {
delete map[id]
}),
)
const busySince = () => {
const id = currentSessionID() ?? draftSessionID()
return id ? busySinceMap[id] : undefined
}
const submitting = () => {
const id = currentSessionID() ?? draftSessionID()
return id ? isSubmitting(id) : false
}
const isSubmitting = (id: string) => (submissionMap[id] ?? 0) > 0
const [loading, setLoading] = createSignal(false)
const [loaded, setLoaded] = createSignal<Set<string>>(new Set())
const [pages, setPages] = createStore<Record<string, MessagePageState>>({})
// Parts stash: holds parts from messagesLoaded outside the reactive store
// until a VscodeSessionTurn is rendered by the virtualizer and calls
// hydrateParts(). This avoids writing parts for off-screen messages into
// the store, which would trigger expensive DOM work for invisible content.
const stash = new PartStash()
// Pending permissions
const [permissions, setPermissions] = createSignal<PermissionRequest[]>([])
// Permission IDs that have been responded to but not yet confirmed by the server
const [respondingPermissions, setRespondingPermissions] = createSignal<Set<string>>(new Set())
// Pending questions
const [questions, setQuestions] = createSignal<QuestionRequest[]>([])
const cah = createCostAlertHandler(vscode.postMessage, handleQuestionRequest, handleQuestionResolved, language.t)
// Tracks question IDs that failed so the UI can reset sending state
const [questionErrors, setQuestionErrors] = createSignal<Set<string>>(new Set())
const [suggestions, setSuggestions] = createSignal<SuggestionRequest[]>([])
const [suggestionErrors, setSuggestionErrors] = createSignal<Set<string>>(new Set())
const [respondingSuggestions, setRespondingSuggestions] = createSignal<Set<string>>(new Set())
// Tracks whether the user has explicitly set a model override per agent (to
// prevent the default-sync effect from overwriting it).
const [userSetAgents, setUserSetAgents] = createSignal<Record<string, boolean>>({})
// Agents (modes) loaded from the CLI backend
const [agents, setAgents] = createSignal<AgentInfo[]>([])
const [allAgents, setAllAgents] = createSignal<AgentInfo[]>([])
const [defaultAgent, setDefaultAgent] = createSignal("code")
const [pendingKiloModel, setPendingKiloModel] = createSignal<{
modelID?: string
agent?: string
after: number
} | null>(null)
const [catalog, setCatalog] = createSignal(0)
// Skills loaded from the CLI backend
const [skills, setSkills] = createSignal<SkillInfo[]>([])
const removeAgent = (name: string) => {
setAgents((prev) => prev.filter((a) => a.name !== name))
// Clear stale selections so selectedAgentName() falls back to the default
if (pendingAgentSelection() === name) {
setPendingAgentSelection(null)
}
setStore(
"agentSelections",
produce((selections) => {
for (const sid of Object.keys(selections)) {
if (selections[sid] === name) delete selections[sid]
}
}),
)
vscode.postMessage({ type: "removeAgent", name })
}
const removeMcp = (name: string) => {
vscode.postMessage({ type: "removeMcp", name })
}
// MCP runtime status
const [mcpStatus, setMcpStatus] = createSignal<Record<string, McpStatusEntry>>({})
const [mcpLoading, setMcpLoading] = createSignal<string | null>(null)
const connectMcp = (name: string) => {
if (mcpLoading()) return
if (!server.isConnected()) return
setMcpLoading(name)
vscode.postMessage({ type: "connectMcp", name })
}
const disconnectMcp = (name: string) => {
if (mcpLoading()) return
if (!server.isConnected()) return
setMcpLoading(name)
vscode.postMessage({ type: "disconnectMcp", name })
}
const authenticateMcp = (name: string) => {
if (mcpLoading()) return
if (!server.isConnected()) return
setMcpLoading(name)
vscode.postMessage({ type: "authenticateMcp", name })
}
// Pending agent selection for before a session exists
const [pendingAgentSelection, setPendingAgentSelection] = createSignal<string | null>(null)
// Cloud session preview state
const [cloudPreviewId, setCloudPreviewId] = createSignal<string | null>(null)
const [hiddenErrors, setHiddenErrors] = createSignal<Set<string>>(new Set())
// Live worktree diff stats from extension polling
const [worktreeStats, setWorktreeStats] = createSignal<
{ files: number; additions: number; deletions: number } | undefined
>()
// Tracks optimistic messageIDs that haven't been confirmed by the server yet.
// Prevents handleMessagesLoaded from wiping them when it replaces the array.
const pendingOptimistic = new Map<string, Set<string>>()
// Sessions can be created/imported while an older list request is still in flight.
// Keep them until a later list payload confirms them or deletion arrives.
const freshSessions = new Set<string>()
const startSubmission = (sid: string, messageID: string) => {
pendingSubmissions.set(messageID, sid)
setSubmissionMap(sid, (count = 0) => count + 1)
if (!busySinceMap[sid]) setBusySinceMap(sid, Date.now())
}
const finishSubmission = (messageID: string) => {
aborts.finish(messageID)
const sid = pendingSubmissions.get(messageID)
if (!sid) return
pendingSubmissions.delete(messageID)
const count = submissionMap[sid] ?? 0
if (count > 1) {
setSubmissionMap(sid, count - 1)
return
}
setSubmissionMap(
produce((map) => {
delete map[sid]
}),
)
if ((statusMap[sid] ?? idle).type !== "idle") return
setBusySinceMap(
produce((map) => {
delete map[sid]
}),
)
}
const confirmSubmissions = (sid: string) => {
for (const [id, scope] of pendingSubmissions) {
if (scope !== sid) continue
aborts.finish(id)
pendingSubmissions.delete(id)
}
setSubmissionMap(
produce((map) => {
delete map[sid]
}),
)
}
// Store for sessions, messages, parts, todos, modelSelections, agentSelections
const [store, setStore] = createStore<SessionStore>({
sessions: {},
messages: {},
parts: {},
toolParts: {},
todos: {},
modelSelections: {},
sessionOverrides: {},
agentSelections: {},
variantSelections: {},
recentModels: [],
favoriteModels: [],
modelUsage: {},
})
const [modelUsageReady, setModelUsageReady] = createSignal(false)
let modelUsageQueued = false
function refreshModelUsage() {
const sessionID = currentSessionID()
if (!sessionID || sessionID.startsWith("cloud:")) return
const requestID = crypto.randomUUID()
setStore("modelUsage", sessionID, { requestID, data: store.modelUsage[sessionID]?.data })
vscode.postMessage({ type: "requestSessionModelUsage", sessionID, requestID })
}
function queueModelUsageRefresh() {
if (modelUsageQueued) return
modelUsageQueued = true
queueMicrotask(() => {
modelUsageQueued = false
refreshModelUsage()
})
}
// Per-session agent selection
const selectedAgentName = createMemo<string>(() => {
const sessionID = currentSessionID()
if (sessionID) {
return store.agentSelections[sessionID] ?? defaultAgent()
}
return pendingAgentSelection() ?? defaultAgent()
})
function agentForScope(sessionID?: string) {
if (sessionID) return store.agentSelections[sessionID] ?? defaultAgent()
return selectedAgentName()
}
const agentDrafts = createDraftAgentSeed({
selections: () => store.agentSelections,
pending: pendingAgentSelection,
active: (draft) => !!submissionMap[draft],
set: (draft, agent) => setStore("agentSelections", draft, agent),
drop: (draft) =>
setStore(
"agentSelections",
produce((agents) => void delete agents[draft]),
),
})
const agentNames = createMemo(() => new Set(agents().map((agent) => agent.name)))
const { pendingCloudPrune, prune: pruneCloudOrphans } = createCloudPrune((m) => setStore("parts", produce(m)), stash)
/** Per-mode model from config (e.g. config.agent.code.model). */
function getModeModel(agentName: string): ModelSelection | null {
return parseModelString(config().agent?.[agentName]?.model)
}
/** Global default model from config (config.model). */
function getGlobalModel(): ModelSelection | null {
return parseModelString(config().model)
}
function resolveModel(agentName: string, override?: ModelSelection | null): ModelSelection | null {
return resolveModelSelection({
providers: provider.providers(),
connected: provider.connected(),
override,
mode: getModeModel(agentName),
global: getGlobalModel(),
recent: store.recentModels,
fallback: KILO_AUTO,
})
}
// Keep model selection in sync with provider/mode default until the user
// explicitly overrides it.
createEffect(() => {
const agentName = selectedAgentName()
if (userSetAgents()[agentName]) return
const sel = resolveModel(agentName)
setStore("modelSelections", agentName, sel)
})
const currentSelected = createMemo<ModelSelection | null>(() => {
const sid = currentSessionID()
if (sid) {
const session = store.sessionOverrides[sid]
if (session) return session
}
const agentName = selectedAgentName()
return resolveModel(agentName, store.modelSelections[agentName])
})
// Precedence: scoped override > per-agent global/default > config/default.
function selected(sessionID?: string): ModelSelection | null {
if (!sessionID) return currentSelected()
const session = store.sessionOverrides[sessionID]
if (session) return session
const agentName = agentForScope(sessionID)
return resolveModel(agentName, store.modelSelections[agentName])
}
function pushRecent(selection: ModelSelection) {
const key = `${selection.providerID}/${selection.modelID}`
const filtered = store.recentModels.filter((r) => `${r.providerID}/${r.modelID}` !== key)
const updated = [selection, ...filtered].slice(0, RECENT_LIMIT)
setStore("recentModels", updated)
vscode.postMessage({ type: "persistRecents", recents: updated })
}
function applyModel(agentName: string, selection: ModelSelection, sessionID?: string) {
pushRecent(selection)
if (sessionID) {
setStore("sessionOverrides", sessionID, selection)
return
}
// Always remember the per-mode model choice so switching modes restores
// the last-used model (mirrors CLI TUI's model.json behavior).
setUserSetAgents((prev) => ({ ...prev, [agentName]: true }))
setStore("modelSelections", agentName, selection)
// Persist to model.json via the extension host
vscode.postMessage({
type: "persistModelSelection",
agent: agentName,
providerID: selection.providerID,
modelID: selection.modelID,
})
}
function selectModel(providerID: string, modelID: string, sessionID?: string) {
const sid = sessionID ?? currentSessionID()
applyModel(agentForScope(sid), { providerID, modelID }, sid)
if (sid) {
hideErrors(sid)
}
}
function selectKiloModel(modelID?: string, agent?: string) {
if (!modelID && !agent) return
setPendingKiloModel({ ...(modelID && { modelID }), ...(agent && { agent }), after: catalog() })
if (modelID) vscode.postMessage({ type: "requestProviders" })
}
const unsubKiloModel = vscode.onMessage((message: ExtensionMessage) => {
if (message.type === "providersLoaded") {
setCatalog((value) => value + 1)
return
}
if (message.type === "selectKiloModel") selectKiloModel(message.modelID, message.agent)
})
onCleanup(unsubKiloModel)
createEffect(() => {
const pending = pendingKiloModel()
if (!pending || agents().length === 0 || (pending.modelID && catalog() <= pending.after)) return
setPendingKiloModel(null)
if (pending.modelID && !provider.providers()[KILO_PROVIDER_ID]?.models[pending.modelID]) {
console.warn("[Kilo New] Ignoring unavailable Kilo catalog model:", pending.modelID)
return
}
if (pending.agent && !agentNames().has(pending.agent)) {
console.warn("[Kilo New] Ignoring unavailable Kilo agent:", pending.agent)
return
}
if (pending.agent) selectAgent(pending.agent)
if (pending.modelID) selectModel(KILO_PROVIDER_ID, pending.modelID)
})
function promptAgent(sessionID?: string) {
const name = agentForScope(sessionID)
return name !== defaultAgent() ? name : undefined
}
function hideErrors(sid: string) {
const ids = errorIDs(store.messages[sid] ?? [])
if (ids.length === 0) return
setHiddenErrors((prev) => {
const next = new Set(prev)
for (const id of ids) next.add(id)
return next
})
}
function clearModeModelSelection(agentName: string, persist = false) {
setUserSetAgents((prev) => {
const next = { ...prev }
delete next[agentName]
return next
})
setStore(
"modelSelections",
produce((selections) => {
delete selections[agentName]
}),
)
if (persist) vscode.postMessage({ type: "clearModelSelection", agent: agentName })
}
function shouldClearModeModelSelection(agentName: string) {
return getModeModel(agentName) !== null && userSetAgents()[agentName] === true
}
function clearHiddenErrors(ids: string[]) {
if (ids.length === 0) return
setHiddenErrors((prev) => {
const next = new Set(prev)
for (const id of ids) next.delete(id)
if (next.size === prev.size) return prev
return next
})
}
function configModel(sessionID?: string): ModelSelection | null {
const agentName = agentForScope(sessionID)
return resolveModel(agentName)
}
function modelForAgent(agentName: string): ModelSelection | null {
return getAgentModel(
{
modelSelections: store.modelSelections,
sessionOverrides: store.sessionOverrides,
agentSelections: store.agentSelections,
recentModels: store.recentModels,
},
{
providers: provider.providers(),
connected: provider.connected(),
getModeModel,
getGlobalModel,
fallback: KILO_AUTO,
},
agentName,
userSetAgents()[agentName] === true,
)
}
function configModelForAgent(agentName: string): ModelSelection | null {
return resolveModel(agentName)
}
/** True when the active model differs from what the config dictates. */
function hasModelOverride(sessionID?: string) {
const sel = selected(sessionID)
const cfg = configModel(sessionID)
if (!sel || !cfg) return false
return sel.providerID !== cfg.providerID || sel.modelID !== cfg.modelID
}
/** Clear the per-mode model override, falling back to config default. */
function clearModelOverride(sessionID?: string) {
const sid = sessionID ?? currentSessionID()
const agentName = sid ? agentForScope(sid) : selectedAgentName()
// Always clear the persisted per-mode model selection so the user's
// configured (or fallback) model becomes effective, not the last manual pick.
clearModeModelSelection(agentName, true)
if (sid) {
setStore(
"sessionOverrides",
produce((overrides) => {
delete overrides[sid]
}),
)
hideErrors(sid)
}
}
// Handle agentsLoaded immediately (not in onMount) so we never miss
// the initial push that arrives before the DOM mounts. This mirrors the
// pattern used by ProviderProvider for providersLoaded.
const unsubAgents = vscode.onMessage((message: ExtensionMessage) => {
if (message.type !== "agentsLoaded") {
return
}
setAgents(message.agents)
setAllAgents(message.allAgents ?? message.agents)
setDefaultAgent(message.defaultAgent)
const names = new Set(message.agents.map((a) => a.name))
// Reset pending selection if the agent no longer exists (e.g. after org switch)
const pending = pendingAgentSelection()
if (!pending || !names.has(pending)) {
setPendingAgentSelection(message.defaultAgent)
}
// Clear per-session selections that reference a mode no longer available
setStore(
"agentSelections",
produce((selections) => {
for (const sid of Object.keys(selections)) {
if (selections[sid] && !names.has(selections[sid]!)) delete selections[sid]
}
}),
)
// Rescan already-loaded message history so sessions whose messagesLoaded
// arrived before agentsLoaded (and therefore got no agent selection) are
// backfilled now that we know the valid agent names.
batch(() => {
for (const [sid, msgs] of Object.entries(store.messages)) {
recoverPrefs(sid, msgs, names)
}
})
})
// Request agents immediately; if the extension's httpClient is not yet ready,
// extensionDataReady will fire once initialization completes and we retry once.
vscode.postMessage({ type: "requestAgents" })
// Skills loaded from the CLI backend
const unsubSkills = vscode.onMessage((message: ExtensionMessage) => {
if (message.type === "skillsLoaded") {
setSkills(message.skills)
}
})
const refreshSkills = () => {
vscode.postMessage({ type: "requestSkills" })
}
const removeSkill = (location: string) => {
setSkills((prev) => prev.filter((s) => s.location !== location))
vscode.postMessage({ type: "removeSkill", location })
}
// Handle permission events immediately (not in onMount) so we never miss
// the first permission request that may arrive before the DOM mounts.
// This matches the pattern already used for agentsLoaded and skillsLoaded.
const unsubPermissions = vscode.onMessage((message: ExtensionMessage) => {
switch (message.type) {
case "permissionRequest":
handlePermissionRequest(message.permission)
break
case "permissionResolved":
handlePermissionResolved(message.permissionID)
break
case "permissionError":
handlePermissionError(message.permissionID, message.stale)
break
}
})
onCleanup(unsubPermissions)
// MCP status loaded from CLI backend
const unsubMcpStatus = vscode.onMessage((message: ExtensionMessage) => {
if (message.type === "mcpStatusLoaded") {
setMcpStatus(message.status)
setMcpLoading(null)
}
})
// Request MCP status immediately; retry once on extensionDataReady if still missing.
vscode.postMessage({ type: "requestMcpStatus" })
const fallback = setTimeout(() => {
if (agents().length === 0) vscode.postMessage({ type: "requestAgents" })
if (Object.keys(mcpStatus()).length === 0) vscode.postMessage({ type: "requestMcpStatus" })
}, 3000)
const unsubReady = vscode.onMessage((message: ExtensionMessage) => {
if (message.type !== "extensionDataReady") return
unsubReady()
clearTimeout(fallback)
if (agents().length === 0) vscode.postMessage({ type: "requestAgents" })
if (Object.keys(mcpStatus()).length === 0) vscode.postMessage({ type: "requestMcpStatus" })
})
onCleanup(() => {
unsubAgents()
unsubSkills()
unsubMcpStatus()
unsubReady()
clearTimeout(fallback)
})
const variantList = (sessionID?: string) => {
const sel = selected(sessionID)
if (!sel) return []
const model = provider.findModel(sel)
if (!model?.variants) return []
return Object.keys(model.variants)
}
function variantForAgent(agentName: string, sel: ModelSelection | null) {
if (!sel) return undefined
const model = provider.findModel(sel)
return getAgentVariant(store.variantSelections, sel, model, agentName)
}
const currentVariant = (sessionID?: string) => {
const sid = sessionID ?? currentSessionID()
const sel = selected(sid)
if (!sel) return undefined
const list = variantList(sid)
if (list.length === 0) return undefined
return getVariant(store.variantSelections, sel, list, agentForScope(sid), sid)
}
const selectVariant = (value: string, sessionID?: string) => {
const sid = sessionID ?? currentSessionID()
const sel = selected(sid)
if (!sel) return
const key = variantKey(sel, agentForScope(sid), sid)
setStore("variantSelections", key, value)
if (!sid) vscode.postMessage({ type: "persistVariant", key, value })
}
// Load persisted variants from extension globalState
const unsubVariants = vscode.onMessage((message: ExtensionMessage) => {
if (message.type !== "variantsLoaded") return
for (const [k, v] of Object.entries(message.variants)) {
if (k.startsWith("session/")) continue
setStore("variantSelections", k, v)
}
})
vscode.postMessage({ type: "requestVariants" })
onCleanup(unsubVariants)
// Load persisted per-mode model selections from model.json via extension host.
// Uses replace semantics so a reset (empty payload) clears old entries.
const unsubSelections = vscode.onMessage((message: ExtensionMessage) => {
if (message.type !== "modelSelectionsLoaded") return
setStore("modelSelections", reconcile(message.selections))
const flags: Record<string, boolean> = {}
for (const name of Object.keys(message.selections)) {
flags[name] = true
}
setUserSetAgents(flags)
})
vscode.postMessage({ type: "requestModelSelections" })
onCleanup(unsubSelections)
// Load persisted recent models from extension globalState
const unsubRecents = vscode.onMessage((message: ExtensionMessage) => {
if (message.type !== "recentsLoaded") return
setStore("recentModels", message.recents)
})
vscode.postMessage({ type: "requestRecents" })
onCleanup(unsubRecents)
// Load persisted favorite models from extension globalState
const unsubFavorites = vscode.onMessage((message: ExtensionMessage) => {
if (message.type !== "favoritesLoaded") return
setStore("favoriteModels", message.favorites)
})
vscode.postMessage({ type: "requestFavorites" })
onCleanup(unsubFavorites)
// Clear model overrides that match the previous config model (not intentional user overrides).
// When config.model changes, old overrides that were just default values should be cleared
// so sessions fall through to resolveModel() and pick up the new config model.
const [lastConfigModel, setLastConfigModel] = createSignal<ModelSelection | null>(getGlobalModel())
createEffect(() => {
const newConfigModel = getGlobalModel()
// Use untrack to read previous value without making this effect re-trigger on its own updates
const oldConfigModel = untrack(() => lastConfigModel())
if (oldConfigModel) {
// Also clear when newConfigModel is null (user removed model from config)
if (newConfigModel) {
const modelChanged =
oldConfigModel.providerID !== newConfigModel.providerID || oldConfigModel.modelID !== newConfigModel.modelID
if (modelChanged) {
// Clear overrides that match the OLD config model - these were likely defaults,
// not intentional user overrides. Overrides that differ from both old and new
// config are preserved (intentional user selections).
setStore(
"sessionOverrides",
produce((overrides) => {
for (const sid of Object.keys(overrides)) {
const override = overrides[sid]
if (
override &&
override.providerID === oldConfigModel.providerID &&
override.modelID === oldConfigModel.modelID
) {
delete overrides[sid]
}
}
}),
)
}
} else {
// newConfigModel is null - clear all overrides that matched the old config model
// since the config no longer specifies a model. This ensures sessions fall through
// to provider defaults rather than using a stale removed model.
setStore(
"sessionOverrides",
produce((overrides) => {
for (const sid of Object.keys(overrides)) {
const override = overrides[sid]
if (
override &&
override.providerID === oldConfigModel.providerID &&
override.modelID === oldConfigModel.modelID
) {
delete overrides[sid]
}
}
}),
)
}
}
// Update the tracked config model
setLastConfigModel(newConfigModel)
})
function handleError(message: Extract<ExtensionMessage, { type: "error" }>) {
if (!message.sessionID || message.sessionID === currentSessionID()) setLoading(false)
if (message.sessionID) patchPage(message.sessionID, { loadingInitial: false, loadingOlder: false })
}
function toggleFavorite(providerID: string, modelID: string) {
const key = `${providerID}/${modelID}`
const idx = store.favoriteModels.findIndex((f) => `${f.providerID}/${f.modelID}` === key)
const updated =
idx >= 0 ? store.favoriteModels.filter((_, i) => i !== idx) : [...store.favoriteModels, { providerID, modelID }]
const action = idx >= 0 ? "remove" : "add"
setStore("favoriteModels", updated)
vscode.postMessage({ type: "toggleFavorite", action, providerID, modelID })
}
function handleStreamMessage(message: ExtensionMessage): boolean {
if (message.type === "partUpdated") {
handlePartUpdated(message.sessionID, message.messageID, message.part, message.delta)
return true
}
if (message.type === "partsUpdated") {
batch(() => {
for (const update of message.updates) {
handlePartUpdated(update.sessionID, update.messageID, update.part, update.delta)
}
})
return true
}
if (message.type === "partRemoved") {
handlePartRemoved(message.sessionID, message.messageID, message.partID)
return true
}
return false
}
function handleModelUsageMessage(message: ExtensionMessage): boolean {
if (message.type !== "sessionModelUsageLoaded") return false
const state = store.modelUsage[message.sessionID]
if (state?.requestID === message.requestID) {
setStore("modelUsage", message.sessionID, { requestID: message.requestID, data: message.data })
}
return true
}
function refreshModelUsageForMessage(message: ExtensionMessage) {
if (message.type === "sessionModelUsageChanged") {
if (modelUsageRelated(message.sessionID)) queueModelUsageRefresh()
return
}
if (message.type === "partUpdated") {
if (message.part.type === "step-finish" && modelUsageRelated(message.sessionID)) queueModelUsageRefresh()
return
}
if (message.type === "partsUpdated") {
if (message.updates.some((item) => item.part.type === "step-finish" && modelUsageRelated(item.sessionID))) {
queueModelUsageRefresh()
}
return
}
if (message.type === "partRemoved" || message.type === "messageRemoved" || message.type === "sessionDeleted") {
if (modelUsageRelated(message.sessionID, store.sessions[message.sessionID]?.parentID)) queueModelUsageRefresh()
return
}
if (message.type === "sessionCreated" && modelUsageRelated(message.session.id, message.session.parentID)) {
queueModelUsageRefresh()
return
}
if (message.type === "extensionDataReady") queueModelUsageRefresh()
}
function handleExtensionMessage(message: ExtensionMessage): void {
// Route suggestion messages (extracted to stay within complexity limit)
routeSuggestionMessage(message)
if (handleModelUsageMessage(message)) return
refreshModelUsageForMessage(message)
if (handleStreamMessage(message)) return
handleCommandCompletion(message)
cah.handleMessage(message)
switch (message.type) {
case "sessionCreated":
handleSessionCreated(message.session, message.draftID)
break
case "messagesLoaded":
handleMessagesLoaded(message.sessionID, message.messages, {
mode: message.mode,
cursor: message.cursor,
hasMore: message.hasMore,
since: message.since,
})
break
case "messageCreated":
handleMessageCreated(message.message)
break
case "sessionStatus":
handleSessionStatus(message.sessionID, message.status, message.attempt, message.message, message.next)
break
case "sessionTurnClosed":
setCloseMap(message.sessionID, message.reason)
break
case "todoUpdated":
handleTodoUpdated(message.sessionID, message.items)
break
case "questionRequest":
handleQuestionRequest(message.question)
break
case "questionResolved":
handleQuestionResolved(message.requestID)
break
case "questionError":
handleQuestionError(message.requestID)
break
case "clearPendingPrompts":
setPermissions([])
setQuestions([])
setSuggestions([])
setRespondingPermissions(new Set<string>())
setSuggestionErrors(new Set<string>())
setRespondingSuggestions(new Set<string>())
break
case "sessionsLoaded":
handleSessionsLoaded(message.sessions, message.preserveSessionIds)
break
case "sessionUpdated":
handleSessionUpdated(message.session)
break
case "sessionDeleted":
handleSessionDeleted(message.sessionID)
break
case "messageRemoved":
handleMessageRemoved(message.sessionID, message.messageID)
break
case "sessionError": {
if (message.error?.name === "MessageAbortedError") break
const sid = message.sessionID ?? currentSessionID()
if (!sid) break
// Find the last user message in this session to use as parentID
const msgs = store.messages[sid] ?? []
const parent = [...msgs].reverse().find((m) => m.role === "user")
const errorMsg: Message = {
id: Identifier.ascending("message"),
sessionID: sid,
role: "assistant",
createdAt: new Date().toISOString(),
parentID: parent?.id,
error: message.error,
}
handleMessageCreated(errorMsg)
break
}
case "error":
handleError(message)
break
case "sendMessageFailed":
handleSendMessageFailed(message as unknown as SendMessageFailedMessage)
break
case "cloudSessionDataLoaded":
handleCloudSessionDataLoaded(message.cloudSessionId, message.title, message.messages)
break
case "cloudSessionImported":
handleCloudSessionImported(message.cloudSessionId, message.session)
break
case "cloudSessionImportFailed": {
const failedKey = `cloud:${message.cloudSessionId}`
pruneCloudOrphans(failedKey)
setStore(
"sessions",
produce((sessions) => {
delete sessions[failedKey]
}),
)
setStore(
"messages",
produce((messages) => {
delete messages[failedKey]
}),
)
setStore(
"toolParts",
produce((toolParts) => {
delete toolParts[failedKey]
}),
)
// cloudPreviewId stores the raw cloud session id (see selectCloudSession),
// not the synthetic "cloud:<id>" key used for session/draft ids.
clearIfOn(cloudPreviewId, () => setLoading(false), message.cloudSessionId)
clearIfOn(cloudPreviewId, () => setCloudPreviewId(null), message.cloudSessionId)
clearIfOn(currentSessionID, () => setCurrentSessionID(undefined), failedKey)
clearIfOn(draftSessionID, () => setDraftSessionID(undefined), failedKey)
showToast({
variant: "error",
title: language.t("session.cloud.import.failed") ?? "Failed to import cloud session",
description: message.error,
})
console.error("[Kilo New] Cloud session import failed:", message.error)
break
}
case "worktreeStatsLoaded":
setWorktreeStats({ files: message.files, additions: message.additions, deletions: message.deletions })
break
}
}
// Handle messages from extension
onMount(() => {
const unsubscribe = vscode.onMessage(handleExtensionMessage)
setModelUsageReady(true)
onCleanup(unsubscribe)
})
// Event handlers
function handleSessionCreated(session: SessionInfo, draftID?: string) {
freshSessions.add(session.id)
if (draftID) aborts.move(draftID, session.id)
batch(() => {
setStore("sessions", session.id, session)
if (draftID && submissionMap[draftID]) {
const submissions = submissionMap[draftID]
for (const [id, scope] of pendingSubmissions) {
if (scope === draftID) pendingSubmissions.set(id, session.id)
}
setSubmissionMap(session.id, (count = 0) => count + submissions)
setSubmissionMap(
produce((map) => {
delete map[draftID]
}),
)
if (busySinceMap[draftID] && !busySinceMap[session.id]) {
setBusySinceMap(session.id, busySinceMap[draftID])
}
setBusySinceMap(
produce((map) => {
delete map[draftID]
}),
)
}
const drafts = draftID ? store.messages[draftID] : undefined
if (draftID && drafts?.length) {
const current = store.messages[session.id] ?? []
const ids = new Set(current.map((message) => message.id))
const promoted = drafts
.filter((message) => !ids.has(message.id))
.map((message) => ({ ...message, sessionID: session.id }))
setStore("messages", session.id, [...current, ...promoted])
setStore(
"messages",
produce((messages) => {
delete messages[draftID]
}),
)
const pending = pendingOptimistic.get(draftID)
if (pending) {
const merged = pendingOptimistic.get(session.id) ?? new Set<string>()
for (const id of pending) merged.add(id)
pendingOptimistic.set(session.id, merged)
pendingOptimistic.delete(draftID)
}
setLoaded((prev) => {
if (prev.has(session.id)) return prev
const next = new Set(prev)
next.add(session.id)
return next
})
patchPage(session.id, { lastMutation: "append" })
setPages(
produce((state) => {
delete state[draftID]
}),
)
}
// Only initialize messages if none exist yet — a cloud session import
// (handleCloudSessionImported) may have already populated messages for
// this session ID. The SSE session.created event can race with the
// cloudSessionImported message, and wiping to [] causes a flash of
// the empty/welcome screen.
if (!store.messages[session.id]?.length) {
setStore("messages", session.id, [])
}
if (!store.toolParts[session.id]) setStore("toolParts", session.id, [])
const pendingAgent = draftID ? store.agentSelections[draftID] : pendingAgentSelection()
const pendingModel = draftID ? store.sessionOverrides[draftID] : undefined
if (draftID) {
const entries = transferVariants(store.variantSelections, draftID, session.id)
for (const [key, value] of Object.entries(entries)) {
setStore("variantSelections", key, value)
vscode.postMessage({ type: "persistVariant", key, value })
}
if (pendingAgent) setStore("agentSelections", session.id, pendingAgent)
if (pendingModel) setStore("sessionOverrides", session.id, pendingModel)
setStore(
"agentSelections",
produce((agents) => {
delete agents[draftID]
}),
)
setStore(
"sessionOverrides",
produce((models) => {
delete models[draftID]
}),
)
setStore(
"variantSelections",
produce((variants) => {
for (const key of sessionVariantKeys(variants, draftID)) delete variants[key]
}),
)
agentDrafts.promote(draftID)
} else if (pendingAgent && !store.agentSelections[session.id]) {
setStore("agentSelections", session.id, pendingAgent)
setPendingAgentSelection(null)
}
const active = currentSessionID()
const draft = draftSessionID()
if (draftID && (draft === draftID || active === draftID)) {
setCurrentSessionID(session.id)
setDraftSessionID(session.id)
setUserClearedSession(false)
}
})
}
function patchPage(sessionID: string, patch: Partial<MessagePageState>) {
setPages(sessionID, { ...(pages[sessionID] ?? emptyPageState), ...patch })
}
function mergeMessages(current: Message[], incoming: Message[], mode: Exclude<MessageLoadMode, "focus">) {
if (mode === "reconcile") {
// Tail reconcile: incoming is the authoritative newest-N snapshot.
// Local state may already hold some of those IDs and may also hold
// newer optimistic entries created after the fetch was taken. Merge
// by id (server wins on collision) then sort by createdAt so new
// server messages land in the right position and optimistic tail
// entries stay at the end.
const byId = new Map<string, Message>()
for (const msg of current) byId.set(msg.id, msg)
for (const msg of incoming) byId.set(msg.id, msg)
return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
}
const seen = new Set<string>()
const source = mode === "prepend" ? [...incoming, ...current] : incoming
return source.filter((msg) => {
if (seen.has(msg.id)) return false
seen.add(msg.id)
return true
})
}
function recoverPrefs(sessionID: string, messages: Message[], names = agentNames()) {
const prefs = resolveMessagePrefs(messages, names)
if (prefs.agent && !store.agentSelections[sessionID]) {
setStore("agentSelections", sessionID, prefs.agent)
}
if (prefs.model && !store.sessionOverrides[sessionID]) {
setStore("sessionOverrides", sessionID, prefs.model)
}
if (prefs.model && prefs.variant) {
const agent = prefs.agent ?? store.agentSelections[sessionID] ?? defaultAgent()
const key = variantKey(prefs.model, agent, sessionID)
if (!store.variantSelections[key]) setStore("variantSelections", key, prefs.variant)
}
}
function withPending(sessionID: string, messages: Message[]) {
const pending = pendingOptimistic.get(sessionID)
if (!pending || pending.size === 0) return messages
const ids = new Set(messages.map((msg) => msg.id))
const current = store.messages[sessionID] ?? []
const orphans = current.filter((msg) => pending.has(msg.id) && !ids.has(msg.id))
return [...messages, ...orphans]
}
// Cheap tail check: same ids in the same order and no visible streamed-part
// correction to apply. It skips store churn when SSE already matches the
// snapshot, but lets reconcile heal part removals and finalized text.
function sameReconcileShape(current: Message[], incoming: Message[]): boolean {
if (current.length !== incoming.length) return false
for (const [i, n] of incoming.entries()) {
const c = current[i]!
if (c.id !== n.id) return false
if (!sameParts(store.parts[c.id] ?? c.parts, n.parts)) return false
}
return true
}
function setTools(sessionID: string, tools: ToolPart[]) {
setStore("toolParts", sessionID, reconcileSessionToolParts(tools))
}
function rebuildToolParts(sessionID: string, messages: Message[], parts?: Record<string, Part[]>) {
const tools = buildSessionToolParts(
messages,
(msg) => parts?.[msg.id] ?? store.parts[msg.id] ?? stash.peek(msg.id) ?? msg.parts,
)
setTools(sessionID, tools)
}
function messageParts(messages: Message[]): Record<string, Part[]> {
const parts: Record<string, Part[]> = {}
for (const msg of messages) {
if (msg.parts && msg.parts.length > 0) parts[msg.id] = msg.parts
}
return parts
}
function patchToolPart(sessionID: string | undefined, messageID: string, part: Part) {
const sid = sessionID ?? part.sessionID
if (!sid) return
if (part.type !== "tool") return
const tools = upsertSessionToolPart(store.toolParts[sid] ?? [], part, { id: messageID, sessionID: sid })
setTools(sid, tools)
}
function dropToolPart(sessionID: string | undefined, partID: string) {
if (!sessionID) return
setTools(sessionID, removeSessionToolPart(store.toolParts[sessionID] ?? [], partID))
}
function dropMessageTools(sessionID: string, messageID: string) {
setTools(sessionID, removeSessionToolPartsForMessage(store.toolParts[sessionID] ?? [], messageID))
}
function handleMessagesLoaded(
sessionID: string,
messages: Message[],
input: { mode?: Exclude<MessageLoadMode, "focus">; cursor?: string; hasMore?: boolean; since?: number } = {},
) {
const mode = input.mode ?? "replace"
const reset = mode === "prepend"
// Reconcile fast-path: if the tail matches local state shape-wise, every
// message+part-count already agrees with the server. Skip the reactive
// store churn entirely — virtualizer and rendering stay untouched.
if (mode === "reconcile" && sameReconcileShape(store.messages[sessionID] ?? [], messages)) {
const parts = messageParts(messages)
for (const msg of messages) {
if (store.parts[msg.id]) delete parts[msg.id]
}
rebuildToolParts(sessionID, messages, parts)
patchPage(sessionID, { lastMutation: "update" })
return
}
batch(() => {
setLoaded((prev) => {
if (prev.has(sessionID)) return prev
const next = new Set(prev)
next.add(sessionID)
return next
})
if (sessionID === currentSessionID()) setLoading(false)
const current = store.messages[sessionID] ?? []
const merged =
mode === "prepend" || mode === "reconcile"
? mergeMessages(current, messages, mode)
: withPending(sessionID, messages)
const loadedParts: Record<string, Part[]> = {}
// "replace" mode (session switch): assign directly — reconcile's O(n)
// diff is unnecessary when the entire list is new, and its reactive
// proxy creation for each message object dominated the trace (~900ms).
// "prepend" / "reconcile": reconcile to preserve existing proxies.
if (mode === "replace") {
setStore("messages", sessionID, merged)
} else {
setStore("messages", sessionID, reconcile(merged, { key: "id" }))
}
for (const msg of messages) {
const parts = msg.parts ?? []
if (mode === "reconcile" && store.parts[msg.id]) {
// Reconcile on a message already hydrated into the reactive store:
// write parts directly so visible turns pick up server corrections,
// but do not erase proven newer streamed text absent from a stale snapshot.
const merged = mergeParts(store.parts[msg.id], parts, input.since ?? Number.POSITIVE_INFINITY)
setStore("parts", msg.id, reconcile(merged, { key: "id" }))
stash.remove(msg.id)
continue
}
if (parts.length > 0) {
loadedParts[msg.id] = parts
// Stash parts outside the reactive store. They hydrate on demand
// when the virtualizer renders the corresponding turn.
stash.put(msg.id, parts)
continue
}
if (mode === "reconcile") stash.remove(msg.id)
}
rebuildToolParts(sessionID, merged, loadedParts)
// "reconcile" is a background tail refresh, not a page navigation —
// preserve the existing pagination cursor/hasMore so "load earlier"
// keeps working.
if (mode === "reconcile") {
patchPage(sessionID, { lastMutation: "update" })
} else {
setPages(sessionID, {
loadingInitial: false,
loadingOlder: false,
before: input.cursor,
hasMore: input.hasMore ?? Boolean(input.cursor),
lastMutation: mode,
})
}
const revert = store.sessions[sessionID]?.revert ?? undefined
if (revert) resetTodos(sessionID, revert)
recoverPrefs(sessionID, merged)
const cloudIDs = pendingCloudPrune.get(sessionID)
if (cloudIDs?.size) {
const live = new Set(messages.map((m) => m.id))
setStore(
"parts",
produce((p) => {
for (const id of cloudIDs) if (!live.has(id)) delete p[id]
}),
)
for (const id of cloudIDs) stash.remove(id)
pendingCloudPrune.delete(sessionID)
}
})
if (reset) requestAnimationFrame(() => patchPage(sessionID, { lastMutation: undefined }))
}
function handleMessageCreated(message: Message) {
if (message.role === "assistant") clearSessionDraftDiscarded(message.sessionID)
// Message confirmed by server — no longer optimistic.
// Clear placeholder parts so they don't duplicate alongside real parts
// arriving via individual part.updated events (the server's message.updated
// SSE event does NOT include parts).
const pending = pendingOptimistic.get(message.sessionID)
const wasOptimistic = pending?.has(message.id)
pending?.delete(message.id)
if (wasOptimistic) {
setStore(
"parts",
produce((p) => {
delete p[message.id]
}),
)
}
const exists = (store.messages[message.sessionID] ?? []).some((msg) => msg.id === message.id)
setStore("messages", message.sessionID, (msgs = []) => {
// Check if message already exists (optimistic or update case).
// Since we now use the same messageID for optimistic and server messages,
// this naturally handles the optimistic→real transition.
const idx = msgs.findIndex((m) => m.id === message.id)
if (idx >= 0) {
const updated = [...msgs]
updated[idx] = { ...msgs[idx], ...message }
return updated
}
return [...msgs, message]
})
patchPage(message.sessionID, { lastMutation: exists ? "update" : "append" })
recoverPrefs(message.sessionID, [message])
if (message.parts && message.parts.length > 0) {
stash.remove(message.id)
setStore("parts", message.id, message.parts)
}
rebuildToolParts(message.sessionID, store.messages[message.sessionID] ?? [])
}
function handleCommandCompletion(message: ExtensionMessage): void {
if (message.type === "sessionCommandCompleted") finishSubmission(message.messageID)
}
function handlePartUpdated(
sessionID: string | undefined,
messageID: string | undefined,
part: Part,
delta?: PartDelta,
) {
// Get messageID from the part itself if not provided in the message
const effectiveMessageID = messageID || part.messageID
if (!effectiveMessageID) {
console.warn("[Kilo New] Part updated without messageID:", part.id, part.type)
return
}
if (sessionID) patchPage(sessionID, { lastMutation: "update" })
patchToolPart(sessionID, effectiveMessageID, part)
// If the stash has parts for this message, hydrate them first so the
// SSE update merges into the full part list rather than an empty array.
const stashed = stash.peek(effectiveMessageID)
if (stashed) {
stash.remove(effectiveMessageID)
setStore("parts", effectiveMessageID, stashed)
}
setStore(
"parts",
produce((parts) => {
if (!parts[effectiveMessageID]) {
parts[effectiveMessageID] = []
}
const existingIndex = parts[effectiveMessageID].findIndex((p) => p.id === part.id)
if (existingIndex >= 0) {
// Update existing part
const existing = parts[effectiveMessageID][existingIndex]
if (
delta?.type === "text-delta" &&
delta.textDelta &&
(existing.type === "text" || existing.type === "reasoning")
) {
// Append text delta to text or reasoning parts
;(existing as { text: string }).text += delta.textDelta
} else {
// Preserve the proxy identity so Solid does not remount tool UI
// during streaming updates and restart pending animations.
const target = existing as unknown as Record<string, unknown>
for (const key of Object.keys(target)) {
if (!(key in part)) delete target[key]
}
Object.assign(existing, part)
}
} else {
// Add new part
parts[effectiveMessageID].push(part)
}
}),
)
}
function handlePartRemoved(sessionID: string | undefined, messageID: string, partID: string) {
if (sessionID) patchPage(sessionID, { lastMutation: "update" })
dropToolPart(sessionID, partID)
stash.removePart(messageID, partID)
setStore(
"parts",
produce((parts) => {
const list = parts[messageID]
if (!list) return
const idx = list.findIndex((p) => p.id === partID)
if (idx < 0) return
list.splice(idx, 1)
}),
)
}
function handleSessionStatus(
sessionID: string,
newStatus: SessionStatus,
attempt?: number,
message?: string,
next?: number,
) {
const shouldAbort = aborts.update(sessionID, newStatus)
confirmSubmissions(sessionID)
const prev = statusMap[sessionID] ?? { type: "idle" }
const info: SessionStatusInfo =
newStatus === "retry"
? { type: "retry", attempt: attempt ?? 0, message: message ?? "", next: next ?? 0 }
: newStatus === "offline"
? { type: "offline", message: message ?? "" }
: { type: newStatus }
setStatusMap(sessionID, info)
// Track busy start time and discard the previous turn's terminal state.
if (prev.type === "idle" && newStatus !== "idle") {
clearClose(sessionID)
if (!busySinceMap[sessionID]) setBusySinceMap(sessionID, Date.now())
}
if (newStatus === "idle") {
setBusySinceMap(
produce((map) => {
delete map[sessionID]
}),
)
// Session is idle - any remaining pending optimistic IDs are either
// already confirmed (messageCreated removed them) or orphaned (queued
// callbacks were dropped on abort). Clean up the tracking set; the
// messages themselves will be reconciled on the next messagesLoaded.
pendingOptimistic.delete(sessionID)
}
if (shouldAbort) vscode.postMessage({ type: "abort", sessionID })
}
function handlePermissionRequest(permission: PermissionRequest) {
setPermissions((prev) => upsertPermission(prev, permission))
}
function handlePermissionResolved(permissionID: string) {
setPermissions((prev) => prev.filter((p) => p.id !== permissionID))
setRespondingPermissions((prev) => {
if (!prev.has(permissionID)) return prev
const next = new Set(prev)
next.delete(permissionID)
return next
})
}
function handlePermissionError(permissionID: string, stale?: boolean) {
setRespondingPermissions((prev) => {
if (!prev.has(permissionID)) return prev
const next = new Set(prev)
next.delete(permissionID)
return next
})
if (stale) {
setPermissions((prev) => prev.filter((p) => p.id !== permissionID))
return
}
showToast({
variant: "error",
title: language.t("settings.permissions.toast.updateFailed.title"),
})
}
function handleQuestionRequest(question: QuestionRequest) {
setQuestions((prev) => {
const idx = prev.findIndex((q) => q.id === question.id)
if (idx === -1) return [...prev, question]
const next = prev.slice()
next[idx] = question
return next
})
}
function handleQuestionResolved(requestID: string) {
setQuestions((prev) => prev.filter((q) => q.id !== requestID))
setQuestionErrors((prev) => {
const next = new Set(prev)
next.delete(requestID)
return next
})
}
function handleQuestionError(requestID: string) {
setQuestionErrors((prev) => new Set(prev).add(requestID))
}
function handleSuggestionRequest(suggestion: SuggestionRequest) {
setSuggestions((prev) => {
const idx = prev.findIndex((item) => item.id === suggestion.id)
if (idx === -1) return [...prev, suggestion]
const next = prev.slice()
next[idx] = suggestion
return next
})
}
function handleSuggestionResolved(requestID: string) {
setSuggestions((prev) => prev.filter((item) => item.id !== requestID))
setRespondingSuggestions((prev) => {
if (!prev.has(requestID)) return prev
const next = new Set(prev)
next.delete(requestID)
return next
})
setSuggestionErrors((prev) => {
if (!prev.has(requestID)) return prev
const next = new Set(prev)
next.delete(requestID)
return next
})
}
function handleSuggestionError(requestID: string) {
setRespondingSuggestions((prev) => {
if (!prev.has(requestID)) return prev
const next = new Set(prev)
next.delete(requestID)
return next
})
setSuggestionErrors((prev) => new Set(prev).add(requestID))
}
/**
* Route suggestion-related extension messages.
* Extracted from the main message handler to stay within the complexity limit.
*/
function routeSuggestionMessage(message: ExtensionMessage) {
switch (message.type) {
case "suggestionRequest":
handleSuggestionRequest(message.suggestion)
break
case "suggestionResolved":
handleSuggestionResolved(message.requestID)
break
case "suggestionError":
handleSuggestionError(message.requestID)
break
}
}
/**
* Handle a failed send: remove the optimistic message from the store
* and show a toast. The PromptInput restores the draft text separately
* by listening for the same sendMessageFailed event.
*/
function handleSendMessageFailed(message: SendMessageFailedMessage) {
const sid = message.sessionID ?? message.draftID
if (message.messageID) finishSubmission(message.messageID)
if (!message.messageID && sid) aborts.clear(sid)
if (sid && message.messageID) {
pendingOptimistic.get(sid)?.delete(message.messageID)
stash.remove(message.messageID)
batch(() => {
setStore("messages", sid, (msgs = []) => msgs.filter((m) => m.id !== message.messageID))
dropMessageTools(sid, message.messageID!)
setStore(
"parts",
produce((parts) => {
delete parts[message.messageID!]
}),
)
})
}
showToast({
variant: "error",
title: language.t("prompt.toast.promptSendFailed.title") ?? "Failed to send message",
description: message.error,
})
if (!message.sessionID && message.draftID) {
if (draftSessionID() !== message.draftID) agentDrafts.prune(message.draftID)
}
}
function visibleToolParts(sessionID: string, messages: Message[]): ToolPart[] {
const ids = new Set(messages.map((msg) => msg.id))
return (store.toolParts[sessionID] ?? []).filter((part) => !part.messageID || ids.has(part.messageID))
}
/**
* BFS walk over message parts to discover all session IDs in a session's
* family tree (self + subagents + sub-subagents). Reads directly from the
* store so it's reactive — automatically updates when new parts arrive.
*/
function sessionIDs(rootID: string, source: (sessionID: string) => Message[]): Set<string> {
const ids = new Set<string>([rootID])
const queue = [rootID]
while (queue.length > 0) {
const sid = queue.pop()!
for (const p of visibleToolParts(sid, source(sid))) {
// Webview ToolState omits runtime metadata; task parts still carry it from the backend.
const child = childID(
p as {
type: string
tool?: string
metadata?: { sessionId?: string }
state?: { metadata?: { sessionId?: string } }
},
)
if (child && !ids.has(child)) {
ids.add(child)
queue.push(child)
}
}
}
return ids
}
function sessionFamily(rootID: string): Set<string> {
return sessionIDs(rootID, (sid) => store.messages[sid] ?? [])
}
function modelUsageRelated(sessionID: string, parentID?: string | null): boolean {
const current = currentSessionID()
if (!current) return false
const ids = store.modelUsage[current]?.data?.sessionIDs
if (ids?.includes(sessionID) || (!!parentID && ids?.includes(parentID))) return true
const family = sessionFamily(current)
if (family.has(sessionID) || (!!parentID && family.has(parentID))) return true
return isSameSessionTree(current, sessionID, (id) => store.sessions[id], parentID)
}
function visibleFamily(rootID: string): Set<string> {
return sessionIDs(rootID, visible)
}
createEffect(() => {
if (!modelUsageReady() || !server.isConnected() || !currentSessionID()) return
untrack(refreshModelUsage)
})
/** Return permissions scoped to the given session's family (self + subagents). */
function scopedPermissions(sessionID: string | undefined): PermissionRequest[] {
if (!sessionID) return []
const family = sessionFamily(sessionID)
return permissions().filter((p) => family.has(p.sessionID))
}
/** Return questions scoped to the given session's family (self + subagents). */
function scopedQuestions(sessionID: string | undefined): QuestionRequest[] {
if (!sessionID) return []
const family = sessionFamily(sessionID)
return questions().filter((q) => family.has(q.sessionID))
}
function scopedSuggestions(sessionID: string | undefined): SuggestionRequest[] {
if (!sessionID) return []
const family = sessionFamily(sessionID)
return suggestions().filter((item) => family.has(item.sessionID))
}
function handleTodoUpdated(sessionID: string, items: TodoItem[]) {
setStore("todos", sessionID, items)
}
function resetTodos(sessionID: string, revert?: NonNullable<SessionInfo["revert"]>) {
const items = todoState({
messages: store.messages[sessionID] ?? [],
parts: (messageID) => store.parts[messageID] ?? stash.peek(messageID),
revert,
})
setStore("todos", sessionID, items)
}
function handleSessionUpdated(session: SessionUpdate) {
const changed = session.revert !== undefined
const prev = store.sessions[session.id]?.revert
const next = session.revert ?? undefined
setStore("sessions", session.id, session)
if (!changed || (prev?.messageID === next?.messageID && prev?.partID === next?.partID)) return
clearClose(session.id)
resetTodos(session.id, next)
}
function handleSessionsLoaded(loaded: SessionInfo[], preserve?: string[]) {
const ids = new Set(loaded.map((s) => s.id))
for (const id of ids) freshSessions.delete(id)
const kept = new Set([...(preserve ?? []), ...freshSessions])
batch(() => {
// Reconcile: remove sessions not in the loaded list to prevent stale
// entries from other projects accumulating in the store.
// Sessions whose worktree directories failed to list are preserved —
// their absence is transient, not a real deletion.
setStore(
"sessions",
produce((sessions) => {
for (const id of Object.keys(sessions)) {
if (id.startsWith("cloud:")) continue
if (kept?.has(id)) continue
if (!ids.has(id)) delete sessions[id]
}
}),
)
for (const s of loaded) {
setStore("sessions", s.id, s)
}
})
}
function handleSessionDeleted(sessionID: string) {
pendingOptimistic.delete(sessionID)
freshSessions.delete(sessionID)
aborts.clear(sessionID)
confirmSubmissions(sessionID)
batch(() => {
// Collect message IDs so we can clean up their parts (store + stash)
const msgs = store.messages[sessionID] ?? []
const msgIds = msgs.map((m) => m.id)
for (const id of msgIds) stash.remove(id)
clearHiddenErrors(msgIds)
setStore(
produce((s) => {
delete s.sessions[sessionID]
delete s.messages[sessionID]
for (const id of msgIds) delete s.parts[id]
delete s.toolParts[sessionID]
delete s.todos[sessionID]
for (const [id, state] of Object.entries(s.modelUsage)) {
if (id === sessionID || state.data?.sessionIDs.includes(sessionID)) delete s.modelUsage[id]
}
delete s.agentSelections[sessionID]
delete s.sessionOverrides[sessionID]
for (const key of sessionVariantKeys(s.variantSelections, sessionID)) delete s.variantSelections[key]
}),
)
// prettier-ignore
setPages(produce((map) => { delete map[sessionID] }))
// Clean up pending questions/errors for the deleted session
const deleted = questions()
.filter((q) => q.sessionID === sessionID)
.map((q) => q.id)
if (deleted.length > 0) {
setQuestions((prev) => prev.filter((q) => q.sessionID !== sessionID))
setQuestionErrors((prev) => dropSet(prev, deleted))
}
const gone = suggestions()
.filter((item) => item.sessionID === sessionID)
.map((item) => item.id)
if (gone.length > 0) {
setSuggestions((prev) => prev.filter((item) => item.sessionID !== sessionID))
setSuggestionErrors((prev) => dropSet(prev, gone))
setRespondingSuggestions((prev) => dropSet(prev, gone))
}
const staleResponding = permissions()
.filter((p) => p.sessionID === sessionID)
.map((p) => p.id)
setPermissions((prev) => removeSessionPermissions(prev, sessionID))
if (staleResponding.length > 0) {
setRespondingPermissions((prev) => dropSet(prev, staleResponding))
}
// prettier-ignore
setLoaded((prev) => { if (!prev.has(sessionID)) return prev; const next = new Set(prev); next.delete(sessionID); return next })
// prettier-ignore
setStatusMap(produce((map) => { delete map[sessionID] }))
clearClose(sessionID)
// prettier-ignore
setBusySinceMap(produce((map) => { delete map[sessionID] }))
if (currentSessionID() === sessionID) {
setCurrentSessionID(undefined)
setLoading(false)
}
// prettier-ignore
if (draftSessionID() === sessionID) { setDraftSessionID(undefined) }
})
deleteDraftsForSession(sessionID)
pruneCloudOrphans(sessionID)
}
// Splices the message from the store and deletes its parts.
function handleMessageRemoved(sessionID: string, messageID: string) {
setStore("messages", sessionID, (msgs = []) => msgs.filter((m) => m.id !== messageID))
dropMessageTools(sessionID, messageID)
clearHiddenErrors([messageID])
setStore(
"parts",
produce((parts) => {
delete parts[messageID]
}),
)
// Also clear any stashed parts for this message. Without this, a
// removed-before-hydrated message leaks parts in the stash and can
// resurface them via getParts() after the message is gone.
stash.remove(messageID)
}
function handleCloudSessionDataLoaded(cloudSessionId: string, title: string, messages: Message[]) {
if (cloudPreviewId() !== cloudSessionId) return
const key = `cloud:${cloudSessionId}`
pendingCloudPrune.set(key, new Set(messages.map((m) => m.id)))
batch(() => {
setLoaded((prev) => {
if (prev.has(key)) return prev
const next = new Set(prev)
next.add(key)
return next
})
setStore("sessions", key, {
id: key,
title,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
patchPage(key, { hasMore: false, lastMutation: "replace" })
setStore("messages", key, messages)
for (const msg of messages) {
if (msg.parts && msg.parts.length > 0) {
setStore("parts", msg.id, msg.parts)
}
}
rebuildToolParts(key, messages)
setCurrentSessionID(key)
setLoading(false)
})
}
function handleCloudSessionImported(cloudSessionId: string, session: SessionInfo) {
freshSessions.add(session.id)
const cloudKey = `cloud:${cloudSessionId}`
const cloudMessages = store.messages[cloudKey] ?? []
const active = cloudPreviewId() === cloudSessionId && currentSessionID() === cloudKey
batch(() => {
setLoaded((prev) => {
const next = new Set(prev)
next.add(session.id)
next.delete(cloudKey)
return next
})
setStore("sessions", session.id, session)
const pendingAgent = pendingAgentSelection()
if (pendingAgent && !store.agentSelections[session.id]) {
setStore("agentSelections", session.id, pendingAgent)
}
// Carry over cloud messages so there's no loading flash
setStore("messages", session.id, cloudMessages)
rebuildToolParts(session.id, cloudMessages)
if (active) {
setCloudPreviewId(null)
setCurrentSessionID(session.id)
setDraftSessionID(session.id)
setUserClearedSession(false)
}
setStore(
"sessions",
produce((sessions) => {
delete sessions[cloudKey]
}),
)
setStore(
"messages",
produce((messages) => {
delete messages[cloudKey]
}),
)
setStore(
"toolParts",
produce((parts) => {
delete parts[cloudKey]
}),
)
})
const cloudPruneIDs = pendingCloudPrune.get(cloudKey)
if (cloudPruneIDs) {
pendingCloudPrune.set(session.id, cloudPruneIDs)
pendingCloudPrune.delete(cloudKey)
}
// Load real messages in the background (picks up server-assigned IDs
// and the new user message once the send completes via SSE)
patchPage(session.id, { loadingInitial: true, before: undefined, hasMore: false })
vscode.postMessage({ type: "loadMessages", sessionID: session.id, mode: "replace", limit: MESSAGE_PAGE_LIMIT })
}
// Actions
function selectAgent(name: string, sessionID?: string) {
const id = sessionID ?? currentSessionID()
if (id) {
setStore("agentSelections", id, name)
// Clear per-session model override so the new mode's configured/default
// model takes effect instead of the previous mode's override.
setStore(
"sessionOverrides",
produce((overrides) => {
delete overrides[id]
}),
)
if (shouldClearModeModelSelection(name)) {
clearModeModelSelection(name)
}
} else {
setPendingAgentSelection(name)
if (shouldClearModeModelSelection(name)) {
clearModeModelSelection(name)
return
}
// When switching mode, initialize model for the new mode if the user
// hasn't explicitly set one for it
if (!userSetAgents()[name] && !store.modelSelections[name]) {
setStore("modelSelections", name, resolveModel(name))
}
}
}
/** Create an optimistic user message + parts in the store so the UI updates instantly. */
function addOptimistic(
sid: string,
messageID: string,
text: string,
files?: FileAttachment[],
review?: ReviewMessageData,
) {
const now = Date.now()
const temp: Message = {
id: messageID,
sessionID: sid,
role: "user",
createdAt: new Date(now).toISOString(),
time: { created: now },
}
const pending = pendingOptimistic.get(sid) ?? new Set()
pending.add(messageID)
pendingOptimistic.set(sid, pending)
const parts: Part[] = []
if (text) {
parts.push({
type: "text" as const,
id: Identifier.ascending("part"),
messageID,
text,
metadata: review ? reviewMetadata(review) : undefined,
})
}
for (const file of files ?? []) {
parts.push({
type: "file" as const,
id: Identifier.ascending("part"),
messageID,
mime: file.mime,
url: file.url,
filename: file.filename,
source: file.source,
})
}
setStore("messages", sid, (msgs = []) => [...msgs, temp])
setStore("parts", messageID, parts)
patchPage(sid, { lastMutation: "append" })
queueMicrotask(() => window.dispatchEvent(new CustomEvent("resumeAutoScroll")))
}
function sendMessage(
text: string,
providerID?: string,
modelID?: string,
files?: FileAttachment[],
draftID?: string,
context?: string,
review?: ReviewMessageData,
origin?: string | null,
) {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot send message: not connected")
return
}
const messageID = Identifier.ascending("message")
const sid = origin === undefined ? currentSessionID() : (origin ?? undefined)
const preview = sid?.startsWith("cloud:")
? sid.slice("cloud:".length)
: origin === undefined
? cloudPreviewId()
: null
if (preview) {
const scope = draftID ?? sid
const agent = promptAgent(scope)
vscode.postMessage({
type: "importAndSend",
cloudSessionId: preview,
text,
messageID,
providerID,
modelID,
agent,
variant: currentVariant(scope),
files,
review,
})
return
}
const suggestion = scopedSuggestions(sid)[0]
if (suggestion) dismissSuggestion(suggestion.id)
for (const q of scopedQuestions(sid)) {
dismissQuestion(q.id)
}
const effectiveDraftID = !sid && !draftID ? crypto.randomUUID() : draftID
const scope = effectiveDraftID ?? sid
if (!sid && !draftID && effectiveDraftID) agentDrafts.seed(effectiveDraftID)
if (scope) {
clearClose(scope)
addOptimistic(scope, messageID, text, files, review)
startSubmission(scope, messageID)
if (!sid && (!draftID || draftSessionID() === scope)) {
setUserClearedSession(false)
setDraftSessionID(scope)
}
}
const agent = promptAgent(scope)
vscode.postMessage({
type: "sendMessage",
text,
messageID,
sessionID: sid,
draftID: effectiveDraftID,
providerID,
modelID,
agent,
variant: currentVariant(scope),
files,
review,
agentManagerContext: context,
})
}
function sendCommand(
command: string,
args: string,
providerID?: string,
modelID?: string,
files?: FileAttachment[],
draftID?: string,
context?: string,
origin?: string | null,
) {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot send command: not connected")
return
}
// Cloud previews need import-then-command; post importAndSend with command metadata
const sid = origin === undefined ? currentSessionID() : (origin ?? undefined)
const preview = sid?.startsWith("cloud:")
? sid.slice("cloud:".length)
: origin === undefined
? cloudPreviewId()
: null
if (preview) {
const scope = draftID ?? sid
const agent = promptAgent(scope)
vscode.postMessage({
type: "importAndSend",
cloudSessionId: preview,
text: `/${command} ${args}`.trim(),
messageID: Identifier.ascending("message"),
providerID,
modelID,
agent,
variant: currentVariant(scope),
files,
command,
commandArgs: args,
})
return
}
const messageID = Identifier.ascending("message")
const suggestion = scopedSuggestions(sid)[0]
if (suggestion) dismissSuggestion(suggestion.id)
for (const q of scopedQuestions(sid)) {
dismissQuestion(q.id)
}
const effectiveDraftID = !sid && !draftID ? crypto.randomUUID() : draftID
const scope = effectiveDraftID ?? sid
if (!sid && !draftID && effectiveDraftID) agentDrafts.seed(effectiveDraftID)
if (scope) {
clearClose(scope)
addOptimistic(scope, messageID, `/${command} ${args}`.trim(), files)
startSubmission(scope, messageID)
if (!sid && (!draftID || draftSessionID() === scope)) {
setUserClearedSession(false)
setDraftSessionID(scope)
}
}
const agent = promptAgent(scope)
vscode.postMessage({
type: "sendCommand",
command,
arguments: args,
messageID,
sessionID: sid,
draftID: effectiveDraftID,
providerID,
modelID,
agent,
variant: currentVariant(scope),
files,
agentManagerContext: context,
})
}
function abort() {
const sessionID = currentSessionID()
const scope = sessionID ?? draftSessionID()
if (!scope) {
console.warn("[Kilo New] Cannot abort: no current or pending session")
return
}
const messageID = [...pendingSubmissions].reverse().find(([, sid]) => sid === scope)?.[0]
if (!aborts.request(scope, status(), messageID) || !sessionID) return
vscode.postMessage({
type: "abort",
sessionID,
})
}
function compact() {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot compact: not connected")
return
}
const sessionID = currentSessionID()
if (!sessionID) {
console.warn("[Kilo New] Cannot compact: no current session")
return
}
const sel = selected()
vscode.postMessage({
type: "compact",
sessionID,
providerID: sel?.providerID,
modelID: sel?.modelID,
})
}
function respondToPermission(
permissionId: string,
response: "once" | "always" | "reject",
approvedAlways: string[],
deniedAlways: string[],
) {
// Resolve sessionID from the stored permission request
const permission = permissions().find((p) => p.id === permissionId)
const sessionID = permission?.sessionID ?? currentSessionID() ?? ""
// Mark as responding so the UI disables the buttons.
// The permission is removed when the server confirms via permission.replied SSE.
setRespondingPermissions((prev) => new Set(prev).add(permissionId))
vscode.postMessage({
type: "permissionResponse",
permissionId,
sessionID,
response,
approvedAlways,
deniedAlways,
})
}
function clearQuestionError(requestID: string) {
setQuestionErrors((prev) => {
if (!prev.has(requestID)) return prev
const next = new Set(prev)
next.delete(requestID)
return next
})
}
function clearSuggestionError(requestID: string) {
setSuggestionErrors((prev) => {
if (!prev.has(requestID)) return prev
const next = new Set(prev)
next.delete(requestID)
return next
})
}
function replyToQuestion(requestID: string, answers: string[][]) {
clearQuestionError(requestID)
const question = questions().find((item) => item.id === requestID)
const sessionID = question?.sessionID ?? currentSessionID() ?? ""
if (cah.reply(requestID, "continue")) return
vscode.postMessage({
type: "questionReply",
requestID,
sessionID,
answers,
})
}
function dismissQuestion(requestID: string) {
questions().find((item) => item.id === requestID)?.dismissResponse === "continue"
? replyToQuestion(requestID, [])
: rejectQuestion(requestID)
}
function closeQuestion(id: string) {
cah.close(id, dismissQuestion)
}
function rejectQuestion(requestID: string) {
clearQuestionError(requestID)
const question = questions().find((item) => item.id === requestID)
const sessionID = question?.sessionID ?? currentSessionID() ?? ""
if (cah.reply(requestID, "stop")) return
vscode.postMessage({
type: "questionReject",
requestID,
sessionID,
})
}
function acceptSuggestion(requestID: string, index: number) {
clearSuggestionError(requestID)
setRespondingSuggestions((prev) => new Set(prev).add(requestID))
const sid = suggestions().find((s) => s.id === requestID)?.sessionID ?? currentSessionID() ?? ""
vscode.postMessage({
type: "suggestionAccept",
requestID,
sessionID: sid,
index,
})
}
function dismissSuggestion(requestID: string) {
clearSuggestionError(requestID)
setRespondingSuggestions((prev) => new Set(prev).add(requestID))
const sid = suggestions().find((s) => s.id === requestID)?.sessionID ?? currentSessionID() ?? ""
vscode.postMessage({
type: "suggestionDismiss",
requestID,
sessionID: sid,
})
}
function createSession() {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot create session: not connected")
return
}
// Reset agent selection to default for the new session (model overrides persist)
agentDrafts.prune(draftSessionID())
setPendingAgentSelection(defaultAgent())
vscode.postMessage({ type: "createSession" })
}
function clearCurrentSession() {
agentDrafts.prune(draftSessionID())
setUserClearedSession(true)
setCurrentSessionID(undefined)
setDraftSessionID(undefined)
setCloudPreviewId(null)
setLoading(false)
setPendingAgentSelection(defaultAgent())
vscode.postMessage({ type: "clearSession" })
}
function loadSessions() {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot load sessions: not connected")
return
}
vscode.postMessage({ type: "loadSessions" })
}
function loadOlderMessages() {
const id = currentSessionID()
if (!id || !server.isConnected()) return false
const page = pages[id] ?? emptyPageState
if (!page.hasMore || page.loadingOlder || page.loadingInitial || !page.before) return false
patchPage(id, { loadingOlder: true })
vscode.postMessage({
type: "loadMessages",
sessionID: id,
mode: "prepend",
before: page.before,
limit: MESSAGE_PAGE_LIMIT,
})
return true
}
// Session whose message fetch was deferred because the backend was offline at
// selection time. Replayed by the reconnect effect below.
let deferredFetch: string | undefined
function selectSession(id: string) {
// Cloud preview sessions use a separate keyed path (selectCloudSession).
if (id.startsWith("cloud:")) {
console.warn("[Kilo New] Cannot select cloud preview session via selectSession")
return
}
const ready = loaded().has(id)
// Reflect the selection locally and synchronously so the chat always tracks
// the sidebar/tab selection. These are local signals and need no backend, so
// they update even while disconnected. Bailing out here when not connected
// froze the chat on the previous session while the side diff (resolved from
// the worktree selection) still moved (the reported "only the diff changes").
agentDrafts.prune(draftSessionID())
setCloudPreviewId(null)
setCurrentSessionID(id)
setDraftSessionID(id)
setUserClearedSession(false)
setLoading(!ready)
if (!ready) patchPage(id, { loadingInitial: true, loadingOlder: false, before: undefined, hasMore: false })
// Only the message fetch needs the backend. Defer it while offline and let
// the reconnect effect replay it. We defer even for cached sessions: the
// load message is what re-focuses the backend (focusSession, contextSessionID,
// SSE tracking, active worktree) and runs the reconcile self-heal, so skipping
// it would leave the extension focused on the previously selected session.
if (!server.isConnected()) {
deferredFetch = id
return
}
deferredFetch = undefined
loadFocusedMessages(id, ready)
}
function loadFocusedMessages(id: string, ready: boolean) {
vscode.postMessage(
ready
? { type: "loadMessages", sessionID: id, mode: "focus" }
: { type: "loadMessages", sessionID: id, mode: "replace", limit: MESSAGE_PAGE_LIMIT },
)
}
// Replay a fetch deferred while offline once the backend reconnects. Scoped to
// the still-current session so the normal connected path never double-fetches.
// Uses the same focus/replace choice as a live selection so a reconnect after
// a cached-session switch still re-focuses the backend and reconciles.
createEffect(
on(server.isConnected, (connected) => {
if (!connected) return
const id = deferredFetch
deferredFetch = undefined
if (!id || id !== currentSessionID()) return
loadFocusedMessages(id, loaded().has(id))
}),
)
function selectCloudSession(cloudSessionId: string) {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot select cloud session: not connected")
return
}
const key = `cloud:${cloudSessionId}`
agentDrafts.prune(draftSessionID())
setCloudPreviewId(cloudSessionId)
setCurrentSessionID(key)
setDraftSessionID(key)
setUserClearedSession(false)
setLoading(true)
vscode.postMessage({ type: "requestCloudSessionData", sessionId: cloudSessionId })
}
function deleteSession(id: string) {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot delete session: not connected")
return
}
// Optimistically remove from the list so the UI updates immediately
setStore(
"sessions",
produce((sessions) => {
delete sessions[id]
}),
)
setLoaded((prev) => {
if (!prev.has(id)) return prev
const next = new Set(prev)
next.delete(id)
return next
})
if (id === currentSessionID() || id === draftSessionID()) setUserClearedSession(true)
vscode.postMessage({ type: "deleteSession", sessionID: id })
}
function renameSession(id: string, title: string) {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot rename session: not connected")
return
}
vscode.postMessage({ type: "renameSession", sessionID: id, title })
}
function exportSessionTranscript(id: string) {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot export session transcript: not connected")
return
}
if (id.startsWith("cloud:")) {
console.warn("[Kilo New] Cannot export cloud session transcript")
return
}
vscode.postMessage({ type: "exportSessionTranscript", sessionID: id })
}
// Computed values
const currentSession = () => {
const id = currentSessionID()
return id ? store.sessions[id] : undefined
}
const pageState = () => {
const id = currentSessionID() ?? draftSessionID()
return id ? (pages[id] ?? emptyPageState) : emptyPageState
}
const loadingOlderMessages = () => pageState().loadingOlder
const hasOlderMessages = () => pageState().hasMore
const messageMutation = () => pageState().lastMutation
const messages = () => {
const id = currentSessionID() ?? draftSessionID()
return id ? store.messages[id] || [] : []
}
const getParts = (messageID: string) => {
return store.parts[messageID] || stash.peek(messageID) || []
}
const getSessionToolParts = (sessionID: string) => store.toolParts[sessionID] ?? []
const getSessionToolCount = (sessionID: string) => store.toolParts[sessionID]?.length ?? 0
function hydrateParts(ids: string[]) {
const pending = stash.take(ids, (id) => Boolean(store.parts[id]))
if (Object.keys(pending).length === 0) return
setStore(
"parts",
produce((p) => {
for (const [id, parts] of Object.entries(pending)) p[id] = parts
}),
)
}
const allMessages = () => store.messages
const allParts = () => store.parts
const allStatusMap = () => statusMap as Record<string, SessionStatusInfo>
const userMessages = createMemo(() => messages().filter((m) => m.role === "user"))
function visible(sessionID: string) {
return filterVisibleMessages(
store.messages[sessionID] ?? [],
store.sessions[sessionID]?.revert ?? undefined,
(msg) => getParts(msg.id),
)
}
const revert = createMemo(() => {
const id = currentSessionID()
// revert can be null (cleared by unrevert) or undefined (never set) — treat both as "no revert"
return id ? (store.sessions[id]?.revert ?? undefined) : undefined
})
const visibleMessages = createMemo(() => {
const id = currentSessionID() ?? draftSessionID()
return id ? visible(id) : []
})
const revertedCount = createMemo(() => {
const boundary = revert()?.messageID
if (!boundary) return 0
return userMessages().filter((m) => m.id >= boundary).length
})
const summary = createMemo(() => {
const id = currentSessionID()
return id ? (store.sessions[id]?.summary ?? undefined) : undefined
})
function revertSession(messageID: string, partID?: string) {
const id = currentSessionID()
if (!id) return
clearClose(id)
// Restore the reverted user message's prompt text into the input.
// Dispatch as a window message so PromptInput picks it up via onMessage.
const parts = store.parts[messageID]
if (parts) {
const text = parts
.filter((p) => p.type === "text" && !(p as { synthetic?: boolean }).synthetic)
.map((p) => (p as { text: string }).text ?? "")
.join("")
// Pass the original attachments' exact paths alongside the restored text
// so PromptInput can seed them directly rather than re-deriving mentions
// from the text via regex, which truncates at the first space in a
// filename (see PromptInput's setChatBoxMessage handler).
const paths = parts
.filter((p): p is Extract<Part, { type: "file" }> => p.type === "file")
.map((p) => p.source?.path)
.filter((p): p is string => !!p && !p.startsWith("session:"))
const sessions = parts
.filter((p): p is Extract<Part, { type: "file" }> => p.type === "file")
.filter((p) => p.url.startsWith("session:"))
.map((p) => ({
id: p.url.slice("session:".length),
title: p.source?.text?.value.replace(/^@/, "") ?? p.filename ?? p.url,
updated: 0,
}))
if (text) window.postMessage({ type: "setChatBoxMessage", text, paths, sessions }, "*")
}
vscode.postMessage({ type: "revertSession", sessionID: id, messageID, partID })
}
function unrevertSession() {
const id = currentSessionID()
if (!id) return
// Clear the prompt input on full redo (matching TUI/desktop behavior)
window.postMessage({ type: "setChatBoxMessage", text: "" }, "*")
vscode.postMessage({ type: "unrevertSession", sessionID: id })
}
// Clear local send bookkeeping and request deletion. The message stays visible
// until messageRemoved confirms deletion; a false response leaves it in place.
function deleteQueuedMessage(sessionID: string, messageID: string) {
if (!server.isConnected()) return
pendingOptimistic.get(sessionID)?.delete(messageID)
finishSubmission(messageID)
vscode.postMessage({ type: "deleteMessage", sessionID, messageID })
}
function syncSession(sessionID: string) {
vscode.postMessage({ type: "syncSession", sessionID, parentSessionID: currentSessionID() })
}
const todos = () => {
const id = currentSessionID()
return id ? store.todos[id] || [] : []
}
const sessions = createMemo(() =>
Object.values(store.sessions)
.filter((s) => !s.id.startsWith("cloud:"))
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()),
)
/**
* Per-session **own cost** — reads `store.messages` for per-session
* propagated totals and task metadata as a fallback for parent links so each
* session's entry excludes the cost already propagated up from its
* descendants by the CLI backend.
*/
const familyCosts = createMemo<Map<string, number>>(() => {
const id = currentSessionID()
if (!id) return new Map()
const family = visibleFamily(id)
const msgs: Record<string, Message[]> = {}
for (const sid of family) msgs[sid] = visible(sid)
const parents = buildFamilyParentsFromTools(family, (sid) => visibleToolParts(sid, msgs[sid] ?? []))
return buildFamilyCosts(family, msgs, store.sessions, parents)
})
/** Child session labels — only reads store.parts (not message costs). */
const familyLabels = createMemo<Map<string, string>>(() => {
const id = currentSessionID()
if (!id) return new Map()
const family = visibleFamily(id)
const msgs: Record<string, Message[]> = {}
for (const sid of family) msgs[sid] = visible(sid)
return buildFamilyLabelsFromTools(family, (sid) => visibleToolParts(sid, msgs[sid] ?? []))
})
/** Combined cost breakdown with labels. */
const costBreakdown = createMemo<Array<{ label: string; cost: number }>>(() => {
const id = currentSessionID()
const costs = familyCosts()
if (!id || costs.size === 0) return []
return buildCostBreakdown(id, costs, familyLabels(), language.t("context.stats.thisSession"))
})
// Status text derived from last assistant message parts
const statusText = createMemo<string | undefined>(() => {
if (status() === "idle") return undefined
const fallback = language.t("ui.sessionTurn.status.consideringNextSteps")
const id = currentSessionID()
const msgs = messages()
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role !== "assistant") continue
const parts = getParts(msgs[i].id)
if (parts.length === 0) break
const raw = computeStatus(parts[parts.length - 1], language.t) ?? fallback
// When delegating to a subagent and that subagent is blocked on a prompt,
// replace the generic "Delegating work" label with a more informative one
// so the user understands why nothing appears to be happening.
if (raw === language.t("ui.sessionTurn.status.delegating")) {
const scoped = scopedPermissions(id)
if (scoped.length > 0) return language.t("ui.sessionTurn.status.delegatingWaitingPermission")
const scopedQ = scopedQuestions(id)
if (scopedQ.length > 0) return language.t("ui.sessionTurn.status.delegatingWaitingQuestion")
}
return raw
}
return fallback
})
const modelUsage = createMemo<SessionModelUsage | undefined>(() => {
const id = currentSessionID()
return id ? store.modelUsage[id]?.data : undefined
})
const contextUsage = createMemo<ContextUsage | undefined>(() => {
const msgs = visibleMessages()
for (let i = msgs.length - 1; i >= 0; i--) {
const m = msgs[i]
if (m.role !== "assistant" || !m.tokens) continue
const usage = calcContextUsage(m.tokens, undefined)
if (usage.tokens === 0) continue
const sel = selected()
const model = sel ? provider.findModel(sel) : undefined
const limit = model?.limit?.context ?? model?.contextLength
return calcContextUsage(m.tokens, limit)
}
return undefined
})
const value: SessionContextValue = {
currentSessionID,
currentSession,
setCurrentSessionID,
sessions,
status,
statusInfo,
closeReason,
statusText,
busySince,
submitting,
isSubmitting,
loading,
loadingOlderMessages,
hasOlderMessages,
messageMutation,
messages,
visibleMessages,
userMessages,
getParts,
getSessionToolParts,
getSessionToolCount,
isErrorHidden: (messageID: string) => hiddenErrors().has(messageID),
hydrateParts,
todos,
permissions,
respondingPermissions,
questions,
questionErrors,
suggestions,
suggestionErrors,
respondingSuggestions,
scopedPermissions,
scopedQuestions,
scopedSuggestions,
selected,
configModel,
modelForAgent,
configModelForAgent,
selectModel,
hasModelOverride,
clearModelOverride,
costBreakdown,
contextUsage,
modelUsage,
agents,
allAgents,
skills,
refreshSkills,
removeSkill,
removeAgent,
removeMcp,
mcpStatus,
mcpLoading,
connectMcp,
disconnectMcp,
authenticateMcp,
selectedAgent: agentForScope,
selectAgent,
getSessionAgent: (sessionID: string) => store.agentSelections[sessionID] ?? defaultAgent(),
setSessionModel: (sessionID: string, providerID: string, modelID: string) => {
// Only write per-session override — do NOT touch global modelSelections or
// userSetAgents. The override is what selected() actually
// reads, and mutating the global map here is both redundant and harmful: the
// agent may not yet be assigned (sendInitialMessage calls setSessionModel
// before setSessionAgent), so the write would land on defaultAgent() and
// corrupt the default mode's model for later sessions.
const model = { providerID, modelID }
setStore("sessionOverrides", sessionID, model)
},
setSessionAgent: (sessionID: string, name: string) => {
setStore("agentSelections", sessionID, name)
},
setSessionVariant: (sessionID: string, providerID: string, modelID: string, value: string, agent?: string) => {
const name = agent ?? store.agentSelections[sessionID] ?? defaultAgent()
const key = variantKey({ providerID, modelID }, name, sessionID)
setStore("variantSelections", key, value)
},
allMessages,
allParts,
allStatusMap,
favoriteModels: () => store.favoriteModels,
toggleFavorite,
variantList,
currentVariant,
variantForAgent,
selectVariant,
revert,
revertedCount,
summary,
worktreeStats,
revertSession,
unrevertSession,
deleteQueuedMessage,
sendMessage,
sendCommand,
abort,
compact,
respondToPermission,
replyToQuestion,
rejectQuestion,
closeQuestion,
acceptSuggestion,
dismissSuggestion,
createSession,
clearCurrentSession,
loadSessions,
loadOlderMessages,
selectSession,
deleteSession,
renameSession,
exportSessionTranscript,
syncSession,
cloudPreviewId,
selectCloudSession,
draftSessionID,
setDraftSessionID,
userClearedSession,
}
return <SessionContext.Provider value={value}>{props.children}</SessionContext.Provider>
}
export function useSession(): SessionContextValue {
const context = useContext(SessionContext)
if (!context) {
throw new Error("useSession must be used within a SessionProvider")
}
return context
}