mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge branch 'main' into docs/code-reviews-remove-max-review-time
This commit is contained in:
@@ -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.
|
||||
@@ -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] ?? []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user