fix(vscode): keep queued messages pinned at the bottom (#9195)

* fix(vscode): keep queued messages pinned at the bottom

Queued follow-up user messages were being rendered inline inside the
conversation history, so tool output for an earlier turn could appear
under a later queued message. Render queued turns in a separate stack
beneath the virtualized history and group assistant messages by
parentID so late-arriving tool output stays attached to its turn.

* chore: update kilo-vscode visual regression baselines

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Marius
2026-04-20 09:52:09 +02:00
committed by GitHub
co-authored by github-actions[bot]
parent 7db99c37eb
commit 7038ce0612
6 changed files with 123 additions and 46 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix queued follow-up messages so they stay stacked at the bottom of the chat while the previous turn is still being processed, instead of getting interleaved with tool output.
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"
import { activeUserMessageID, queuedUserMessageIDs } from "../../webview-ui/src/context/session-queue"
import { activeUserMessageID, messageTurns, queuedUserMessageIDs } from "../../webview-ui/src/context/session-queue"
import type { Message } from "../../webview-ui/src/types/messages"
const base = {
@@ -46,6 +46,17 @@ describe("queuedUserMessageIDs", () => {
expect(queuedUserMessageIDs(messages, { type: "busy" })).toEqual(["message_4"])
})
it("keeps all follow-ups queued when the active assistant arrives after them", () => {
const messages = [
user("message_1"),
user("message_3"),
user("message_4"),
assistant("message_2", "message_1", { finish: "tool-calls" }),
]
expect(queuedUserMessageIDs(messages, { type: "busy" })).toEqual(["message_3", "message_4"])
})
it("returns no queued messages while idle", () => {
const messages = [user("message_1"), user("message_2")]
@@ -53,6 +64,28 @@ describe("queuedUserMessageIDs", () => {
})
})
describe("messageTurns", () => {
it("attaches assistant output to its parent turn when queued users are newer", () => {
const messages = [
user("message_1"),
user("message_3"),
user("message_4"),
assistant("message_2", "message_1", { finish: "tool-calls" }),
]
expect(
messageTurns(messages).map((turn) => ({
user: turn.user.id,
assistant: turn.assistant.map((msg) => msg.id),
})),
).toEqual([
{ user: "message_1", assistant: ["message_2"] },
{ user: "message_3", assistant: [] },
{ user: "message_4", assistant: [] },
])
})
})
describe("activeUserMessageID", () => {
it("uses the first pending user before the first assistant exists", () => {
const messages = [user("message_1"), user("message_2")]
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:07b3bf094c0a82e64fc7cb4e22aa37ada3dd5904a19a313879c6016c2ab55a00
size 14586
oid sha256:55616e4acb64a04ef5f5c37707f6123561065c22a2266197b031d47c068949a4
size 14255
@@ -17,7 +17,7 @@ import { useServer } from "../../context/server"
import { useLanguage } from "../../context/language"
import { formatRelativeDate } from "../../utils/date"
import { FeedbackDialog } from "./FeedbackDialog"
import { VscodeSessionTurn, type VscodeTurn } from "./VscodeSessionTurn"
import { VscodeSessionTurn } from "./VscodeSessionTurn"
import { RevertBanner } from "./RevertBanner"
import { AccountSwitcher } from "../shared/AccountSwitcher"
import { KiloNotifications } from "./KiloNotifications"
@@ -25,7 +25,11 @@ 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 {
activeUserMessageID as getActiveUserMessageID,
messageTurns,
queuedUserMessageIDs,
} from "../../context/session-queue"
import type { QuestionRequest, SuggestionRequest } from "../../types/messages"
const KiloLogo = (): JSX.Element => {
@@ -79,20 +83,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
const positions = new Map<string, { top: number; userScrolled: boolean }>()
const boundary = () => session.revert()?.messageID
const turns = createMemo<VscodeTurn[]>(() => {
const result: VscodeTurn[] = []
const b = boundary()
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 turns = createMemo(() => messageTurns(session.messages(), boundary()))
const isEmpty = () => turns().length === 0 && !session.loading() && !boundary()
const recent = createMemo(() =>
@@ -102,11 +93,14 @@ export const MessageList: Component<MessageListProps> = (props) => {
)
const activeUserID = createMemo(() => getActiveUserMessageID(session.messages(), session.statusInfo()))
const queuedIDs = createMemo(() => new Set(queuedUserMessageIDs(session.messages(), session.statusInfo())))
const visibleTurns = createMemo(() => turns().filter((turn) => !queuedIDs().has(turn.user.id)))
const queuedTurns = createMemo(() => turns().filter((turn) => queuedIDs().has(turn.user.id)))
const activeUserIndex = createMemo(() => {
const active = activeUserID()
if (!active) return -1
return turns().findIndex((turn) => turn.user.id === active)
return visibleTurns().findIndex((turn) => turn.user.id === active)
})
const save = (id: string | undefined) => {
@@ -231,7 +225,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
</Show>
<Show when={scrollEl()}>
<Virtualizer
data={turns()}
data={visibleTurns()}
scrollRef={scrollEl()}
shift={session.messageMutation() === "prepend"}
overscan={6}
@@ -251,6 +245,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
<Show when={boundary()}>
<RevertBanner />
</Show>
<For each={queuedTurns()}>{(turn) => <VscodeSessionTurn turn={turn} queued />}</For>
<WorkingIndicator />
<For each={props.questions?.()}>{(req) => <QuestionDock request={req} />}</For>
<For each={props.suggestions?.()}>{(req) => <SuggestBar request={req} />}</For>
@@ -1,9 +1,36 @@
import type { Message, SessionStatusInfo } from "../types/messages"
export interface MessageTurn {
id: string
user: Message
assistant: Message[]
}
export function messageTurns(messages: Message[], boundary?: string) {
const result: MessageTurn[] = []
const by = new Map<string, MessageTurn>()
for (const msg of messages) {
if (msg.role === "user") {
if (boundary && msg.id >= boundary) break
const turn = { id: msg.id, user: msg, assistant: [] }
result.push(turn)
by.set(msg.id, turn)
continue
}
if (msg.role !== "assistant") continue
const turn = (msg.parentID ? by.get(msg.parentID) : undefined) ?? result[result.length - 1]
if (turn) turn.assistant.push(msg)
}
return result
}
function active(messages: Message[]) {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i]
if (msg.role !== "assistant") continue
if (!msg || msg.role !== "assistant") continue
if (typeof msg.time?.completed === "number") continue
if (msg.error) continue
if (msg.finish && !["tool-calls", "unknown"].includes(msg.finish)) continue
@@ -16,25 +43,26 @@ function active(messages: Message[]) {
return undefined
}
function pending(messages: Message[]) {
const done = (() => {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i]
if (msg.role !== "assistant") continue
if (typeof msg.time?.completed === "number") return i
if (msg.error) return i
if (msg.finish && !["tool-calls", "unknown"].includes(msg.finish)) return i
}
return -1
})()
for (let i = done + 1; i < messages.length; i += 1) {
if (messages[i].role === "user") return messages[i].id
function done(messages: Message[]) {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i]
if (!msg || msg.role !== "assistant") continue
if (typeof msg.time?.completed === "number") return msg.parentID
if (msg.error) return msg.parentID
if (msg.finish && !["tool-calls", "unknown"].includes(msg.finish)) return msg.parentID
}
return undefined
}
function pending(messages: Message[]) {
const users = messages.filter((msg) => msg.role === "user")
const id = done(messages)
if (!id) return users[0]?.id
const idx = users.findIndex((msg) => msg.id === id)
return users[idx + 1]?.id
}
// Find the user message whose turn the server is actively processing.
// Any user message after this one is "queued" (waiting for its turn).
export function activeUserMessageID(messages: Message[], status: SessionStatusInfo) {
@@ -234,6 +234,7 @@ export const SuggestBarReview: Story = {
const toolUserID = "user-msg-spacing-001"
const toolAssistantID = "asst-msg-spacing-001"
const queuedUserID = "user-msg-spacing-002"
const queuedSecondID = "user-msg-spacing-003"
const toolNow = 1_700_000_000_000
const spacingMessages = [
{
@@ -242,6 +243,18 @@ const spacingMessages = [
role: "user",
time: { created: toolNow - 9000 },
},
{
id: queuedUserID,
sessionID: SESSION_ID,
role: "user",
time: { created: toolNow - 1000 },
},
{
id: queuedSecondID,
sessionID: SESSION_ID,
role: "user",
time: { created: toolNow - 500 },
},
{
id: toolAssistantID,
sessionID: SESSION_ID,
@@ -254,12 +267,6 @@ const spacingMessages = [
agent: "default",
path: { cwd: "/project", root: "/project" },
},
{
id: queuedUserID,
sessionID: SESSION_ID,
role: "user",
time: { created: toolNow - 1000 },
},
]
const spacingParts = {
[toolUserID]: [
@@ -298,6 +305,15 @@ const spacingParts = {
text: "ok",
},
],
[queuedSecondID]: [
{
id: "part-user-spacing-003",
sessionID: SESSION_ID,
messageID: queuedSecondID,
type: "text",
text: "and then explain it",
},
],
}
const spacingData = {
...defaultMockData,
@@ -306,15 +322,15 @@ const spacingData = {
}
export const MessageListToolToQueuedUserSpacing: Story = {
name: "MessageList — tool to queued user spacing",
name: "MessageList — queued users stay at bottom",
render: () => {
const session = {
...mockSessionValue({ id: SESSION_ID, status: "idle" }),
...mockSessionValue({ id: SESSION_ID, status: "busy" }),
messages: () => spacingMessages,
userMessages: () => spacingMessages.filter((msg) => msg.role === "user"),
}
return (
<StoryProviders data={spacingData} sessionID={SESSION_ID} status="idle" noPadding>
<StoryProviders data={spacingData} sessionID={SESSION_ID} status="busy" noPadding>
<SessionContext.Provider value={session as any}>
<div style={{ height: "420px", display: "flex", "flex-direction": "column" }}>
<MessageList />