Merge branch 'main' into docs/code-reviews-remove-max-review-time

This commit is contained in:
Christiaan Arnoldus
2026-08-17 11:41:45 +02:00
committed by GitHub
10 changed files with 126 additions and 40 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-ui": patch
---
Keep chat pinned to the latest streaming output through layout reflows and downward scrolling.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Optimize large session loading performance and reduce reactive re-render overhead in the webview.
+2
View File
@@ -57,6 +57,8 @@ exclude = [
'^https?://aistudio\.google\.com/api-keys/?$',
# Linear intermittently returns 503 to CI link checks.
'^https?://linear\.app/?$',
# These VS Code Marketplace pages intermittently return 503 to automated link checks.
'^https://marketplace\.visualstudio\.com/items\?itemName=(connor4312\.esbuild-problem-matchers|dbaeumer\.vscode-eslint|esbenp\.prettier-vscode|Requesty\.requesty)$',
# Consistently times out in CI
'^https?://opncd\.ai',
'^https?://zod\.dev/v4/changelog',
@@ -203,6 +203,39 @@ describe("createAutoScroll non-scrollable layouts", () => {
ctx.dispose()
})
test("continues following streaming growth after a downward wheel at the bottom", () => {
const ctx = setup({ working: true })
ctx.el.scrollHeight = 1000
ctx.el.clientHeight = 200
ctx.el.scrollTop = 800
ctx.el.fire("wheel", new FakeWheelEvent(50, ctx.el) as unknown as Event)
ctx.el.scrollHeight = 1048
ctx.resize(0)
expect(ctx.scroll.userScrolled()).toBe(false)
expect(ctx.el.scrollTop).toBe(1048)
ctx.dispose()
})
test("continues following when streaming reflow emits scroll before resize", () => {
const ctx = setup({ working: true })
ctx.el.scrollHeight = 1000
ctx.el.clientHeight = 200
ctx.el.scrollTop = 800
ctx.el.scrollHeight = 1108
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(false)
ctx.resize(0)
expect(ctx.scroll.userScrolled()).toBe(false)
expect(ctx.el.scrollTop).toBe(1108)
ctx.dispose()
})
test("follows when initially short content starts overflowing", () => {
const ctx = setup()
ctx.resize()
@@ -262,4 +295,23 @@ describe("createAutoScroll non-scrollable layouts", () => {
expect(ctx.el.scrollTop).toBe(600)
ctx.dispose()
})
test("pauses when a native scrollbar drag changes scroll position without input events", () => {
const ctx = setup({ working: true })
ctx.el.scrollHeight = 1000
ctx.el.clientHeight = 200
ctx.el.scrollTop = 800
ctx.scroll.handleScroll()
ctx.el.scrollTop = 600
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
ctx.el.scrollHeight = 1050
ctx.resize()
expect(ctx.el.scrollTop).toBe(600)
ctx.dispose()
})
})
@@ -25,6 +25,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
let settling = false
let settleTimer: ReturnType<typeof setTimeout> | undefined
let cleanup: (() => void) | undefined
let lastTop = 0
let lastHeight = 0
const [store, setStore] = createStore({
contentRef: undefined as HTMLElement | undefined,
@@ -99,8 +101,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
const handleScroll = () => {
if (!scroll) return
userActivity.consumeScroll()
const input = userActivity.consumeScroll()
const distance = distanceFromBottom(scroll)
const moved = Math.abs(scroll.scrollTop - lastTop) > 1 && Math.abs(scroll.scrollHeight - lastHeight) <= 1
lastTop = scroll.scrollTop
lastHeight = scroll.scrollHeight
if (!canScroll(scroll)) return
@@ -109,6 +114,10 @@ export function createAutoScroll(options: AutoScrollOptions) {
return
}
// Virtualizer and layout remeasurement can emit scroll before the
// ResizeObserver restores bottom-follow. Only user input should pause it.
if (!store.userScrolled && !input && !userActivity.isRecent() && !moved) return
stop()
}
@@ -200,6 +209,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
if (!el) return
lastTop = el.scrollTop
lastHeight = el.scrollHeight
updateOverflowAnchor(el)
cleanup = userActivity.listen(el)
}
@@ -26,8 +26,8 @@ export const createUserActivity = (options: UserActivityOptions) => {
const handleWheel = (event: WheelEvent) => {
if (!isPotentialScrollInput(event)) return
if (!scroll || scroll.scrollHeight - scroll.clientHeight <= 1) return
mark(event)
if (event.deltaY >= 0 || scroll.scrollTop <= 0) return
mark(event)
options.onWheelUp()
}
@@ -218,6 +218,7 @@ const slimmers: Record<string, (state: Record<string, unknown>) => Record<string
multiedit: slimMultiedit,
write: slimWrite,
bash: slimBash,
task: slimOutput,
}
/** Strip provider metadata that the webview never reads from reasoning parts. */
@@ -199,9 +199,9 @@ export function activeUserMessageID(
status: SessionStatusInfo,
parts?: (msg: Message) => Message["parts"],
) {
if (status.type === "idle") return undefined
const id = active(messages, status, parts)
if (id) return id
if (status.type === "idle") return undefined
return pending(messages, parts)
}
@@ -1,5 +1,23 @@
import { reconcile } from "solid-js/store"
import type { Message, Part, ToolPart } from "../types/messages"
import type { Message, MessageLoadMode, Part, ToolPart } from "../types/messages"
export const SNAPSHOT_PROGRESS_TEXT = "Initializing snapshot..."
export type MessageMutation = Exclude<MessageLoadMode, "focus"> | "append" | "update"
export interface MessagePageState {
loadingInitial: boolean
loadingOlder: boolean
before?: string
hasMore: boolean
lastMutation?: MessageMutation
}
export const emptyPageState: MessagePageState = {
loadingInitial: false,
loadingOlder: false,
hasMore: false,
}
/** Remove ids from a Set immutably, returning the original when nothing changed. */
export function dropSet(prev: Set<string>, ids: Iterable<string>): Set<string> {
@@ -8,7 +26,13 @@ export function dropSet(prev: Set<string>, ids: Iterable<string>): Set<string> {
return next.size === prev.size ? prev : next
}
export const SNAPSHOT_PROGRESS_TEXT = "Initializing snapshot..."
export function messageParts(messages: Message[]): Record<string, Part[]> {
const parts: Record<string, Part[]> = {}
for (const msg of messages) {
if (msg.parts && msg.parts.length > 0) parts[msg.id] = msg.parts
}
return parts
}
type SnapshotPart = {
type?: string
@@ -62,10 +62,14 @@ import {
buildSessionToolParts,
childID,
dropSet,
emptyPageState,
messageParts,
reconcileSessionToolParts,
removeSessionToolPart,
removeSessionToolPartsForMessage,
upsertSessionToolPart,
type MessageMutation,
type MessagePageState,
} from "./session-utils"
import { Identifier } from "../utils/id"
import { resolveModelSelection } from "./model-selection"
@@ -91,22 +95,6 @@ import { createModelSelector } from "./session-model-selector"
const RECENT_LIMIT = 5
const MESSAGE_PAGE_LIMIT = 80
type MessageMutation = Exclude<MessageLoadMode, "focus"> | "append" | "update"
interface MessagePageState {
loadingInitial: boolean
loadingOlder: boolean
before?: string
hasMore: boolean
lastMutation?: MessageMutation
}
const emptyPageState: MessagePageState = {
loadingInitial: false,
loadingOlder: false,
hasMore: false,
}
// Store structure for messages and parts
interface SessionStore {
sessions: Record<string, SessionInfo>
@@ -1403,24 +1391,21 @@ export const SessionProvider: ParentComponent = (props) => {
return [...merged, ...orphans]
}
function setTools(sessionID: string, tools: ToolPart[]) {
setStore("toolParts", sessionID, reconcileSessionToolParts(tools))
function setTools(sessionID: string, tools: ToolPart[], mode?: MessageLoadMode) {
setStore("toolParts", sessionID, mode === "replace" ? tools : reconcileSessionToolParts(tools))
}
function rebuildToolParts(sessionID: string, messages: Message[], parts?: Record<string, Part[]>) {
function rebuildToolParts(
sessionID: string,
messages: Message[],
parts?: Record<string, Part[]>,
mode?: MessageLoadMode,
) {
const tools = buildSessionToolParts(
messages,
(msg) => parts?.[msg.id] ?? store.parts[msg.id] ?? stash.peek(msg.id) ?? msg.parts,
(msg) => parts?.[msg.id] ?? stash.peek(msg.id) ?? untrack(() => store.parts[msg.id]) ?? msg.parts,
)
setTools(sessionID, tools)
}
function messageParts(messages: Message[]): Record<string, Part[]> {
const parts: Record<string, Part[]> = {}
for (const msg of messages) {
if (msg.parts && msg.parts.length > 0) parts[msg.id] = msg.parts
}
return parts
setTools(sessionID, tools, mode)
}
function patchToolPart(sessionID: string | undefined, messageID: string, part: Part) {
@@ -1512,7 +1497,7 @@ export const SessionProvider: ParentComponent = (props) => {
if (mode === "reconcile") stash.remove(msg.id)
}
rebuildToolParts(sessionID, merged, loadedParts)
rebuildToolParts(sessionID, merged, loadedParts, mode)
// "reconcile" is a background tail refresh, not a page navigation —
// preserve the existing pagination cursor/hasMore so "load earlier"
@@ -1857,8 +1842,10 @@ export const SessionProvider: ParentComponent = (props) => {
}
function visibleToolParts(sessionID: string, messages: Message[]): ToolPart[] {
const tools = store.toolParts[sessionID]
if (!tools || tools.length === 0 || messages.length === 0) return []
const ids = new Set(messages.map((msg) => msg.id))
return (store.toolParts[sessionID] ?? []).filter((part) => !part.messageID || ids.has(part.messageID))
return tools.filter((part) => !part.messageID || ids.has(part.messageID))
}
/**
@@ -1871,8 +1858,9 @@ export const SessionProvider: ParentComponent = (props) => {
const queue = [rootID]
while (queue.length > 0) {
const sid = queue.pop()!
const tools = store.toolParts[sid]
if (!tools || tools.length === 0 || !tools.some((t) => t.tool === "task")) continue
for (const p of visibleToolParts(sid, source(sid))) {
// Webview ToolState omits runtime metadata; task parts still carry it from the backend.
const child = childID(
p as {
type: string
@@ -2727,9 +2715,7 @@ export const SessionProvider: ParentComponent = (props) => {
return id ? store.messages[id] || [] : []
}
const getParts = (messageID: string) => {
return store.parts[messageID] || stash.peek(messageID) || []
}
const getParts = (messageID: string) => stash.peek(messageID) ?? untrack(() => store.parts[messageID]) ?? []
const getSessionToolParts = (sessionID: string) => store.toolParts[sessionID] ?? []