Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/queued-messages-stay-at-bottom.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 34 additions & 1 deletion packages/kilo-vscode/tests/unit/session-queue.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -46,13 +46,46 @@ 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")]

expect(queuedUserMessageIDs(messages, { type: "idle" })).toEqual([])
})
})

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")]
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 13 additions & 18 deletions packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,19 @@ 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"
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 => {
Expand Down Expand Up @@ -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(() =>
Expand All @@ -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) => {
Expand Down Expand Up @@ -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}
Expand All @@ -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>
Expand Down
60 changes: 44 additions & 16 deletions packages/kilo-vscode/webview-ui/src/context/session-queue.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) {
Expand Down
34 changes: 25 additions & 9 deletions packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand All @@ -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,
Expand All @@ -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]: [
Expand Down Expand Up @@ -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,
Expand All @@ -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 />
Expand Down
Loading