From 63ef03f8f6a4ee5e8a07de402a02436f27144562 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 10 May 2026 00:46:47 +0200 Subject: [PATCH] Improve queued message handling --- src/client/src/api.ts | 2 +- src/client/src/api/parsers.test.ts | 2 + src/client/src/api/parsers.ts | 10 +- src/client/src/chatTranscript.test.ts | 17 +++ src/client/src/chatTranscript.ts | 14 ++- src/client/src/components/ChatView.ts | 20 +++ src/client/src/components/PiWebApp.ts | 2 +- src/client/src/components/PromptEditor.ts | 2 +- src/client/src/components/shared.ts | 7 ++ src/server/realtime/sessionEventHub.test.ts | 1 + src/server/sessions/piSessionService.test.ts | 121 ++++++++++++++++++- src/server/sessions/piSessionService.ts | 30 ++++- src/shared/apiTypes.ts | 6 + 13 files changed, 226 insertions(+), 8 deletions(-) diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 0ba6786..cdd2655 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,3 +1,3 @@ export { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; export { globalSessionEvents, sessionEvents, terminalSocket } from "./api/sockets"; -export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionActivity, SessionInfo, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, Workspace } from "../../shared/apiTypes"; +export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, Workspace } from "../../shared/apiTypes"; diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 7fd4588..0a9880a 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -14,6 +14,7 @@ describe("API parsers", () => { isCompacting: true, isBashRunning: false, pendingMessageCount: 2, + queuedMessages: [{ kind: "steer", text: "adjust this" }, { kind: "followUp", text: "then do that" }], tokens: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, total: 10 }, cost: 0.12, model: { provider: "p", id: "m", contextWindow: 100, reasoning: { effort: "low" } }, @@ -25,6 +26,7 @@ describe("API parsers", () => { isCompacting: true, isBashRunning: false, pendingMessageCount: 2, + queuedMessages: [{ kind: "steer", text: "adjust this" }, { kind: "followUp", text: "then do that" }], tokens: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, total: 10 }, cost: 0.12, model: { provider: "p", id: "m", contextWindow: 100, reasoning: { effort: "low" } }, diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 437833a..9745b04 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,4 +1,4 @@ -import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionInfo, SessionStatus, SlashCommand, TerminalInfo, Workspace } from "../../../shared/apiTypes"; +import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, QueuedSessionMessage, SessionInfo, SessionStatus, SlashCommand, TerminalInfo, Workspace } from "../../../shared/apiTypes"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -100,6 +100,7 @@ export function parseSessionStatus(value: unknown): SessionStatus { isCompacting: requireBoolean(record, "isCompacting"), isBashRunning: requireBoolean(record, "isBashRunning"), pendingMessageCount: requireNumber(record, "pendingMessageCount"), + queuedMessages: record["queuedMessages"] === undefined ? [] : arrayOf(parseQueuedSessionMessage)(record["queuedMessages"]), tokens: parseTokens(record["tokens"]), cost: requireNumber(record, "cost"), ...optionalModel(record["model"]), @@ -108,6 +109,13 @@ export function parseSessionStatus(value: unknown): SessionStatus { }; } +function parseQueuedSessionMessage(value: unknown): QueuedSessionMessage { + const record = requireRecord(value); + const kind = requireString(record, "kind"); + if (kind !== "steer" && kind !== "followUp") throw new Error("Invalid queued message kind"); + return { kind, text: requireString(record, "text") }; +} + function parseTokens(value: unknown): SessionStatus["tokens"] { const record = requireRecord(value); return { diff --git a/src/client/src/chatTranscript.test.ts b/src/client/src/chatTranscript.test.ts index 535e1a2..10895f3 100644 --- a/src/client/src/chatTranscript.test.ts +++ b/src/client/src/chatTranscript.test.ts @@ -41,4 +41,21 @@ describe("applyTranscriptEvent", () => { }, ]); }); + + it("does not merge different finalized user messages", () => { + const messages = [textMessage("user", "first queued prompt")]; + + expect(applyTranscriptEvent(messages, { type: "message.end", message: { role: "user", content: "second queued prompt" } })).toEqual([ + textMessage("user", "first queued prompt"), + textMessage("user", "second queued prompt"), + ]); + }); + + it("replaces an optimistic user message when the finalized text matches", () => { + const messages = [textMessage("user", "sent prompt")]; + + expect(applyTranscriptEvent(messages, { type: "message.end", message: { role: "user", content: "sent prompt", timestamp: "2026-05-09T12:00:00.000Z" } })).toEqual([ + { ...textMessage("user", "sent prompt"), meta: { timestamp: "2026-05-09T12:00:00.000Z" } }, + ]); + }); }); diff --git a/src/client/src/chatTranscript.ts b/src/client/src/chatTranscript.ts index a023c23..6f4be8d 100644 --- a/src/client/src/chatTranscript.ts +++ b/src/client/src/chatTranscript.ts @@ -23,7 +23,19 @@ function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[ if (ended === undefined) return undefined; const last = messages.at(-1); if (last?.role !== ended.role) return [...messages, ended]; - return [...messages.slice(0, -1), ended]; + if (ended.role === "assistant" || sameMessageText(last, ended)) return [...messages.slice(0, -1), ended]; + return [...messages, ended]; +} + +function sameMessageText(left: ChatLine, right: ChatLine): boolean { + return messageText(left) === messageText(right); +} + +function messageText(message: ChatLine): string { + return message.parts + .filter((part): part is Extract => part.type === "text") + .map((part) => part.text) + .join("\n\n"); } function appendNormalized(messages: ChatLine[], rawMessage: unknown): ChatLine[] { diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 589a72b..42119e6 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -70,6 +70,7 @@ export class ChatView extends LitElement { ${groupChatMessages(this.messages, this.messageStart).map((group) => group.kind === "message" ? this.renderMessage(group.message, group.index) : this.renderMessageGroup(group.messages, group.startIndex))} + ${this.renderQueuedMessages()} ${this.renderSessionActivity()} ${this.renderActivityDock()} @@ -89,6 +90,25 @@ export class ChatView extends LitElement { `; } + private renderQueuedMessages() { + const queued = this.status?.queuedMessages ?? []; + if (queued.length === 0) return null; + return html` + + `; + } + private renderSessionActivity() { if (this.isReceivingPartialStream) return html`