mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
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:
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user