Archived
Improve queued message handling
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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 {
|
||||
|
||||
@@ -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" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ChatLine["parts"][number], { type: "text" }> => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function appendNormalized(messages: ChatLine[], rawMessage: unknown): ChatLine[] {
|
||||
|
||||
@@ -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()}
|
||||
</div>
|
||||
${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`
|
||||
<aside class="queued-messages" aria-live="polite">
|
||||
<div class="queued-header">
|
||||
<strong>Queued messages</strong>
|
||||
<small>${queued.length} pending · Stop clears the queue</small>
|
||||
</div>
|
||||
${queued.map((message, index) => html`
|
||||
<div class="queued-message">
|
||||
<span class="queued-kind">${message.kind === "steer" ? "Steer" : "Follow-up"} ${String(index + 1)}</span>
|
||||
<formatted-text .text=${message.text}></formatted-text>
|
||||
</div>
|
||||
`)}
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSessionActivity() {
|
||||
if (this.isReceivingPartialStream) return html`
|
||||
<aside class="session-activity receiving" aria-live="polite">
|
||||
|
||||
@@ -253,7 +253,7 @@ export class PiWebApp extends LitElement {
|
||||
<div class="mobile-navigation-panel">${this.renderNavigationPanel(true)}</div>
|
||||
${state.selectedSession ? html`
|
||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()}></prompt-editor>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()}></prompt-editor>
|
||||
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace}></status-bar>
|
||||
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
|
||||
` : html`<div class="empty">Select or start a session.</div>`}
|
||||
|
||||
@@ -57,7 +57,7 @@ export class PromptEditor extends LitElement {
|
||||
<div class="actions">
|
||||
<button ?disabled=${this.disabled} title=${queuesInput ? "Queue until the current activity finishes" : "Send message"} @click=${() => { this.send("followUp"); }}>${queuesInput ? "Queue" : "Send"}</button>
|
||||
${this.canSteer && !this.isCompacting ? html`<button ?disabled=${this.disabled} title="Steer the current response before the next model call" @click=${() => { this.send("steer"); }}>Steer</button>` : null}
|
||||
<button ?disabled=${this.disabled || !this.canStop} title=${this.canStop ? "Stop current work" : "Nothing running"} @click=${() => this.onStop?.()}>Stop</button>
|
||||
<button ?disabled=${this.disabled || !this.canStop} title=${this.canStop ? "Stop current work and clear queued messages" : "Nothing running"} @click=${() => this.onStop?.()}>Stop</button>
|
||||
</div>
|
||||
</footer>
|
||||
`;
|
||||
|
||||
@@ -145,6 +145,13 @@ export const chatStyles = css`
|
||||
.group-msg.system { color: #ff7b72; }
|
||||
.group-msg.bash { color: #3fb950; }
|
||||
.history-boundary { display: grid; gap: 3px; margin: 0 0 14px; color: #8b949e; font-size: 12px; text-align: center; }
|
||||
.queued-messages { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 8px; margin: 0 0 14px; padding: 12px; border: 1px solid #6e5200; border-radius: 10px; background: #1f1a10; color: #e6edf3; overflow: auto; -webkit-overflow-scrolling: touch; }
|
||||
.queued-header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||||
.queued-header strong { color: #d29922; }
|
||||
.queued-header small { color: #8b949e; }
|
||||
.queued-message { display: grid; gap: 4px; padding-top: 8px; border-top: 1px solid #30363d; }
|
||||
.queued-message:first-of-type { padding-top: 0; border-top: 0; }
|
||||
.queued-kind { color: #8b949e; font-size: 12px; text-transform: uppercase; }
|
||||
.session-activity { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 4px; margin: 0 0 14px; padding: 12px; border: 1px solid #30363d; border-radius: 10px; background: #161b22; color: #e6edf3; overflow: auto; -webkit-overflow-scrolling: touch; }
|
||||
.session-activity.compacting { border-color: #a371f7; background: #21132f; }
|
||||
.session-activity.receiving { border-color: #238636; background: #0f1b12; }
|
||||
|
||||
Reference in New Issue
Block a user