mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-29 18:52:45 +08:00
3077 lines
105 KiB
TypeScript
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
|
|
}
|