perf(vscode): paginated message loading with virtualized scroll (#8911)

* perf(vscode): paginated message loading with virtualized scroll

Reimplement session message loading with cursor-based pagination and
virtual list rendering to reduce initial load time and memory for long
sessions.

* chore: update kilo-vscode visual regression baselines

* fix(vscode): update VscodeSessionTurn props in storybook

* chore: update kilo-vscode visual regression baselines

* perf(vscode): skip reconcile on session switch, lazy part hydration, fast markdown render

Skip SolidJS reconcile() for replace-mode message loads — direct array
assignment avoids expensive O(n) diffing and proxy creation for 80+
messages on every session switch.

Defer part hydration until the virtualizer renders each turn, reducing
reactive store writes by 85% on initial load. Double-rAF scroll
restoration avoids forced layout reflow mid-paint.

Extract markdown fast-path render into packages/ui/src/kilocode/ to
minimize shared file changes.

* fix(vscode): keep lazy part hydration correct in stories

* fix(vscode): resolve state-risk regressions in message pagination

Five correctness issues surfaced during review of the paginated-message-
loading stack, plus the follow-through performance tuning needed to keep
session switching near-instant:

- focus-mode selection re-enters the server for the tail (new "reconcile"
  load mode) so SSE drops self-heal on the next session switch instead of
  stranding the webview on a stale snapshot. Throttled to 1s to avoid
  stacking up fetches on rapid tab switching, and early-outs in the
  webview when the server tail matches local state.
- cursor pagination falls back to a client-synthesized `{id,time}` cursor
  when a proxy or older binary strips the X-Next-Cursor header, so "load
  earlier" keeps working.
- in-flight loadMessages results for a session deleted mid-fetch are
  dropped instead of resurrecting a ghost entry in the webview store.
- sub-agent viewer now loads the full transcript via `limit: 0` instead
  of silently truncating to MESSAGE_PAGE_LIMIT (it has no "load earlier"
  UI to recover from the cap).
- stashed message parts are now cleared on messageRemoved. Extracted the
  stash-access helpers into a PartStash class so every lifecycle event
  coordinates store + stash cleanup in one place.

Also restores the two-pass markdown rendering intended by PR #7102 — an
upstream merge silently re-added marked-shiki, which made Shiki run
synchronously during parse and froze the main thread for up to 1.3s on
session switches with many code blocks. Code blocks render as plain
<pre><code> first and deferredHighlight() upgrades them after paint.

Regression tests cover all five state risks (fetchMessagePage cursor
fallback, KiloProvider focus reconcile, ghost session on prepend,
sub-agent full load, focus-mode throttle, PartStash leak on
messageRemoved).

* chore(vscode): restore unrelated doc comments in KiloProvider

PR review: the previous fix commit trimmed four unrelated doc comments
(loadMessagesAbort, handleSyncSession JSDoc, "inherit parent directory"
comment, handleDeleteSession JSDoc) to squeeze under the 3350-line
max-lines cap. Restoring them — the cap is hit exactly at 3350 and the
PR surface stays focused on the state-risk fixes.

* chore: update kilo-vscode visual regression baselines

* refactor: simplify pagination helpers without behavior change

- fetchMessagePage: drop the conditional spread for `limit`/`before` — the
  server schema accepts `limit: 0` the same as omitted (`z.coerce.number()
  .int().min(0).optional()`), so always passing the values directly works.
  Inline the `oldest` temp and drop `?? undefined` (`headers.get()` returns
  `null | string` which `??` handles identically downstream).
- handleLoadMessages: inline the single-use `focus` boolean, fold the
  `mode === "replace"` refresh call into the `if (abort)` block since
  they're gated by the same condition.
- sameReconcileShape: destructure `c`/`n` once per iteration instead of
  accessing `current[i]!` / `incoming[i]!` three times each.

All 1815 tests pass, typecheck + lint clean.

* docs: clarify intent of helpers flagged in PR review

- commands.ts: comment explains the in-flight dedup pattern and why the
  identity check in the `finally` guards against clear-then-restart races.
- sessionsForWorktree: comment notes the oldest-first sort is the canonical
  order before applyTabOrder, and why both the worktree label and the tab
  bar must agree on "which session is first".

No behavior change — both helpers already did this; now the intent is on
the page for future readers.

* chore: address PR #8911 review feedback from chrarnoldus

- Rewrite changeset as user-facing imperative summary (per AGENTS.md
  guidance: changesets appear in release notes; keep concise and feature-
  oriented, not implementation details).
- Drop redundant `kilocode_change - new file` marker on
  packages/ui/src/kilocode/markdown-fast-path.ts — the kilocode/ directory
  already signals the file is a Kilo addition; markers aren't needed in
  paths containing "kilocode".

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Marius
2026-04-17 16:47:45 +02:00
committed by GitHub
parent e258fa2bce
commit eac2dbafa0
29 changed files with 1277 additions and 301 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@opencode-ai/ui": patch
---
Make switching between sessions in Agent Manager near-instant. Long sessions no longer freeze the UI when selected, and the chat view self-heals if it missed any messages while the session was in the background.
+1
View File
@@ -322,6 +322,7 @@
"simple-git": "3.35.2",
"solid-js": "^1.9.11",
"uri-js": "^4.4.1",
"virtua": "catalog:",
"web-tree-sitter": "^0.24.7",
"yaml": "2.8.3",
"zod": "^3.24.2",
+1
View File
@@ -880,6 +880,7 @@
"simple-git": "3.35.2",
"solid-js": "^1.9.11",
"uri-js": "^4.4.1",
"virtua": "catalog:",
"web-tree-sitter": "^0.24.7",
"yaml": "2.8.3",
"zod": "^3.24.2"
+113 -102
View File
@@ -49,6 +49,8 @@ import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-fil
import { handleFileSearch } from "./kilo-provider/file-search"
import { getTerminalContents } from "./services/terminal/context"
import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
import { clearCommandsCache, loadCommands } from "./kilo-provider/commands"
import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-page"
import { childID } from "./kilo-provider/task-session"
import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network"
import { abortSession, parseQueued } from "./kilo-provider/abort"
@@ -110,6 +112,8 @@ type KiloProviderOptions = {
slimEditMetadata?: boolean
}
type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile"
// Helper to map agent data to the subset of fields sent to the webview
const mapAgent = (a: Agent) => ({
name: a.name,
@@ -172,6 +176,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private projectID: string | undefined
/** Abort controller for the current loadMessages request; aborted when a new session is selected. */
private loadMessagesAbort: AbortController | null = null
/** Per-session last focus-mode reconcile timestamp — throttles rapid tab switching. */
private lastReconciledAt = new Map<string, number>()
/** Set when refreshSessions() is called before the client is ready.
* Cleared and retried once the connection transitions to "connected". */
private pendingSessionRefresh = false
@@ -453,6 +459,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.trackedSessionIds.add(sessionId)
}
public loadMessages(sessionID: string): Promise<void> {
// Sub-agent viewer: full transcript (no "load earlier" UI, no pagination).
return this.handleLoadMessages(sessionID, { limit: 0 })
}
/**
* Register a directory override for a session (e.g., worktree path).
* When set, all operations for this session use this directory instead of the workspace root.
@@ -638,7 +649,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "loadMessages":
// Don't await: allow parallel loads so rapid session switching
// isn't blocked by slow responses for earlier sessions.
void this.handleLoadMessages(message.sessionID)
void this.handleLoadMessages(message.sessionID, {
mode: message.mode,
before: message.before,
limit: message.limit,
})
break
case "syncSession":
this.handleSyncSession(message.sessionID, message.parentSessionID).catch((e) =>
@@ -1273,109 +1288,103 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
/**
* Handle loading messages for a session.
*/
private async handleLoadMessages(sessionID: string): Promise<void> {
// Track the session so we receive its SSE events
this.trackedSessionIds.add(sessionID)
this.focusSession(sessionID)
this.contextSessionID = sessionID
if (!this.client) {
this.postMessage({
type: "error",
message: "Not connected to CLI backend",
sessionID,
/** Non-blocking: refresh session metadata + status for the webview after switching. */
private refreshSessionDetails(sessionID: string, dir: string, signal?: AbortSignal): void {
if (!this.client) return
this.client.session
.get({ sessionID, directory: dir })
.then((r) => {
if (r.data && !signal?.aborted) {
this.currentSession = r.data
this.contextSessionID = r.data.id
}
})
.catch((e: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", e))
this.postMessage({ type: "workspaceDirectoryChanged", directory: this.getWorkspaceDirectory(sessionID) })
this.client.session
.status({ directory: dir })
.then((r) => {
if (!r.data || signal?.aborted) return
for (const [sid, info] of Object.entries(r.data) as [string, SessionStatus][]) {
if (!this.trackedSessionIds.has(sid)) continue
this.postMessage({
type: "sessionStatus",
sessionID: sid,
status: info.type,
...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}),
})
}
})
.catch((e: unknown) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", e))
}
private async handleLoadMessages(
sessionID: string,
options: { mode?: MessageLoadMode; before?: string; limit?: number } = {},
): Promise<void> {
const mode = options.mode ?? "replace"
if (mode !== "prepend") {
this.trackedSessionIds.add(sessionID)
this.focusSession(sessionID)
this.contextSessionID = sessionID
}
if (!this.client) {
this.postMessage({ type: "error", message: "Not connected to CLI backend", sessionID })
return
}
// Abort any previous in-flight loadMessages request so the backend
// isn't overwhelmed when the user switches sessions rapidly.
this.loadMessagesAbort?.abort()
const abort = new AbortController()
this.loadMessagesAbort = abort
const dir = this.getWorkspaceDirectory(sessionID)
if (mode === "focus") {
this.refreshSessionDetails(sessionID, dir)
// Reconcile tail so SSE drops self-heal. Throttled to skip rapid tab-switching bursts.
if (Date.now() - (this.lastReconciledAt.get(sessionID) ?? 0) < 1000) return
await this.handleLoadMessages(sessionID, { mode: "reconcile", limit: options.limit ?? MESSAGE_PAGE_LIMIT })
return
}
// Replace competes for the spinner and cancels earlier loads; prepend/reconcile run in parallel.
const abort = mode === "replace" ? new AbortController() : undefined
if (abort) {
this.loadMessagesAbort?.abort()
this.loadMessagesAbort = abort
this.refreshSessionDetails(sessionID, dir, abort.signal)
}
try {
const workspaceDir = this.getWorkspaceDirectory(sessionID)
const { data: messagesData } = await retry(() =>
this.client!.session.messages(
{ sessionID, directory: workspaceDir },
{ throwOnError: true, signal: abort.signal },
),
)
// If this request was aborted while awaiting, skip posting stale results
if (abort.signal.aborted) return
// Update currentSession so fallback logic in handleSendMessage/handleAbort
// references the correct session after switching. loadMessages is the
// canonical "user switched to this session" signal, so always update —
// the old guard `this.currentSession.id === sessionID` prevented updates
// when switching between different sessions.
// Non-blocking: don't let a failure here prevent messages from loading.
// 404s are expected for cross-worktree sessions — use silent to suppress HTTP error logs.
this.client.session
.get({ sessionID, directory: workspaceDir })
.then((result) => {
if (result.data && !abort.signal.aborted) {
this.currentSession = result.data
this.contextSessionID = result.data.id
}
})
.catch((err: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", err))
this.postMessage({
type: "workspaceDirectoryChanged",
directory: this.getWorkspaceDirectory(sessionID),
const page = await fetchMessagePage(this.client, {
sessionID,
workspaceDir: dir,
limit: options.limit ?? MESSAGE_PAGE_LIMIT,
before: options.before,
signal: abort?.signal,
})
// Fetch current session status so the webview has the correct busy/idle
// state after switching tabs (SSE events may have been missed).
this.client.session
.status({ directory: workspaceDir })
.then((result) => {
if (!result.data) return
for (const [sid, info] of Object.entries(result.data) as [string, SessionStatus][]) {
if (!this.trackedSessionIds.has(sid)) continue
this.postMessage({
type: "sessionStatus",
sessionID: sid,
status: info.type,
...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}),
})
}
})
.catch((err: unknown) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", err))
const messages = messagesData.map((m) => ({
if (abort?.signal.aborted) return
// Drop results for a session deleted mid-fetch. Prepend/reconcile have
// no abort controller, so this guard prevents ghost entries.
if (!this.trackedSessionIds.has(sessionID)) return
const messages = page.items.map((m) => ({
...m.info,
parts: this.slimParts(m.parts),
createdAt: new Date(m.info.time.created).toISOString(),
}))
for (const message of messages) {
this.connectionService.recordMessageSessionId(message.id, message.sessionID)
}
// Snapshot must reflect every SSE event up to its taken-time; any
// delta still queued here is either already applied in the snapshot
// (re-emitting would duplicate streamed text) or trails the snapshot
// and is silently lost via drop().
this.streams.drop(sessionID)
this.postMessage({ type: "messagesLoaded", sessionID, messages })
// Authoritative snapshot: drop queued deltas. Prepend is older history
// and must not clobber live deltas.
if (mode === "replace" || mode === "reconcile") this.streams.drop(sessionID)
if (mode === "reconcile") this.lastReconciledAt.set(sessionID, Date.now())
this.postMessage({
type: "messagesLoaded",
sessionID,
messages,
mode,
cursor: page.cursor,
hasMore: Boolean(page.cursor),
})
// Recover any prompts missed while the webview was loading or during an SSE reconnection.
this.recoverPendingPrompts()
} catch (error) {
// Silently ignore aborted requests — the user switched to a different session
if (abort.signal.aborted) return
if (abort?.signal.aborted) return
console.error("[Kilo New] KiloProvider: Failed to load messages:", error)
this.postMessage({
type: "error",
message: getErrorMessage(error) || "Failed to load messages",
sessionID,
})
this.postMessage({ type: "error", message: getErrorMessage(error) || "Failed to load messages", sessionID })
}
}
@@ -1420,7 +1429,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Snapshot supersedes any queued deltas (see handleLoadMessages for the
// snapshot-freshness assumption that governs drop() here).
this.streams.drop(sessionID)
this.postMessage({ type: "messagesLoaded", sessionID, messages })
this.postMessage({
type: "messagesLoaded",
sessionID,
messages,
mode: "replace",
hasMore: false,
})
// Recover any prompts emitted by the child before we started tracking it.
this.recoverPendingPrompts()
} catch (err) {
@@ -1516,6 +1532,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.streams.drop(sessionID)
this.syncedChildSessions.delete(sessionID)
this.sessionDirectories.delete(sessionID)
this.lastReconciledAt.delete(sessionID)
this.connectionService.pruneSession(sessionID)
if (this.currentSession?.id === sessionID) {
this.currentSession = null
@@ -1735,6 +1752,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
private clearCommandsCache(): void {
this.cachedCommandsMessage = null
clearCommandsCache()
}
private async fetchAndSendCommands(): Promise<void> {
if (!this.client) {
if (this.cachedCommandsMessage) {
@@ -1745,19 +1767,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
try {
const dir = this.getWorkspaceDirectory()
const { data: commands } = await retry(() =>
this.client!.command.list({ directory: dir }, { throwOnError: true }),
)
const message = await loadCommands(this.client, dir)
const message = {
type: "commandsLoaded",
commands: commands.map((c) => ({
name: c.name,
description: c.description,
source: c.source,
hints: c.hints,
})),
}
this.cachedCommandsMessage = message
this.postMessage(message)
} catch (error) {
@@ -1790,7 +1801,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
if (result.error) {
console.error("[Kilo New] removeSkill returned error:", result.error)
this.cachedSkillsMessage = null
this.cachedCommandsMessage = null
this.clearCommandsCache()
await Promise.all([this.fetchAndSendSkills(), this.fetchAndSendCommands()])
return false
}
@@ -61,18 +61,8 @@ export class SubAgentViewerProvider implements vscode.Disposable {
// sessionCreated to the webview.
provider.registerSession(session)
// Fetch and send existing messages
const { data: messagesData } = await client.session.messages({ sessionID }, { throwOnError: true })
const messages = messagesData.map((m) => ({
...m.info,
parts: m.parts,
createdAt: new Date(m.info.time.created).toISOString(),
}))
provider.postMessage({
type: "messagesLoaded",
sessionID,
messages,
})
// Fetch the newest page before navigating so the tab opens on the latest turn.
await provider.loadMessages(sessionID)
// Navigate to the sub-agent viewer
provider.postMessage({ type: "viewSubAgentSession", sessionID })
@@ -532,6 +532,9 @@ interface PreviewImageIn {
interface LoadMessagesIn {
type: "loadMessages"
sessionID: string
mode?: "replace" | "prepend" | "focus"
before?: string
limit?: number
}
interface FileSourceIn {
+1
View File
@@ -326,6 +326,7 @@ export function activate(context: vscode.ExtensionContext) {
const match = uri.path.match(/^\/kilocode\/s\/([a-zA-Z0-9_-]+)$/)
if (!match) return
const sessionId = match[1]
if (!sessionId) return
console.log("[Kilo New] URI handler: opening cloud session:", sessionId)
await vscode.commands.executeCommand(`${KiloProvider.viewType}.focus`)
provider.openCloudSession(sessionId)
@@ -0,0 +1,34 @@
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { retry } from "../services/cli-backend/retry"
const promises = new Map<string, Promise<unknown>>()
export function clearCommandsCache(): void {
promises.clear()
}
export async function loadCommands(client: KiloClient, dir: string): Promise<unknown> {
const pending = promises.get(dir)
if (pending) return pending
const promise = retry(() => client.command.list({ directory: dir }, { throwOnError: true })).then(({ data }) => ({
type: "commandsLoaded",
commands: data.map((cmd) => ({
name: cmd.name,
description: cmd.description,
source: cmd.source,
hints: cmd.hints,
})),
}))
promises.set(dir, promise)
try {
return await promise
} finally {
// Clear the cache entry once the request settles so subsequent calls
// fetch fresh data. Identity check guards against clear-then-restart
// races: if clearCommandsCache() wiped the map and a new loadCommands()
// already stored a fresh promise, don't delete its entry.
if (promises.get(dir) === promise) promises.delete(dir)
}
}
@@ -0,0 +1,64 @@
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { retry } from "../services/cli-backend/retry"
export const MESSAGE_PAGE_LIMIT = 80
/**
* Build the same base64url-encoded cursor format the server emits so a
* synthesized cursor round-trips through `session.messages({ before })`.
* Server contract: `{ id, time }` JSON → base64url. See MessageV2.cursor.
*/
function synthesizeCursor(oldest: { info: { id: string; time: { created: number } } }): string {
const payload = JSON.stringify({ id: oldest.info.id, time: oldest.info.time.created })
return Buffer.from(payload, "utf8").toString("base64url")
}
export async function fetchMessagePage(
client: KiloClient,
input: {
sessionID: string
workspaceDir: string
limit: number
before?: string
signal?: AbortSignal
},
) {
// limit: 0 is the server contract for "return every message" — used by
// the sub-agent viewer, which has no "load earlier" UI.
const full = input.limit === 0
const read = async (before?: string) => {
const result = await retry(() =>
client.session.messages(
{ sessionID: input.sessionID, directory: input.workspaceDir, limit: input.limit, before },
{ throwOnError: true, signal: input.signal },
),
)
// When a proxy/auth gateway strips X-Next-Cursor but the response fills
// the requested limit, synthesize a cursor from the oldest item so the
// "load earlier" path keeps working. Risk of one extra empty request is
// preferable to silently hiding older history. Never synthesize for
// full loads — those return everything by contract.
const items = result.data
const header = result.response.headers.get("X-Next-Cursor")
const cursor = full
? undefined
: (header ?? (items.length >= input.limit && items[0] ? synthesizeCursor(items[0]) : undefined))
return { items, cursor }
}
const suffix = (items: Awaited<ReturnType<typeof read>>["items"]) => {
const index = [...items].reverse().findIndex((item) => item.info.role === "user")
if (index === -1) return items
return items.slice(items.length - index - 1)
}
const fill = async (page: Awaited<ReturnType<typeof read>>): Promise<Awaited<ReturnType<typeof read>>> => {
if (page.items[0]?.info.role !== "assistant") return page
if (!page.cursor || input.signal?.aborted) return page
const next = await read(page.cursor)
const items = [...suffix(next.items), ...page.items]
return fill({ items, cursor: next.cursor })
}
return fill(await read(input.before))
}
@@ -139,16 +139,22 @@ function slimWrite(state: Record<string, unknown>): Record<string, unknown> {
return next
}
/** bash: truncate metadata.output (up to 30KB) and state.output (up to 50KB). */
function slimBash(state: Record<string, unknown>): Record<string, unknown> {
/** read/list/search: keep the rendered tool details lightweight on historical loads. */
function slimOutput(state: Record<string, unknown>): Record<string, unknown> {
const next = { ...state }
if (typeof state.output === "string" && state.output.length > OUTPUT_CAP) {
next.output = cap(state.output)
}
return next
}
/** bash: truncate metadata.output and state.output. */
function slimBash(state: Record<string, unknown>): Record<string, unknown> {
const next = slimOutput(state)
const meta = state.metadata
if (isObj(meta) && typeof meta.output === "string" && meta.output.length > OUTPUT_CAP) {
next.metadata = { ...meta, output: cap(meta.output) }
}
if (typeof state.output === "string" && (state.output as string).length > OUTPUT_CAP) {
next.output = cap(state.output)
}
return next
}
@@ -157,6 +163,10 @@ function slimBash(state: Record<string, unknown>): Record<string, unknown> {
// ---------------------------------------------------------------------------
const slimmers: Record<string, (state: Record<string, unknown>) => Record<string, unknown>> = {
read: slimOutput,
list: slimOutput,
glob: slimOutput,
grep: slimOutput,
edit: slimEdit,
apply_patch: slimPatch,
multiedit: slimMultiedit,
@@ -0,0 +1,244 @@
import { describe, it, expect } from "bun:test"
// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts)
const { KiloProvider } = await import("../../src/KiloProvider")
type State = "connecting" | "connected" | "disconnected" | "error"
interface Deferred<T> {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
}
function defer<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function mkMessage(id: string, role: "user" | "assistant", time = 0) {
return {
info: {
id,
sessionID: "s1",
role,
time: { created: time },
},
parts: [],
}
}
function mkResult(items: unknown[]) {
return { data: items, response: { headers: new Headers() } }
}
function createClient(options?: {
messagesDeferred?: Deferred<{ data: unknown[]; response: { headers: Headers } }>
messagesData?: unknown[]
deleteDeferred?: Deferred<unknown>
}) {
const calls: { before?: string; limit?: number }[] = []
return {
calls,
session: {
list: async () => ({ data: [] }),
get: async () => ({ data: null }),
status: async () => ({ data: {} }),
messages: async (params: { before?: string; limit?: number }) => {
calls.push({ before: params.before, limit: params.limit })
if (options?.messagesDeferred) return options.messagesDeferred.promise
return mkResult(options?.messagesData ?? [])
},
delete: async () => {
if (options?.deleteDeferred) return options.deleteDeferred.promise
return { data: {} }
},
},
provider: { list: async () => ({ data: { all: [], connected: {}, default: {} } }) },
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: {} }) },
kilo: {
notifications: async () => ({ data: [] }),
profile: async () => ({ data: {} }),
},
command: { list: async () => ({ data: [] }) },
}
}
function createConnection(client: ReturnType<typeof createClient>) {
return {
connect: async () => {},
getClient: () => client,
onEventFiltered: () => () => undefined,
onStateChange: (_l: (s: State) => void) => () => undefined,
onNotificationDismissed: () => () => undefined,
onLanguageChanged: () => () => undefined,
onProfileChanged: () => () => undefined,
onMigrationComplete: () => () => undefined,
onFavoritesChanged: () => () => undefined,
onClearPendingPrompts: () => () => undefined,
registerDirectoryProvider: () => () => undefined,
getServerInfo: () => ({ port: 12345 }),
getConnectionState: () => "connected" as const,
resolveEventSessionId: () => undefined,
recordMessageSessionId: () => undefined,
notifyNotificationDismissed: () => undefined,
pruneSession: () => undefined,
registerFocused: () => undefined,
unregisterFocused: () => undefined,
}
}
type ProviderInternals = {
connectionState: State
webview: { postMessage: (message: unknown) => Promise<unknown> } | null
trackedSessionIds: Set<string>
handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise<void>
handleDeleteSession: (sid: string) => Promise<void>
}
function makeProvider(client: ReturnType<typeof createClient>) {
const connection = createConnection(client)
const provider = new KiloProvider({} as never, connection as never)
const internal = provider as unknown as ProviderInternals
internal.connectionState = "connected"
const sent: unknown[] = []
internal.webview = {
postMessage: async (message: unknown) => {
sent.push(message)
},
}
return { provider, internal, sent }
}
describe("KiloProvider.handleLoadMessages / focus mode freshness", () => {
it("refetches the tail page on focus-mode reselection and posts a reconcile snapshot", async () => {
// Regression: switching to an already-loaded session sent mode: "focus"
// which only refreshed session metadata and status — not messages. If
// SSE dropped events during the gap (reconnect, missed child-task
// messages, backend crash-restart) the webview showed stale content with
// no way to recover short of reloading the extension. Focus mode must
// still reconcile the tail against the server snapshot so silent drift
// self-heals on the next session switch.
const messages = [
mkMessage("m1", "user", 1),
mkMessage("m2", "assistant", 2),
mkMessage("m3", "user", 3), // delivered after SSE reconnect, missed by webview
]
const client = createClient({ messagesData: messages })
const { internal, sent } = makeProvider(client)
internal.trackedSessionIds.add("s1")
await internal.handleLoadMessages("s1", { mode: "focus" })
// Server must be hit to reconcile the current state.
expect(client.calls.length).toBeGreaterThanOrEqual(1)
// Must post a messagesLoaded snapshot tagged reconcile — not replace —
// so the webview merges without tearing down existing reactive proxies.
const loaded = sent.find(
(msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded",
) as { mode?: string; messages: { id: string }[] } | undefined
expect(loaded).toBeDefined()
expect(loaded!.mode).toBe("reconcile")
expect(loaded!.messages.map((m) => m.id)).toContain("m3")
})
it("throttles repeat focus-mode reconciles within 1s", async () => {
// Regression: rapid session tab switching (A→B→A) used to stack up one
// reconcile fetch per click, each doing a full-page fetch + 80-message
// reactive-store reconcile. A 1s throttle kills the redundant work while
// still catching SSE drops on normal use patterns.
const client = createClient({ messagesData: [mkMessage("m1", "user", 1)] })
const { internal } = makeProvider(client)
internal.trackedSessionIds.add("s1")
await internal.handleLoadMessages("s1", { mode: "focus" })
const callsAfterFirst = client.calls.length
// Second focus within the throttle window — no fetch should happen.
await internal.handleLoadMessages("s1", { mode: "focus" })
expect(client.calls.length).toBe(callsAfterFirst)
})
it("does not post messagesLoaded on focus when the session is no longer tracked", async () => {
// Defensive: if the user deletes the session while the background focus
// refetch is in flight, drop the response (same invariant as prepend).
const messages = defer<{ data: unknown[]; response: { headers: Headers } }>()
const client = createClient({ messagesDeferred: messages })
const { internal, sent } = makeProvider(client)
internal.trackedSessionIds.add("s1")
const load = internal.handleLoadMessages("s1", { mode: "focus" })
await internal.handleDeleteSession("s1")
messages.resolve(mkResult([mkMessage("m1", "user", 10)]))
await load
const loaded = sent.filter(
(msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded",
)
expect(loaded).toEqual([])
})
})
describe("KiloProvider.loadMessages / sub-agent viewer full history", () => {
it("loads all messages without the MESSAGE_PAGE_LIMIT cap (sub-agent viewer needs full turn history)", async () => {
// Regression: SubAgentViewerProvider used to call client.session.messages
// with no limit, loading every turn. After switching to provider.loadMessages
// it inherited the 80-message page cap and sub-agents with more than 80
// turns would open truncated with no visible indicator. loadMessages() is
// the sub-agent viewer's single entry point — it must request the full
// transcript.
const big = Array.from({ length: 200 }, (_, i) => mkMessage(`m${i}`, i % 2 === 0 ? "user" : "assistant", i))
const client = createClient({ messagesData: big })
const { provider, sent } = makeProvider(client)
await provider.loadMessages("s1")
const loaded = sent.find(
(msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded",
) as { messages: unknown[] } | undefined
expect(loaded).toBeDefined()
expect(loaded!.messages).toHaveLength(200)
// Server contract: limit: 0 (or undefined) returns everything.
expect(client.calls).toHaveLength(1)
const limit = client.calls[0]?.limit
expect(limit === undefined || limit === 0).toBe(true)
})
})
describe("KiloProvider.handleLoadMessages / prepend into deleted session", () => {
it("does not post messagesLoaded for a session deleted mid-prepend", async () => {
// Regression: handleLoadMessages fires fire-and-forget from the webview
// message dispatcher. If the user deletes the session while a prepend
// fetch is in flight, the response still arrives and posts messagesLoaded
// for a now-dead session ID, resurrecting a ghost entry in the webview
// store until something else clears it.
const messages = defer<{ data: unknown[]; response: { headers: Headers } }>()
const client = createClient({ messagesDeferred: messages })
const { internal, sent } = makeProvider(client)
// Simulate the session being tracked (as it would after the initial load).
internal.trackedSessionIds.add("s1")
const load = internal.handleLoadMessages("s1", { mode: "prepend", before: "cursor-1", limit: 80 })
// User deletes the session while the fetch is still pending.
await internal.handleDeleteSession("s1")
// Fetch finally resolves after deletion.
messages.resolve(mkResult([mkMessage("m1", "user", 10)]))
await load
const loaded = sent.filter(
(msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded",
)
expect(loaded).toEqual([])
})
})
@@ -0,0 +1,118 @@
import { describe, it, expect } from "bun:test"
import { fetchMessagePage } from "../../src/kilo-provider/message-page"
type Message = { info: { id: string; role: "user" | "assistant"; time: { created: number } }; parts: unknown[] }
function message(id: string, role: "user" | "assistant", time: number): Message {
return { info: { id, role, time: { created: time } }, parts: [] }
}
function mockClient(pages: { items: Message[]; cursor?: string }[]) {
const calls: { before?: string; limit?: number }[] = []
let idx = 0
const client = {
session: {
messages: async (
params: { sessionID: string; directory: string; limit: number; before?: string },
_opts: { throwOnError: boolean; signal?: AbortSignal },
) => {
calls.push({ before: params.before, limit: params.limit })
const page = pages[idx++]
if (!page) throw new Error("no more mock pages")
const headers = new Headers()
if (page.cursor) headers.set("X-Next-Cursor", page.cursor)
return {
data: page.items,
response: { headers } as Response,
}
},
},
}
return { client, calls }
}
describe("fetchMessagePage / cursor fallback", () => {
it("returns server cursor when X-Next-Cursor header is present", async () => {
const { client } = mockClient([
{
items: [message("m1", "user", 1), message("m2", "assistant", 2), message("m3", "user", 3)],
cursor: "server-cursor-abc",
},
])
const page = await fetchMessagePage(client as never, {
sessionID: "s1",
workspaceDir: "/repo",
limit: 3,
})
expect(page.cursor).toBe("server-cursor-abc")
})
it("synthesizes a cursor when server omits X-Next-Cursor but page is full (header stripped by proxy / missing permission)", async () => {
// Regression: if a proxy or auth layer strips X-Next-Cursor, the webview
// loses access to older messages even when they exist. When the response
// fills the requested limit, derive a cursor from the oldest item so the
// "load earlier" path keeps working.
const { client } = mockClient([
{
items: [
message("m1", "user", 10),
message("m2", "assistant", 20),
message("m3", "user", 30),
message("m4", "assistant", 40),
],
// Intentionally no cursor — simulating a stripped header.
},
])
const page = await fetchMessagePage(client as never, {
sessionID: "s1",
workspaceDir: "/repo",
limit: 4,
})
expect(page.cursor).toBeDefined()
// Cursor must be a base64url-encoded { id, time } of the oldest item so
// the server's before parser accepts it on the next request.
const decoded = JSON.parse(Buffer.from(page.cursor!, "base64url").toString("utf8"))
expect(decoded).toEqual({ id: "m1", time: 10 })
})
it("leaves cursor undefined when server omits header AND page is not full (truly no more)", async () => {
const { client } = mockClient([
{
items: [message("m1", "user", 10), message("m2", "assistant", 20)],
},
])
const page = await fetchMessagePage(client as never, {
sessionID: "s1",
workspaceDir: "/repo",
limit: 80,
})
expect(page.cursor).toBeUndefined()
})
it("synthesized cursor round-trips through the server's before parameter", async () => {
// First page: server strips header, items fill limit → cursor synthesized.
// Next page request uses that cursor and returns more items.
const { client, calls } = mockClient([
{
items: [message("m3", "user", 30), message("m4", "assistant", 40)],
},
{
items: [message("m1", "user", 10), message("m2", "assistant", 20)],
},
])
const first = await fetchMessagePage(client as never, {
sessionID: "s1",
workspaceDir: "/repo",
limit: 2,
})
expect(first.cursor).toBeDefined()
await fetchMessagePage(client as never, {
sessionID: "s1",
workspaceDir: "/repo",
limit: 2,
before: first.cursor,
})
expect(calls[1]?.before).toBe(first.cursor)
})
})
@@ -0,0 +1,54 @@
import { describe, it, expect } from "bun:test"
import { PartStash } from "../../webview-ui/src/context/part-stash"
import type { Part } from "../../webview-ui/src/types/messages"
function text(id: string, messageID: string, value: string): Part {
return { type: "text", id, messageID, text: value } as Part
}
describe("PartStash", () => {
it("put / peek round-trips parts", () => {
const stash = new PartStash()
stash.put("m1", [text("p1", "m1", "hi")])
const peeked = stash.peek("m1")
expect(peeked?.[0] && "text" in peeked[0] ? peeked[0].text : undefined).toBe("hi")
})
it("remove() clears stashed parts — regression for handleMessageRemoved leak", () => {
// Before the fix, handleMessageRemoved wiped reactive parts but left the
// stash entry alive. If an off-screen message was removed before its turn
// mounted, its parts would sit in the stash forever. Worse: a later call
// to peek() or getParts() could surface the parts of a deleted message.
const stash = new PartStash()
stash.put("m1", [text("p1", "m1", "stale")])
stash.remove("m1")
expect(stash.peek("m1")).toBeUndefined()
expect(stash.size()).toBe(0)
})
it("take() consumes stashed parts atomically", () => {
const stash = new PartStash()
stash.put("m1", [text("p1", "m1", "a")])
stash.put("m2", [text("p2", "m2", "b")])
const taken = stash.take(["m1", "m2"])
expect(Object.keys(taken).sort()).toEqual(["m1", "m2"])
expect(stash.size()).toBe(0)
})
it("take() skips IDs already hydrated into the reactive store", () => {
const stash = new PartStash()
stash.put("m1", [text("p1", "m1", "stash")])
const taken = stash.take(["m1"], (id) => id === "m1")
expect(taken).toEqual({})
// The stash entry should be preserved — hydrateParts will noop and
// subsequent SSE updates that target the message can still merge into it
// if needed.
expect(stash.peek("m1")).toBeDefined()
})
it("take() returns empty when no IDs match", () => {
const stash = new PartStash()
stash.put("m1", [text("p1", "m1", "a")])
expect(stash.take(["m2", "m3"])).toEqual({})
})
})
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8348db9191e1e616c93bf54ae33702a57e25c092a8f80a24d1da04811e523500
size 26519
oid sha256:4294f36eea4005ca5f3f6d4a3f7ed84d5113f7454c2149280f5182ccbc686124
size 26407
@@ -762,20 +762,28 @@ const AgentManagerContent: Component = () => {
return result
})
// Sessions for the currently selected worktree (tab bar), respecting custom order if set
// Oldest-first sort before applyTabOrder — worktree label and tab bar must agree on "first session".
const sessionsForWorktree = (worktreeId: string): SessionInfo[] => {
const ids = new Set(
managedSessions()
.filter((ms) => ms.worktreeId === worktreeId)
.map((ms) => ms.id),
)
return applyTabOrder(
session
.sessions()
.filter((s) => ids.has(s.id))
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
worktreeTabOrder()[worktreeId],
)
}
const activeWorktreeSessions = createMemo((): SessionInfo[] => {
const sel = selection()
if (!sel || sel === LOCAL) return []
const managed = managedSessions().filter((ms) => ms.worktreeId === sel)
const ids = new Set(managed.map((ms) => ms.id))
const sessions = session
.sessions()
.filter((s) => ids.has(s.id))
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
return applyTabOrder(sessions, worktreeTabOrder()[sel])
return sessionsForWorktree(sel)
})
// Active tab sessions: local sessions when on "local", worktree sessions otherwise
const activeTabs = createMemo((): SessionInfo[] => {
const sel = selection()
if (sel === LOCAL) return localSessions()
@@ -783,11 +791,10 @@ const AgentManagerContent: Component = () => {
return []
})
// Whether the selected context has zero sessions
const contextEmpty = createMemo(() => {
const sel = selection()
if (sel === LOCAL) return localSessionIDs().length === 0
if (sel) return activeWorktreeSessions().length === 0
if (sel) return activeWorktreeSessions().length === 0 && managedSessions().every((ms) => ms.worktreeId !== sel)
return false
})
@@ -802,8 +809,6 @@ const AgentManagerContent: Component = () => {
}
})
// Scroll the sidebar to the focused item whenever selection changes (covers keyboard
// navigation, new worktree creation, and any other programmatic selection change).
createEffect(() => {
const id = selection() ?? session.currentSessionID()
if (!id) return
@@ -813,22 +818,16 @@ const AgentManagerContent: Component = () => {
})
})
// Read-only mode: viewing an unassigned session (not in a worktree or local)
const readOnly = createMemo(() => selection() === null && !!session.currentSessionID())
// Tab scroll: hidden scrollbar with fade overflow indicators
const visibleTabId = createMemo(() =>
reviewActive() ? REVIEW_TAB_ID : (session.currentSessionID() ?? activePendingId()),
)
const tabScroll = useTabScroll(activeTabs, visibleTabId)
// Display name for worktree — prefers persisted label, then first session title, then branch
const worktreeLabel = (wt: WorktreeState): string => {
if (wt.label) return wt.label
const managed = managedSessions().filter((ms) => ms.worktreeId === wt.id)
const ids = new Set(managed.map((ms) => ms.id))
const sessions = session.sessions().filter((s) => ids.has(s.id))
return firstOrderedTitle(sessions, worktreeTabOrder()[wt.id], wt.branch)
return firstOrderedTitle(sessionsForWorktree(wt.id), worktreeTabOrder()[wt.id], wt.branch)
}
const worktreeSubtitle = (wt: WorktreeState): string | undefined => {
@@ -838,7 +837,6 @@ const AgentManagerContent: Component = () => {
const isStaleWorktree = (worktreeId: string): boolean => staleWorktreeIds().has(worktreeId)
/** True when any session in the given ID list is actively working (busy/retry and not blocked by permissions/questions). */
const isAnySessionBusy = (ids: string[]): boolean => {
if (ids.length === 0) return false
const statuses = session.allStatusMap()
@@ -1008,12 +1006,15 @@ const AgentManagerContent: Component = () => {
const selectWorktree = (worktreeId: string) => {
saveTabMemory()
setSelection(worktreeId)
// Try rich session list first, fall back to managed session IDs when
// session.sessions() hasn't been populated yet for this worktree.
const rich = sessionsForWorktree(worktreeId)
const managed = managedSessions().filter((ms) => ms.worktreeId === worktreeId)
const ids = new Set(managed.map((ms) => ms.id))
const sessions = session.sessions().filter((s) => ids.has(s.id))
const remembered = tabMemory()[worktreeId]
const target = remembered ? sessions.find((s) => s.id === remembered) : undefined
const fallback = target ?? sessions[0]
const target = remembered
? (rich.find((s) => s.id === remembered) ?? managed.find((ms) => ms.id === remembered))
: undefined
const fallback = target ?? rich[0] ?? managed[0]
if (fallback) session.selectSession(fallback.id)
else session.setCurrentSessionID(undefined)
setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByContext()[worktreeId] === true)
@@ -1,13 +1,13 @@
/**
* MessageList component
* Scrollable turn-based message list.
* Scrollable turn-based message list with virtualization.
* Each user message is rendered as a VscodeSessionTurn — a custom component that
* renders all assistant parts as a flat, verbose list with no context grouping,
* and fully expands sub-agent (task tool) parts inline.
* Shows recent sessions in the empty state for quick resumption.
*/
import { Component, For, Show, createEffect, createMemo, onCleanup, JSX } from "solid-js"
import { Component, For, Show, createEffect, createMemo, createSignal, on, onCleanup, JSX } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
@@ -17,12 +17,13 @@ import { useServer } from "../../context/server"
import { useLanguage } from "../../context/language"
import { formatRelativeDate } from "../../utils/date"
import { FeedbackDialog } from "./FeedbackDialog"
import { VscodeSessionTurn } from "./VscodeSessionTurn"
import { VscodeSessionTurn, type VscodeTurn } from "./VscodeSessionTurn"
import { RevertBanner } from "./RevertBanner"
import { AccountSwitcher } from "../shared/AccountSwitcher"
import { KiloNotifications } from "./KiloNotifications"
import { WorkingIndicator } from "../shared/WorkingIndicator"
import { QuestionDock } from "./QuestionDock"
import { Virtualizer } from "virtua/solid"
import { SuggestBar } from "./SuggestBar"
import { activeUserMessageID as getActiveUserMessageID } from "../../context/session-queue"
import type { QuestionRequest, SuggestionRequest } from "../../types/messages"
@@ -74,14 +75,25 @@ export const MessageList: Component<MessageListProps> = (props) => {
}
})
const allUserMessages = () => session.userMessages()
const [scrollEl, setScrollEl] = createSignal<HTMLElement>()
const positions = new Map<string, { top: number; userScrolled: boolean }>()
const boundary = () => session.revert()?.messageID
const userMessages = createMemo(() => {
const turns = createMemo<VscodeTurn[]>(() => {
const result: VscodeTurn[] = []
const b = boundary()
if (!b) return allUserMessages()
return allUserMessages().filter((m) => m.id < b)
for (const msg of session.messages()) {
if (msg.role === "user") {
if (b && msg.id >= b) break
result.push({ id: msg.id, user: msg, assistant: [] })
continue
}
const turn = result[result.length - 1]
if (turn && msg.role === "assistant") turn.assistant.push(msg)
}
return result
})
const isEmpty = () => userMessages().length === 0 && !session.loading() && !boundary()
const isEmpty = () => turns().length === 0 && !session.loading() && !boundary()
const recent = createMemo(() =>
[...session.sessions()]
@@ -94,9 +106,67 @@ export const MessageList: Component<MessageListProps> = (props) => {
const activeUserIndex = createMemo(() => {
const active = activeUserID()
if (!active) return -1
return userMessages().findIndex((msg) => msg.id === active)
return turns().findIndex((turn) => turn.user.id === active)
})
const save = (id: string | undefined) => {
const el = scrollEl()
if (!id || !el) return
positions.set(id, { top: el.scrollTop, userScrolled: autoScroll.userScrolled() })
}
const maybeLoadOlder = () => {
const el = scrollEl()
if (!el || el.scrollTop > 600) return
session.loadOlderMessages()
}
const handleScroll = () => {
autoScroll.handleScroll()
maybeLoadOlder()
}
const setScrollRef = (el: HTMLElement | undefined) => {
setScrollEl(el)
autoScroll.scrollRef(el)
}
const [pendingRestore, setPendingRestore] = createSignal<string>()
createEffect(
on(session.currentSessionID, (id, prev) => {
save(prev)
setPendingRestore(id)
}),
)
createEffect(() => {
const id = pendingRestore()
if (!id || session.loading()) return
turns().length
// Double-rAF: the first frame lets the browser paint the new DOM from
// the messagesLoaded batch. The second frame restores scroll position
// without forcing a synchronous layout reflow mid-paint.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (pendingRestore() !== id) return
const el = scrollEl()
if (!el) return
const pos = positions.get(id)
if (pos?.userScrolled) {
el.scrollTop = pos.top
autoScroll.pause()
} else {
autoScroll.forceScrollToBottom()
}
setPendingRestore(undefined)
maybeLoadOlder()
})
})
})
onCleanup(() => save(session.currentSessionID()))
return (
<div class="message-list-container">
<Show when={isEmpty()}>
@@ -105,13 +175,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
<KiloNotifications />
</div>
</Show>
<div
ref={autoScroll.scrollRef}
onScroll={autoScroll.handleScroll}
class="message-list"
role="log"
aria-live="polite"
>
<div ref={setScrollRef} onScroll={handleScroll} class="message-list" role="log" aria-live="polite">
<div ref={autoScroll.contentRef} class={isEmpty() ? "message-list-content-empty" : "message-list-content"}>
<Show when={session.loading()}>
<div class="message-list-loading" role="status">
@@ -153,24 +217,37 @@ export const MessageList: Component<MessageListProps> = (props) => {
</button>
</div>
</Show>
<Show when={!session.loading()}>
<For each={userMessages()}>
{(msg, index) => {
const queued = createMemo(() => {
const active = activeUserIndex()
if (active === -1) return false
return index() > active
})
<Show when={!session.loading() && !isEmpty()}>
<Show when={session.loadingOlderMessages()}>
<div class="message-list-page-loader" role="status">
<Spinner />
<span>{language.t("session.messages.loadingEarlier")}</span>
</div>
</Show>
<Show when={session.hasOlderMessages() && !session.loadingOlderMessages()}>
<button class="message-list-load-older" onClick={() => session.loadOlderMessages()}>
{language.t("session.messages.loadEarlier")}
</button>
</Show>
<Show when={scrollEl()}>
<Virtualizer
data={turns()}
scrollRef={scrollEl()}
shift={session.messageMutation() === "prepend"}
overscan={6}
itemSize={260}
>
{(turn, index) => {
const queued = createMemo(() => {
const active = activeUserIndex()
if (active === -1) return false
return index() > active
})
return (
<VscodeSessionTurn
sessionID={session.currentSessionID() ?? ""}
messageID={msg.id}
queued={queued()}
/>
)
}}
</For>
return <VscodeSessionTurn turn={turn} queued={queued()} />
}}
</Virtualizer>
</Show>
<Show when={boundary()}>
<RevertBanner />
</Show>
@@ -32,6 +32,7 @@ import { ErrorDisplay } from "./ErrorDisplay"
import { useServer } from "../../context/server"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
import type { Message as WebMessage } from "../../types/messages"
function getDirectory(path: string): string {
const sep = path.includes("/") ? "/" : "\\"
@@ -45,9 +46,14 @@ function getFilename(path: string): string {
return idx === -1 ? path : path.slice(idx + 1)
}
export interface VscodeTurn {
id: string
user: WebMessage
assistant: WebMessage[]
}
interface VscodeSessionTurnProps {
sessionID: string
messageID: string
turn: VscodeTurn
queued?: boolean
}
@@ -59,45 +65,22 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
const session = useSession()
const language = useLanguage()
const emptyMessages: SDKMessage[] = []
const emptyParts: SDKPart[] = []
const emptyDiffs: SnapshotFileDiff[] = []
const allMessages = createMemo(() => {
const msgs = data.store.message?.[props.sessionID]
return (msgs ?? emptyMessages) as SDKMessage[]
createEffect(() => {
const turn = props.turn
session.hydrateParts([turn.user.id, ...turn.assistant.map((m) => m.id)])
})
const message = createMemo(() => {
return allMessages().find((m) => m.id === props.messageID && m.role === "user") as
| (SDKMessage & { role: "user" })
| undefined
})
const message = createMemo(() => props.turn.user as SDKMessage & { role: "user" })
const parts = createMemo(() => {
const msg = message()
if (!msg) return emptyParts
return (data.store.part?.[msg.id] ?? emptyParts) as SDKPart[]
})
const messageIndex = createMemo(() => {
const msgs = allMessages()
return msgs.findIndex((m) => m.id === props.messageID)
})
const assistantMessages = createMemo(() => {
const index = messageIndex()
if (index < 0) return [] as SDKAssistantMessage[]
const msgs = allMessages()
const result: SDKAssistantMessage[] = []
for (let i = index + 1; i < msgs.length; i++) {
const m = msgs[i]
if (!m) continue
if (m.role === "user") break
if (m.role === "assistant") result.push(m as SDKAssistantMessage)
}
return result
})
const assistantMessages = createMemo(() => props.turn.assistant as SDKAssistantMessage[])
const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError"))
@@ -174,7 +157,7 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
assistantMessages().length > 0 && !session.revert()
? () => {
if (session.status() !== "idle") return
session.revertSession(props.messageID)
session.revertSession(msg().id)
}
: undefined
}
@@ -0,0 +1,63 @@
/**
* PartStash holds message parts outside the reactive Solid store until a
* turn is actually rendered by the virtualizer. Writing parts for off-screen
* messages into the reactive store triggers expensive DOM work for invisible
* content — parking them here keeps initial-load churn cheap.
*
* The stash lives alongside (not inside) the reactive store. Every lifecycle
* event that invalidates a message must reach both the store and the stash.
* Centralising stash access behind this helper keeps that invariant easy to
* audit (and easy to unit-test, since the store is Solid-specific).
*/
import type { Part } from "../types/messages"
export class PartStash {
private map = new Map<string, Part[]>()
/** Stash parts for a message that hasn't been rendered yet. */
put(messageID: string, parts: Part[]): void {
this.map.set(messageID, parts)
}
/** Read without consuming. Returns `undefined` if absent. */
peek(messageID: string): Part[] | undefined {
return this.map.get(messageID)
}
/**
* Invalidate any stashed parts for a message. Callers MUST invoke this in
* every path that removes a message from state (messageRemoved,
* sendMessageFailed, sessionDeleted) or promotes it into the reactive
* store (messageCreated, partUpdated, hydrateParts). Missing a call here
* leaks memory and, worse, can resurface stale parts via `peek()` after
* the message is gone.
*/
remove(messageID: string): void {
this.map.delete(messageID)
}
/**
* Collect parts for the given IDs, consuming the stash. Used by the
* virtualizer when a turn is about to render: the returned parts should
* be written to the reactive store atomically by the caller.
*
* IDs already present in the reactive store are skipped — pass an optional
* `isHydrated` predicate for that check.
*/
take(ids: string[], isHydrated?: (id: string) => boolean): Record<string, Part[]> {
const out: Record<string, Part[]> = {}
for (const id of ids) {
if (isHydrated?.(id)) continue
const parts = this.map.get(id)
if (!parts) continue
out[id] = parts
this.map.delete(id)
}
return out
}
/** Diagnostics and tests only. */
size(): number {
return this.map.size
}
}
@@ -32,6 +32,7 @@ import type {
FileAttachment,
SendMessageFailedMessage,
McpStatusEntry,
MessageLoadMode,
} from "../types/messages"
import { removeSessionPermissions, upsertPermission } from "./permission-queue"
import {
@@ -46,9 +47,29 @@ import { Identifier } from "../utils/id"
import { resolveModelSelection } from "./model-selection"
import { resolveSessionAgent } from "./session-agent"
import { queuedUserMessageIDs } from "./session-queue"
import { PartStash } from "./part-stash"
import { KILO_AUTO, parseModelString } from "../../../src/shared/provider-model"
const RECENT_LIMIT = 5
const MESSAGE_PAGE_LIMIT = 80
type MessageMutation = Exclude<MessageLoadMode, "focus"> | "append" | "update"
interface MessagePageState {
initialLoaded: boolean
loadingInitial: boolean
loadingOlder: boolean
before?: string
hasMore: boolean
lastMutation?: MessageMutation
}
const emptyPageState: MessagePageState = {
initialLoaded: false,
loadingInitial: false,
loadingOlder: false,
hasMore: false,
}
// Store structure for messages and parts
interface SessionStore {
@@ -79,6 +100,9 @@ interface SessionContextValue {
statusText: Accessor<string | undefined>
busySince: Accessor<number | undefined>
loading: Accessor<boolean>
loadingOlderMessages: Accessor<boolean>
hasOlderMessages: Accessor<boolean>
messageMutation: Accessor<MessageMutation | undefined>
// Messages for current session
messages: Accessor<Message[]>
@@ -105,6 +129,10 @@ interface SessionContextValue {
// Parts for a specific message
getParts: (messageID: string) => Part[]
// 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[]>
@@ -202,6 +230,7 @@ interface SessionContextValue {
createSession: () => void
clearCurrentSession: () => void
loadSessions: () => void
loadOlderMessages: () => void
selectSession: (id: string) => void
deleteSession: (id: string) => void
renameSession: (id: string, title: string) => void
@@ -246,6 +275,13 @@ export const SessionProvider: ParentComponent = (props) => {
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[]>([])
@@ -641,6 +677,11 @@ export const SessionProvider: ParentComponent = (props) => {
vscode.postMessage({ type: "requestFavorites" })
onCleanup(unsubFavorites)
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)
@@ -679,7 +720,11 @@ export const SessionProvider: ParentComponent = (props) => {
break
case "messagesLoaded":
handleMessagesLoaded(message.sessionID, message.messages)
handleMessagesLoaded(message.sessionID, message.messages, {
mode: message.mode,
cursor: message.cursor,
hasMore: message.hasMore,
})
break
case "messageCreated":
@@ -751,9 +796,7 @@ export const SessionProvider: ParentComponent = (props) => {
}
case "error":
// Only clear loading if the error is for the current session
// (or has no sessionID for backwards compatibility)
if (!message.sessionID || message.sessionID === currentSessionID()) setLoading(false)
handleError(message)
break
case "sendMessageFailed":
@@ -822,7 +865,72 @@ export const SessionProvider: ParentComponent = (props) => {
})
}
function handleMessagesLoaded(sessionID: string, messages: Message[]) {
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 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 shape check: same ids in same order AND same part counts per message.
// Short-circuits reconcile when the server snapshot matches local state
// (the common case — SSE didn't actually miss anything), avoiding the
// 80 setStore("parts", ...) calls per session switch.
function sameReconcileShape(current: Message[], incoming: Message[]): boolean {
if (current.length !== incoming.length) return false
for (let i = 0; i < incoming.length; i++) {
const c = current[i]!
const n = incoming[i]!
if (c.id !== n.id) return false
if ((c.parts?.length ?? 0) !== (n.parts?.length ?? 0)) return false
}
return true
}
function handleMessagesLoaded(
sessionID: string,
messages: Message[],
input: { mode?: Exclude<MessageLoadMode, "focus">; cursor?: string; hasMore?: boolean } = {},
) {
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)) {
patchPage(sessionID, { initialLoaded: true, lastMutation: "update" })
return
}
batch(() => {
setLoaded((prev) => {
if (prev.has(sessionID)) return prev
@@ -832,31 +940,59 @@ export const SessionProvider: ParentComponent = (props) => {
})
if (sessionID === currentSessionID()) setLoading(false)
// Preserve optimistic messages that haven't been confirmed yet.
// The server may not have created the message record by the time
// this session's messages are loaded (e.g. on session switch).
const pending = pendingOptimistic.get(sessionID)
if (pending && pending.size > 0) {
const loadedIds = new Set(messages.map((m) => m.id))
const current = store.messages[sessionID] ?? []
const orphans = current.filter((m) => pending.has(m.id) && !loadedIds.has(m.id))
setStore("messages", sessionID, reconcile([...messages, ...orphans], { key: "id" }))
const current = store.messages[sessionID] ?? []
const merged =
mode === "prepend" || mode === "reconcile"
? mergeMessages(current, messages, mode)
: withPending(sessionID, messages)
// "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(messages, { key: "id" }))
setStore("messages", sessionID, reconcile(merged, { key: "id" }))
}
// Also extract parts from messages
for (const msg of messages) {
if (msg.parts && msg.parts.length > 0) {
if (!msg.parts || msg.parts.length === 0) continue
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 the server-
// authoritative state immediately instead of waiting for the
// virtualizer to re-render.
setStore("parts", msg.id, reconcile(msg.parts, { key: "id" }))
stash.remove(msg.id)
} else {
// Stash parts outside the reactive store — they'll be hydrated
// on demand when the virtualizer renders the corresponding turn.
stash.put(msg.id, msg.parts)
}
}
const agent = resolveSessionAgent(messages, agentNames())
// "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, { initialLoaded: true, lastMutation: "update" })
} else {
setPages(sessionID, {
initialLoaded: true,
loadingInitial: false,
loadingOlder: false,
before: input.cursor,
hasMore: input.hasMore ?? Boolean(input.cursor),
lastMutation: mode,
})
}
const agent = resolveSessionAgent(merged, agentNames())
if (agent) {
setStore("agentSelections", sessionID, agent)
}
})
if (reset) requestAnimationFrame(() => patchPage(sessionID, { lastMutation: undefined }))
}
function handleMessageCreated(message: Message) {
@@ -877,6 +1013,7 @@ export const SessionProvider: ParentComponent = (props) => {
)
}
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,
@@ -889,6 +1026,7 @@ export const SessionProvider: ParentComponent = (props) => {
}
return [...msgs, message]
})
patchPage(message.sessionID, { initialLoaded: true, lastMutation: exists ? "update" : "append" })
// Sync mode picker from any message role (user or assistant).
// agentNames() already excludes subagent/hidden agents, so subtask
@@ -899,6 +1037,7 @@ export const SessionProvider: ParentComponent = (props) => {
}
if (message.parts && message.parts.length > 0) {
stash.remove(message.id)
setStore("parts", message.id, message.parts)
}
}
@@ -917,6 +1056,16 @@ export const SessionProvider: ParentComponent = (props) => {
return
}
if (sessionID) patchPage(sessionID, { lastMutation: "update" })
// 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) => {
@@ -1094,6 +1243,7 @@ export const SessionProvider: ParentComponent = (props) => {
function handleSendMessageFailed(message: SendMessageFailedMessage) {
if (message.sessionID && message.messageID) {
pendingOptimistic.get(message.sessionID)?.delete(message.messageID)
stash.remove(message.messageID)
batch(() => {
setStore("messages", message.sessionID!, (msgs = []) => msgs.filter((m) => m.id !== message.messageID))
setStore(
@@ -1239,9 +1389,10 @@ export const SessionProvider: ParentComponent = (props) => {
function handleSessionDeleted(sessionID: string) {
pendingOptimistic.delete(sessionID)
batch(() => {
// Collect message IDs so we can clean up their parts
// 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)
setStore(
"sessions",
@@ -1269,6 +1420,11 @@ export const SessionProvider: ParentComponent = (props) => {
delete todos[sessionID]
}),
)
setPages(
produce((map) => {
delete map[sessionID]
}),
)
setStore(
"agentSelections",
produce((selections) => {
@@ -1334,6 +1490,10 @@ export const SessionProvider: ParentComponent = (props) => {
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[]) {
@@ -1352,6 +1512,7 @@ export const SessionProvider: ParentComponent = (props) => {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
patchPage(key, { initialLoaded: true, hasMore: false, lastMutation: "replace" })
setStore("messages", key, messages)
for (const msg of messages) {
if (msg.parts && msg.parts.length > 0) {
@@ -1425,7 +1586,8 @@ export const SessionProvider: ParentComponent = (props) => {
})
// Load real messages in the background (picks up server-assigned IDs
// and the new user message once the send completes via SSE)
vscode.postMessage({ type: "loadMessages", sessionID: session.id })
patchPage(session.id, { loadingInitial: true, before: undefined, hasMore: false })
vscode.postMessage({ type: "loadMessages", sessionID: session.id, mode: "replace", limit: MESSAGE_PAGE_LIMIT })
}
// Actions
@@ -1483,6 +1645,7 @@ export const SessionProvider: ParentComponent = (props) => {
setStore("messages", sid, (msgs = []) => [...msgs, temp])
setStore("parts", messageID, parts)
patchPage(sid, { initialLoaded: true, lastMutation: "append" })
queueMicrotask(() => window.dispatchEvent(new CustomEvent("resumeAutoScroll")))
}
@@ -1754,6 +1917,21 @@ export const SessionProvider: ParentComponent = (props) => {
vscode.postMessage({ type: "loadSessions" })
}
function loadOlderMessages() {
const id = currentSessionID()
if (!id || !server.isConnected()) return
const page = pages[id] ?? emptyPageState
if (!page.hasMore || page.loadingOlder || page.loadingInitial || !page.before) return
patchPage(id, { loadingOlder: true })
vscode.postMessage({
type: "loadMessages",
sessionID: id,
mode: "prepend",
before: page.before,
limit: MESSAGE_PAGE_LIMIT,
})
}
function selectSession(id: string) {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot select session: not connected")
@@ -1763,10 +1941,16 @@ export const SessionProvider: ParentComponent = (props) => {
console.warn("[Kilo New] Cannot select cloud preview session via selectSession")
return
}
const ready = loaded().has(id)
setCurrentSessionID(id)
setDraftSessionID(id)
setLoading(!loaded().has(id))
vscode.postMessage({ type: "loadMessages", sessionID: id })
setLoading(!ready)
if (ready) {
vscode.postMessage({ type: "loadMessages", sessionID: id, mode: "focus" })
return
}
patchPage(id, { loadingInitial: true, loadingOlder: false, before: undefined, hasMore: false })
vscode.postMessage({ type: "loadMessages", sessionID: id, mode: "replace", limit: MESSAGE_PAGE_LIMIT })
}
function selectCloudSession(cloudSessionId: string) {
@@ -1817,13 +2001,33 @@ export const SessionProvider: ParentComponent = (props) => {
return id ? store.sessions[id] : undefined
}
const pageState = () => {
const id = currentSessionID()
return id ? (pages[id] ?? emptyPageState) : emptyPageState
}
const loadingOlderMessages = () => pageState().loadingOlder
const hasOlderMessages = () => pageState().hasMore
const messageMutation = () => pageState().lastMutation
const messages = () => {
const id = currentSessionID()
return id ? store.messages[id] || [] : []
}
const getParts = (messageID: string) => {
return store.parts[messageID] || []
return store.parts[messageID] || stash.peek(messageID) || []
}
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
@@ -1962,9 +2166,13 @@ export const SessionProvider: ParentComponent = (props) => {
statusText,
busySince,
loading,
loadingOlderMessages,
hasOlderMessages,
messageMutation,
messages,
userMessages,
getParts,
hydrateParts,
todos,
permissions,
respondingPermissions,
@@ -2042,6 +2250,7 @@ export const SessionProvider: ParentComponent = (props) => {
createSession,
clearCurrentSession,
loadSessions,
loadOlderMessages,
selectSession,
deleteSession,
renameSession,
@@ -1,4 +1,4 @@
import { createSignal, onCleanup, onMount } from "solid-js"
import { createSignal, onCleanup } from "solid-js"
import type { Accessor } from "solid-js"
import type { SlashCommandInfo, WebviewMessage, ExtensionMessage } from "../types/messages"
@@ -39,6 +39,7 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set<string>): S
const [server, setServer] = createSignal<SlashCommandInfo[]>([])
const [query, setQuery] = createSignal<string | null>(null)
const [index, setIndex] = createSignal(0)
const [requested, setRequested] = createSignal(false)
const all: SlashCommandEntry[] = [
{
@@ -118,6 +119,12 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set<string>): S
const show = () => query() !== null
const request = () => {
if (requested()) return
setRequested(true)
vscode.postMessage({ type: "requestCommands" })
}
const results = () => {
const q = query()
if (q === null) return []
@@ -137,10 +144,6 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set<string>): S
setServer(message.commands)
})
onMount(() => {
vscode.postMessage({ type: "requestCommands" })
})
onCleanup(() => {
unsubscribe()
})
@@ -153,6 +156,7 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set<string>): S
const before = val.substring(0, cursor)
const match = before.match(SLASH_PATTERN)
if (match) {
request()
setQuery(match[1])
setIndex(0)
} else {
@@ -150,6 +150,9 @@ export function mockSessionValue(overrides?: {
statusText: () => (status === "idle" ? undefined : "Thinking…"),
busySince: () => (status === "busy" ? Date.now() - 2000 : undefined),
loading: () => false,
loadingOlderMessages: () => false,
hasOlderMessages: () => false,
messageMutation: () => undefined,
messages: () => [],
userMessages: () => [],
allMessages: () => ({}),
@@ -157,6 +160,7 @@ export function mockSessionValue(overrides?: {
allStatusMap: () => ({}),
familyData: () => ({ messages: {}, parts: {}, status: {} }),
getParts: () => [],
hydrateParts: noop,
todos: () => [],
permissions: () => permissions,
respondingPermissions: () => new Set<string>(),
@@ -209,6 +213,7 @@ export function mockSessionValue(overrides?: {
createSession: noop,
clearCurrentSession: noop,
loadSessions: noop,
loadOlderMessages: noop,
selectSession: noop,
deleteSession: noop,
renameSession: noop,
@@ -1040,7 +1040,13 @@ export const DiffSummaryCollapsed: Story = {
<ServerContext.Provider value={server as any}>
<SessionContext.Provider value={session as any}>
<div style={{ width: "380px", padding: "12px" }}>
<VscodeSessionTurn sessionID={SESSION_ID} messageID={USER_MSG_ID} />
<VscodeSessionTurn
turn={{
id: USER_MSG_ID,
user: data.message[SESSION_ID][0] as any,
assistant: [data.message[SESSION_ID][1] as any],
}}
/>
</div>
</SessionContext.Provider>
</ServerContext.Provider>
@@ -63,6 +63,33 @@
font-size: 13px;
}
.message-list-page-loader {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 0 12px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.message-list-load-older {
display: block;
margin: 0 auto 12px;
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 6px;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
cursor: pointer;
padding: 5px 10px;
font: inherit;
font-size: 12px;
}
.message-list-load-older:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
.message-list-content {
display: flex;
min-height: 100%;
@@ -589,10 +589,15 @@ export interface MessageRemovedMessage {
messageID: string
}
export type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile"
export interface MessagesLoadedMessage {
type: "messagesLoaded"
sessionID: string
messages: Message[]
mode?: Exclude<MessageLoadMode, "focus">
cursor?: string
hasMore?: boolean
}
export interface MessageCreatedMessage {
@@ -1685,6 +1690,9 @@ export interface ClearSessionRequest {
export interface LoadMessagesRequest {
type: "loadMessages"
sessionID: string
mode?: MessageLoadMode
before?: string
limit?: number
}
export interface LoadSessionsRequest {
+36 -36
View File
@@ -475,40 +475,6 @@ export type EventTodoUpdated = {
}
}
export type SessionStatus =
| {
type: "idle"
}
| {
type: "retry"
attempt: number
message: string
next: number
}
| {
type: "busy"
}
| {
type: "offline"
requestID: string
message: string
}
export type EventSessionStatus = {
type: "session.status"
properties: {
sessionID: string
status: SessionStatus
}
}
export type EventSessionIdle = {
type: "session.idle"
properties: {
sessionID: string
}
}
export type SuggestionAction = {
/**
* Button or option label (1-5 words)
@@ -568,6 +534,40 @@ export type EventSuggestionDismissed = {
}
}
export type SessionStatus =
| {
type: "idle"
}
| {
type: "retry"
attempt: number
message: string
next: number
}
| {
type: "busy"
}
| {
type: "offline"
requestID: string
message: string
}
export type EventSessionStatus = {
type: "session.status"
properties: {
sessionID: string
status: SessionStatus
}
}
export type EventSessionIdle = {
type: "session.idle"
properties: {
sessionID: string
}
}
export type EventSessionCompacted = {
type: "session.compacted"
properties: {
@@ -1145,11 +1145,11 @@ export type Event =
| EventQuestionReplied
| EventQuestionRejected
| EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
| EventSuggestionShown
| EventSuggestionAccepted
| EventSuggestionDismissed
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventKiloSessionsRemoteStatusChanged
| EventWorkspaceReady
+39 -5
View File
@@ -6,6 +6,7 @@ import { checksum } from "@opencode-ai/util/encode"
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
import { isServer } from "solid-js/web"
import { stream } from "./markdown-stream"
import { tryFastRender } from "../kilocode/markdown-fast-path" // kilocode_change
type Entry = {
hash: string
@@ -308,6 +309,16 @@ export function Markdown(
copy: i18n.t("ui.message.copy"),
copied: i18n.t("ui.message.copied"),
}
// kilocode_change start
const fast = tryFastRender(container, content, local.streaming, decorate, setupCodeCopy, () => labels, copyCleanup)
if (fast.handled) {
copyCleanup = fast.copyCleanup
kickHighlight(container, labels)
return
}
// kilocode_change end
const temp = document.createElement("div")
temp.innerHTML = content
decorate(temp, labels)
@@ -356,14 +367,37 @@ export function Markdown(
})
// kilocode_change end
if (!copyCleanup)
copyCleanup = setupCodeCopy(container, () => ({
copy: i18n.t("ui.message.copy"),
copied: i18n.t("ui.message.copied"),
}))
kickHighlight(container, labels)
})
// kilocode_change start: progressive Shiki highlighting (issue #6221, PR #7102).
// Parser emits plain <pre><code data-lang="..."> blocks; we upgrade them to
// Shiki-highlighted <pre class="shiki"> here via setTimeout(0) so initial
// paint is instant and session switches with many code blocks don't freeze.
// The generation counter + abort signal cancel a previous in-flight pass
// when streaming tokens (or session switches) spawn a new render.
function kickHighlight(container: HTMLDivElement, labels: { copy: string; copied: string }) {
highlightState.signal.aborted = true
const gen = ++highlightState.gen
const signal = { aborted: false }
highlightState.signal = signal
void deferredHighlight(
container,
() => {
if (gen !== highlightState.gen) return
if (copyCleanup) copyCleanup()
copyCleanup = setupCodeCopy(container, () => labels)
},
signal,
)
}
// kilocode_change end
onCleanup(() => {
// kilocode_change: cancel any in-flight deferredHighlight pass so its
// completion callback doesn't touch the unmounted DOM.
highlightState.signal.aborted = true
highlightState.gen++
if (copyCleanup) copyCleanup()
})
@@ -1596,6 +1596,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="bullet-list"
defer
trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }}
>
<Show when={props.output}>
@@ -1616,6 +1617,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="magnifying-glass-menu"
defer
trigger={{
title: i18n.t("ui.tool.glob"),
subtitle: getDirectory(props.input.path || "/"),
@@ -1643,6 +1645,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="magnifying-glass-menu"
defer
trigger={{
title: i18n.t("ui.tool.grep"),
subtitle: getDirectory(props.input.path || "/"),
@@ -1863,6 +1866,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="console"
defer
trigger={
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
+10 -21
View File
@@ -1,6 +1,11 @@
import { marked } from "marked"
import markedKatex from "marked-katex-extension"
import markedShiki from "marked-shiki"
// kilocode_change: marked-shiki highlighted code blocks synchronously during
// parse, freezing the main thread on session switches with many code blocks
// (issue #6221 / PR #7102). We render plain <pre><code data-lang="..."> here
// and hand off to deferredHighlight() in markdown.tsx for progressive Shiki.
// This import was re-added by an upstream merge; removing it restores the
// two-pass rendering design.
import katex from "katex"
import { bundledLanguages, type BundledLanguage } from "shiki"
import { parseFilePath } from "../file-path" // kilocode_change
@@ -670,26 +675,10 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
throwOnError: false,
nonStandard: true,
}),
markedShiki({
async highlight(code, lang) {
const highlighter = await getSharedHighlighter({
themes: ["Kilo"],
langs: [],
preferredHighlighter: "shiki-wasm",
})
if (!(lang in bundledLanguages)) {
lang = "text"
}
if (!highlighter.getLoadedLanguages().includes(lang)) {
await highlighter.loadLanguage(lang as BundledLanguage)
}
return highlighter.codeToHtml(code, {
lang: lang || "text",
theme: "Kilo",
tabindex: false,
})
},
}),
// kilocode_change: markedShiki removed — the custom `code` renderer
// above returns plain <pre><code data-lang="..."> and markdown.tsx
// calls deferredHighlight() after paint. Running Shiki inside parse
// blocks the main thread on session switches (issue #6221).
)
// kilocode_change end
@@ -0,0 +1,29 @@
// Fast-path initial render for completed (non-streaming) markdown blocks.
// Skips morphdom's expensive tree-matching by writing innerHTML directly
// when the container is empty. On large session switches this avoids the
// dominant "Parse HTML + morphdom diff" cost for historical messages.
type CopyLabels = { copy: string; copied: string }
/**
* If the content is a first paint of completed markdown (not streaming,
* container empty), render directly via innerHTML and return true.
* The caller should skip morphdom when this returns true.
*/
export function tryFastRender(
container: HTMLDivElement,
content: string,
streaming: boolean | undefined,
decorate: (root: HTMLDivElement, labels: CopyLabels) => void,
setupCopy: (root: HTMLDivElement, getLabels: () => CopyLabels) => (() => void) | undefined,
getLabels: () => CopyLabels,
copyCleanup: (() => void) | undefined,
): { handled: boolean; copyCleanup: (() => void) | undefined } {
if (streaming || container.childNodes.length > 0) {
return { handled: false, copyCleanup }
}
container.innerHTML = content
decorate(container, getLabels())
const cleanup = copyCleanup ?? setupCopy(container, getLabels)
return { handled: true, copyCleanup: cleanup }
}