mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
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:
co-authored by
github-actions[bot]
parent
7db99c37eb
commit
7038ce0612
@@ -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")]
|
||||
|
||||
+2
-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 />
|
||||
|
||||
Reference in New Issue
Block a user