diff --git a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx index 95882dea1c4..dafc7c6b47b 100644 --- a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx +++ b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx @@ -20,6 +20,7 @@ Object.assign(globalThis, { HTMLTextAreaElement: window.HTMLTextAreaElement, SVGElement: window.SVGElement, MutationObserver: window.MutationObserver, + IntersectionObserver: window.IntersectionObserver, ResizeObserver: window.ResizeObserver, CustomEvent: window.CustomEvent, Event: window.Event, diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index 17a9093f485..c0f6e5e2da8 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -1,8 +1,8 @@ /** * Source contract tests for prompt send paths. * - * Static analysis — reads session.tsx source and verifies that sendMessage and - * sendCommand still dismiss suggestions and reject questions before dispatching. + * Static analysis — reads the session context source and verifies that sendMessage + * and sendCommand still dismiss suggestions and reject questions before dispatching. * Also reads ChatView.tsx and asserts the prompt-block predicate is fed only * permission counts, never question counts — guarantees that a pending question * cannot re-block the prompt input. @@ -17,6 +17,7 @@ import { clearIfOn } from "../../webview-ui/src/context/session-cloud-prune" const ROOT = path.resolve(import.meta.dir, "../..") const SESSION_FILE = path.join(ROOT, "webview-ui/src/context/session.tsx") +const SESSION_TYPES_FILE = path.join(ROOT, "webview-ui/src/context/session-types.ts") const CHATVIEW_FILE = path.join(ROOT, "webview-ui/src/components/chat/ChatView.tsx") const AGENT_MANAGER_FILE = path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx") const PROMPT_UTILS_FILE = path.join(ROOT, "webview-ui/src/components/chat/prompt-input-utils.ts") @@ -427,7 +428,7 @@ describe("SessionContext userClearedSession contract", () => { // restoreFailed uses session.userClearedSession() to decide whether :new // is a legitimate restore target after the user clicks New Task or // deletes their current/draft session. The accessor must be exposed. - expect(source).toMatch(/userClearedSession:\s*Accessor/) + expect(readFile(SESSION_TYPES_FILE)).toMatch(/userClearedSession:\s*Accessor/) }) it("clearCurrentSession sets the flag", () => { diff --git a/packages/kilo-vscode/webview-ui/src/context/session-types.ts b/packages/kilo-vscode/webview-ui/src/context/session-types.ts new file mode 100644 index 00000000000..f9c545c67a2 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/session-types.ts @@ -0,0 +1,214 @@ +import type { Accessor } from "solid-js" +import type { ReviewMessageData } from "../../../src/shared/review-comments" +import type { + AgentInfo, + ContextUsage, + FileAttachment, + McpStatusEntry, + Message, + ModelSelection, + ModelUsageMap, + Part, + PermissionRequest, + QuestionRequest, + SessionCloseReason, + SessionInfo, + SessionModelUsage, + SessionStatus, + SessionStatusInfo, + SkillInfo, + SuggestionRequest, + TodoItem, + ToolPart, +} from "../types/messages" +import type { Activity } from "../utils/session-activity" +import type { MessageMutation } from "./session-utils" + +export interface SessionContextValue { + // Current session + currentSessionID: Accessor + currentSession: Accessor + setCurrentSessionID: (id: string | undefined) => void + + // All sessions (sorted most recent first) + sessions: Accessor + + // Session status + status: Accessor + statusInfo: Accessor + closeReason: Accessor + statusText: Accessor + busySince: Accessor + submitting: Accessor + isSubmitting: (id: string) => boolean + loading: Accessor + loadingOlderMessages: Accessor + hasOlderMessages: Accessor + messageMutation: Accessor + + // Messages for current session + messages: Accessor + + // Messages for current session with soft-reverted turns hidden + visibleMessages: Accessor + + // User messages for current session (role === "user") + userMessages: Accessor + + // All messages keyed by sessionID (includes child sessions) + allMessages: () => Record + + // All parts keyed by messageID (includes child sessions) + allParts: () => Record + + // All session statuses keyed by sessionID (for DataBridge) + allStatusMap: () => Record + + activityFor: (sessionID: string | undefined) => Activity + inUseFor: (sessionID: string) => boolean + + // 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 + + // Pending permission requests (unscoped — all tracked sessions) + permissions: Accessor + respondingPermissions: Accessor> + + // Pending question requests (unscoped — all tracked sessions) + questions: Accessor + questionErrors: Accessor> + suggestions: Accessor + suggestionErrors: Accessor> + respondingSuggestions: Accessor> + + // 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 + modelForAgent: (agent: string) => ModelSelection | null + selectModel: (providerID: string, modelID: string, sessionID?: string) => void + + // Cost and context usage for the current session + costBreakdown: Accessor> + contextUsage: Accessor + modelUsage: Accessor + + // Skills loaded from the CLI backend + skills: Accessor + refreshSkills: () => void + removeSkill: (location: string) => void + + // Agent/mode selection (per-session) + agents: Accessor + allAgents: Accessor + removeAgent: (name: string) => void + removeMcp: (name: string) => void + + // MCP server status (runtime connect/disconnect) + mcpStatus: Accessor> + mcpLoading: Accessor + 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 | undefined, sessionID?: string) => void + + // Model favorites + recentModels: Accessor + modelUsageHistory: Accessor + favoriteModels: Accessor + toggleFavorite: (providerID: string, modelID: string) => void + + // Revert/undo state for the current session + revert: Accessor + revertedCount: Accessor + summary: Accessor + + // 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, + overrides?: { agent?: string; model?: string; variant?: string }, + ) => 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, options?: { focus?: boolean }) => void + releaseSession: (id: string) => void + deleteSession: (id: string) => void + renameSession: (id: string, title: string) => void + exportSessionTranscript: (id: string) => void + syncSession: (sessionID: string, parentSessionID?: string, scope?: "task" | "inspector") => void + unsyncSession: (sessionID: string, scope?: "task" | "inspector") => void + + // Cloud session preview + cloudPreviewId: Accessor + selectCloudSession: (cloudSessionId: string) => void + draftSessionID: Accessor + setDraftSessionID: (id: string | undefined) => void + userClearedSession: Accessor +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index a0c8ebf1b17..76d012d3cfc 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -17,7 +17,7 @@ import { batch, untrack, } from "solid-js" -import type { ParentComponent, Accessor } from "solid-js" +import type { ParentComponent } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useVSCode } from "./vscode" import { useServer } from "./server" @@ -73,7 +73,6 @@ import { removeSessionToolPartsForMessage, revertPromptState, upsertSessionToolPart, - type MessageMutation, type MessagePageState, } from "./session-utils" import { Identifier } from "../utils/id" @@ -97,6 +96,7 @@ import { isSameSessionTree } from "./model-usage" import { createDraftAgentSeed, resolvePromptAgent } from "./session-agent" import { createModelSelector } from "./session-model-selector" import { activities, type Activity } from "../utils/session-activity" +import type { SessionContextValue } from "./session-types" const RECENT_LIMIT = 5 const MESSAGE_PAGE_LIMIT = 80 @@ -123,195 +123,6 @@ interface CloseState { parentID?: string } -interface SessionContextValue { - // Current session - currentSessionID: Accessor - currentSession: Accessor - setCurrentSessionID: (id: string | undefined) => void - - // All sessions (sorted most recent first) - sessions: Accessor - - // Session status - status: Accessor - statusInfo: Accessor - closeReason: Accessor - statusText: Accessor - busySince: Accessor - submitting: Accessor - isSubmitting: (id: string) => boolean - loading: Accessor - loadingOlderMessages: Accessor - hasOlderMessages: Accessor - messageMutation: Accessor - - // Messages for current session - messages: Accessor - - // Messages for current session with soft-reverted turns hidden - visibleMessages: Accessor - - // User messages for current session (role === "user") - userMessages: Accessor - - // All messages keyed by sessionID (includes child sessions) - allMessages: () => Record - - // All parts keyed by messageID (includes child sessions) - allParts: () => Record - - // All session statuses keyed by sessionID (for DataBridge) - allStatusMap: () => Record - - activityFor: (sessionID: string | undefined) => Activity - inUseFor: (sessionID: string) => boolean - - // 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 - - // Pending permission requests (unscoped — all tracked sessions) - permissions: Accessor - respondingPermissions: Accessor> - - // Pending question requests (unscoped — all tracked sessions) - questions: Accessor - questionErrors: Accessor> - suggestions: Accessor - suggestionErrors: Accessor> - respondingSuggestions: Accessor> - - // 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 - modelForAgent: (agent: string) => ModelSelection | null - selectModel: (providerID: string, modelID: string, sessionID?: string) => void - - // Cost and context usage for the current session - costBreakdown: Accessor> - contextUsage: Accessor - modelUsage: Accessor - - // Skills loaded from the CLI backend - skills: Accessor - refreshSkills: () => void - removeSkill: (location: string) => void - - // Agent/mode selection (per-session) - agents: Accessor - allAgents: Accessor - removeAgent: (name: string) => void - removeMcp: (name: string) => void - - // MCP server status (runtime connect/disconnect) - mcpStatus: Accessor> - mcpLoading: Accessor - 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 | undefined, sessionID?: string) => void - - // Model favorites - recentModels: Accessor - modelUsageHistory: Accessor - favoriteModels: Accessor - toggleFavorite: (providerID: string, modelID: string) => void - - // Revert/undo state for the current session - revert: Accessor - revertedCount: Accessor - summary: Accessor - - // 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, - overrides?: { agent?: string; model?: string; variant?: string }, - ) => 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, options?: { focus?: boolean }) => void - releaseSession: (id: string) => void - deleteSession: (id: string) => void - renameSession: (id: string, title: string) => void - exportSessionTranscript: (id: string) => void - syncSession: (sessionID: string, parentSessionID?: string, scope?: "task" | "inspector") => void - unsyncSession: (sessionID: string, scope?: "task" | "inspector") => void - - // Cloud session preview - cloudPreviewId: Accessor - selectCloudSession: (cloudSessionId: string) => void - draftSessionID: Accessor - setDraftSessionID: (id: string | undefined) => void - userClearedSession: Accessor -} - export const SessionContext = createContext() export const SessionProvider: ParentComponent = (props) => {