diff --git a/.changeset/queued-messages-stay-at-bottom.md b/.changeset/queued-messages-stay-at-bottom.md new file mode 100644 index 00000000000..fc28d66d165 --- /dev/null +++ b/.changeset/queued-messages-stay-at-bottom.md @@ -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. diff --git a/packages/kilo-vscode/tests/unit/session-queue.test.ts b/packages/kilo-vscode/tests/unit/session-queue.test.ts index 367362d07de..9f298bd1ce6 100644 --- a/packages/kilo-vscode/tests/unit/session-queue.test.ts +++ b/packages/kilo-vscode/tests/unit/session-queue.test.ts @@ -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")] diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png index 92378b5e3c5..0ccf5483f0a 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:07b3bf094c0a82e64fc7cb4e22aa37ada3dd5904a19a313879c6016c2ab55a00 -size 14586 +oid sha256:55616e4acb64a04ef5f5c37707f6123561065c22a2266197b031d47c068949a4 +size 14255 diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index 9f5801e121f..ef189951ff3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -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 = (props) => { const positions = new Map() const boundary = () => session.revert()?.messageID - const turns = createMemo(() => { - 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 = (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 = (props) => { = (props) => { + {(turn) => } {(req) => } {(req) => } diff --git a/packages/kilo-vscode/webview-ui/src/context/session-queue.ts b/packages/kilo-vscode/webview-ui/src/context/session-queue.ts index ae3613102bd..759c444232f 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-queue.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-queue.ts @@ -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() + + 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) { diff --git a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx index 0bc276128ed..0d3002d0812 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx @@ -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 ( - +