diff --git a/lat.md/chat-commands.md b/lat.md/chat-commands.md index 25f26e78c..6e00fb07d 100644 --- a/lat.md/chat-commands.md +++ b/lat.md/chat-commands.md @@ -24,6 +24,16 @@ Slash commands run on the gateway's **persistent slash-worker subprocess**, conc Because no global loading state is set, the slash branch shows its own feedback: it inserts an in-place `⏳ Running …` agent bubble, buffers the pipeline output, and replaces that bubble with the result (or `error: …`) when the command resolves — otherwise a slow or unreachable gateway would leave the user staring at nothing. +## Mid-turn queue anchoring + +Follow-ups submitted during a running turn preserve where they were entered without changing the canonical message order consumed by stream reducers and backend history. + +[[src/renderer/src/screens/Chat/queueAnchoring.ts#captureQueueAnchor]] records the last message in the active turn when a plain follow-up or resolved `/queue` prompt is deferred. Pending items stay in `queuedMessages`, outside the canonical `messages` array, so reasoning, tool, and completion events cannot mistake a queued prompt for a new active-turn boundary. + +[[src/renderer/src/screens/Chat/queueAnchoring.ts#buildQueueAwareRenderPlan]] builds a visual-only transcript plan. It places pending queue notes and their eventual user bubbles after the captured message while leaving the underlying array in backend order. When the queue drains, the pending note is replaced by exactly one normal user message carrying the same renderer-only anchor. + +The `/queue` send-directive path in [[src/renderer/src/screens/Chat/hooks/useChatActions.ts#useChatActions]] does not optimistically append the literal command. It resolves the command first, defers only the resulting prompt while busy, and starts a normal turn immediately when idle. A synchronous queued-send failure removes the optimistic user row and rejects back to the queue drain, which restores the item at the front and pauses automatic draining until the user retries instead of silently losing it or entering a retry loop. + ## Transport connection lifecycle Every dashboard turn first connects a JSON-RPC WebSocket to the gateway; that handshake must be time-bounded or a stalled socket wedges the whole transport with no error and no fallback (issue #718). diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index f1f71ef82..053ee8a66 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -3988,6 +3988,71 @@ body { background: var(--bg-hover, rgba(255, 255, 255, 0.08)); } +.chat-queue-retry { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 7px; + border: 0; + border-radius: var(--radius-sm, 6px); + background: var(--bg-hover, rgba(255, 255, 255, 0.08)); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; +} + +.chat-queue-retry:hover { + color: var(--text-primary); +} + +/* Transcript-local queue marker. It is deliberately rendered outside the + canonical message array so adding it cannot change the active stream turn. */ +.chat-queued-prompt-row { + display: flex; + justify-content: flex-end; + padding: 4px 0; +} + +.chat-queued-prompt-note { + position: relative; + width: min(72%, 680px); + padding: 10px 34px 10px 12px; + border: 1px dashed var(--accent, #8b7cf6); + border-radius: var(--radius-md, 10px); + background: color-mix( + in srgb, + var(--accent, #8b7cf6) 8%, + var(--bg-secondary) + ); + color: var(--text-primary); +} + +.chat-queued-prompt-label, +.chat-queued-origin-label { + display: flex; + align-items: center; + gap: 5px; + margin-bottom: 4px; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.25; +} + +.chat-queued-prompt-text { + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.chat-queued-prompt-note > .chat-queue-remove { + position: absolute; + top: 8px; + right: 8px; +} + +.chat-queued-origin-label { + color: color-mix(in srgb, currentColor 70%, transparent); +} + /* Input area */ .chat-input-area { position: relative; diff --git a/src/renderer/src/screens/Chat/Chat.tsx b/src/renderer/src/screens/Chat/Chat.tsx index 989514347..9bca2ab48 100644 --- a/src/renderer/src/screens/Chat/Chat.tsx +++ b/src/renderer/src/screens/Chat/Chat.tsx @@ -30,15 +30,16 @@ import { ConfigHealthBanner } from "../../components/ConfigHealthBanner"; import FollowUsModal from "../../components/FollowUsModal"; import type { Attachment } from "../../../../shared/attachments"; import type { SessionModelOverride } from "../../../../shared/model-override"; -import type { ActiveTurn, ChatMessage, UsageState } from "./types"; +import type { + ActiveTurn, + ChatMessage, + QueuedMessage, + UsageState, +} from "./types"; import type { ContextUsage } from "./ContextGauge"; import { contextWindowForModel } from "./contextWindows"; import { QueuedMessages } from "./QueuedMessages"; - -interface QueuedMessage { - text: string; - attachments: Attachment[]; -} +import { createQueuedMessage } from "./queueAnchoring"; export type { ChatMessage } from "./types"; @@ -177,7 +178,9 @@ function Chat({ const dragCounter = useRef(0); const chatInputRef = useRef(null); const queueRef = useRef([]); + const queueSequenceRef = useRef(0); const [queuedMessages, setQueuedMessages] = useState([]); + const [queuePaused, setQueuePaused] = useState(false); const activeTurnRef = useRef(null); const dashboardChatEnabled = dashboardChatEnabledForConnection( import.meta.env.VITE_HERMES_DESKTOP_DASHBOARD_CHAT, @@ -492,7 +495,9 @@ function Chat({ setUsage(null); setToolProgress(null); queueRef.current = []; + queueSequenceRef.current = 0; setQueuedMessages([]); + setQueuePaused(false); }, [isLoading, runId, hermesSessionId, setMessages, modelConfig.reload]); const localCommands = useLocalCommands({ @@ -535,12 +540,24 @@ function Chat({ onDashboardUnavailable: handleDashboardUnavailable, }); - // Defer a message onto the busy queue (used when a slash command resolves to - // an agent prompt while a turn is already in flight). - const enqueueMessage = useCallback((text: string) => { - queueRef.current.push({ text, attachments: [] }); - setQueuedMessages([...queueRef.current]); - }, []); + // Capture the exact transcript boundary visible when a follow-up is queued. + // The queue marker is rendered from separate state, never inserted into the + // canonical message array that receives the active turn's stream events. + const enqueueMessage = useCallback( + (text: string, attachments: Attachment[] = []) => { + queueSequenceRef.current += 1; + const queued = createQueuedMessage( + text, + attachments, + messages, + activeTurnRef.current, + queueSequenceRef.current, + ); + queueRef.current.push(queued); + setQueuedMessages([...queueRef.current]); + }, + [messages], + ); const actions = useChatActions({ runId, @@ -583,21 +600,31 @@ function Chat({ // Drain queued messages one at a time when the agent finishes. useEffect(() => { - if (isLoading) return; + if (isLoading || queuePaused) return; const next = queueRef.current.shift(); if (!next) return; setQueuedMessages([...queueRef.current]); - handleSendRef.current(next.text, next.attachments, true).catch(() => { - // Put the message back at the front so it isn't silently lost if - // the send fails (e.g. IPC error before onChatError fires). - queueRef.current.unshift(next); - setQueuedMessages([...queueRef.current]); - }); - }, [isLoading]); + handleSendRef + .current(next.text, next.attachments, true, next.anchor) + .catch(() => { + // Put the message back at the front so it isn't silently lost if + // the send fails (e.g. IPC error before onChatError fires). + queueRef.current.unshift(next); + setQueuedMessages([...queueRef.current]); + setQueuePaused(true); + }); + }, [isLoading, queuePaused]); - const handleRemoveQueued = useCallback((index: number) => { + const handleRemoveQueued = useCallback((id: string) => { + const index = queueRef.current.findIndex((message) => message.id === id); + if (index < 0) return; queueRef.current.splice(index, 1); setQueuedMessages([...queueRef.current]); + setQueuePaused(false); + }, []); + + const handleRetryQueued = useCallback(() => { + setQueuePaused(false); }, []); const handleSubmitOrQueue = useCallback( @@ -626,13 +653,12 @@ function Chat({ return; } if (isLoading) { - queueRef.current.push({ text, attachments }); - setQueuedMessages([...queueRef.current]); + enqueueMessage(text, attachments); return; } void handleSendRef.current(text, attachments); }, - [isLoading, localCommands, dashboardChatEnabled], + [isLoading, localCommands, dashboardChatEnabled, enqueueMessage], ); const handleSuggestion = useCallback((text: string) => { @@ -817,11 +843,13 @@ function Chat({ ) : ( )}
@@ -844,6 +872,8 @@ function Chat({ ({ + useI18n: () => ({ + t: (key: string) => + ({ + "chat.queuedCancel": "Remove from queue", + "chat.queuedSubmittedHere": "Queued during this turn", + "chat.queuedSentFromHere": "Queued here · sent after the turn finished", + "chat.copyMessage": "Copy message", + "common.copied": "Copied", + })[key] ?? key, + }), +})); + +vi.mock("./HistoryRow", () => ({ + ReasoningRow: ({ msg }: { msg: { text: string } }) =>
{msg.text}
, + ToolActivityGroup: ({ + items, + }: { + items: Array<{ id: string; args?: string }>; + }) =>
{items.map((item) => item.args || item.id).join(" ")}
, +})); + +vi.mock("./ClarifyCard", () => ({ + ClarifyCard: () =>
clarify
, +})); + +const baseMessages: ChatMessage[] = [ + { + id: "user-1", + role: "user", + content: "start task", + turnId: "turn-1", + }, + { + id: "tool-1", + kind: "tool_call", + role: "agent", + callId: "call-1", + name: "terminal", + args: "first tool", + }, + { + id: "answer-1", + role: "agent", + content: "original answer", + turnId: "turn-1", + }, +]; + +const queuedMessage: QueuedMessage = { + id: "queue-1", + text: "follow up", + attachments: [], + anchor: { + afterMessageId: "tool-1", + afterMessageIndex: 1, + sequence: 1, + turnId: "turn-1", + }, +}; + +function listProps( + messages: ChatMessage[], + queuedMessages: QueuedMessage[], +): ComponentProps { + return { + messages, + queuedMessages, + isLoading: true, + toolProgress: null, + onApprove: vi.fn(), + onDeny: vi.fn(), + onClarifyResolved: vi.fn(), + onRemoveQueued: vi.fn(), + }; +} + +describe("MessageList queued prompt rendering", () => { + it("replaces the anchored pending note with one normal user bubble on send", () => { + const view = render( + , + ); + const pendingText = view.container.textContent ?? ""; + expect(pendingText.indexOf("first tool")).toBeLessThan( + pendingText.indexOf("follow up"), + ); + expect(pendingText.indexOf("follow up")).toBeLessThan( + pendingText.indexOf("original answer"), + ); + expect( + view.container.querySelector('[data-queued-message-id="queue-1"]'), + ).toBeInTheDocument(); + + const sentMessages: ChatMessage[] = [ + ...baseMessages, + { + id: "user-2", + role: "user", + content: "follow up", + turnId: "turn-2", + queueAnchor: queuedMessage.anchor, + }, + ]; + view.rerender(); + + const sentText = view.container.textContent ?? ""; + expect(sentText.match(/follow up/g)).toHaveLength(1); + expect(sentText).toContain("Queued here · sent after the turn finished"); + expect(sentText.indexOf("first tool")).toBeLessThan( + sentText.indexOf("follow up"), + ); + expect(sentText.indexOf("follow up")).toBeLessThan( + sentText.indexOf("original answer"), + ); + expect( + view.container.querySelector('[data-queued-message-id="queue-1"]'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/renderer/src/screens/Chat/MessageList.tsx b/src/renderer/src/screens/Chat/MessageList.tsx index 1ad0b7671..5baa67d26 100644 --- a/src/renderer/src/screens/Chat/MessageList.tsx +++ b/src/renderer/src/screens/Chat/MessageList.tsx @@ -2,9 +2,12 @@ import { memo, useMemo } from "react"; import { HermesAvatar, MessageRow } from "./MessageRow"; import { ReasoningRow, ToolActivityGroup } from "./HistoryRow"; import { ClarifyCard } from "./ClarifyCard"; +import { QueuedPromptRow } from "./QueuedMessages"; +import { buildQueueAwareRenderPlan } from "./queueAnchoring"; import type { ChatMessage, ClarifyMessage, + QueuedMessage, ToolCallMessage, ToolResultMessage, } from "./types"; @@ -16,12 +19,14 @@ function isToolRow(m: ChatMessage): m is ToolCallMessage | ToolResultMessage { interface MessageListProps { messages: ChatMessage[]; + queuedMessages: QueuedMessage[]; isLoading: boolean; toolProgress: string | null; onApprove: () => void; onDeny: () => void; /** Mark an inline clarify card resolved once the user answers/skips. */ onClarifyResolved: (requestId: string, answer: string) => void; + onRemoveQueued: (id: string) => void; } function TypingIndicator({ @@ -61,21 +66,28 @@ function isBubble(m: ChatMessage): m is import("./types").ChatBubbleMessage { export const MessageList = memo(function MessageList({ messages, + queuedMessages, isLoading, toolProgress, onApprove, onDeny, onClarifyResolved, + onRemoveQueued, }: MessageListProps): React.JSX.Element { - // Bubbles with empty content are still hidden (live-stream placeholders). - // History rows pass through unconditionally. - const visibleMessages = useMemo( + // Queue markers and already-sent queued prompts are placed only in this + // visual plan. The canonical array remains untouched for stream reducers. + const visibleItems = useMemo( () => - messages.filter((m) => { - if (!isBubble(m)) return true; - return !!m.error || ((m.content as string) || "").trim().length > 0; + buildQueueAwareRenderPlan(messages, queuedMessages).filter((item) => { + if (item.type === "queued") return true; + const message = item.message; + if (!isBubble(message)) return true; + return ( + !!message.error || + ((message.content as string) || "").trim().length > 0 + ); }), - [messages], + [messages, queuedMessages], ); const lastBubble = [...messages].reverse().find(isBubble); @@ -85,35 +97,60 @@ export const MessageList = memo(function MessageList({ // contiguous run of tool_call/tool_result rows folds into a single // ToolActivityGroup (collapsed by default) instead of one bubble per call. const rows: React.JSX.Element[] = []; - for (let i = 0; i < visibleMessages.length; i++) { - const msg = visibleMessages[i]; + let previousMessage: ChatMessage | undefined; + for (let i = 0; i < visibleItems.length; i++) { + const item = visibleItems[i]; + if (item.type === "queued") { + rows.push( + , + ); + continue; + } + + const msg = item.message; // One avatar per turn: show it only on the first row of a contiguous run // of same-role rows. The agent turn's thinking/tool rows + answer bubble // share one avatar; the continuation rows render a spacer. - const prev = visibleMessages[i - 1]; - const showAvatar = !prev || prev.role !== msg.role; + const previousTurnId = + previousMessage && isBubble(previousMessage) + ? previousMessage.turnId + : undefined; + const currentTurnId = isBubble(msg) ? msg.turnId : undefined; + const showAvatar = + !previousMessage || + previousMessage.role !== msg.role || + (!!previousTurnId && !!currentTurnId && previousTurnId !== currentTurnId); if (isToolRow(msg)) { // Collect the whole run of consecutive tool rows. const group: (ToolCallMessage | ToolResultMessage)[] = []; const start = i; - while (i < visibleMessages.length && isToolRow(visibleMessages[i])) { - group.push(visibleMessages[i] as ToolCallMessage | ToolResultMessage); + while (i < visibleItems.length) { + const candidate = visibleItems[i]; + if (candidate.type !== "message" || !isToolRow(candidate.message)) { + break; + } + group.push(candidate.message); i++; } i--; // step back: the for-loop's i++ advances past the run + const hasMessageAfter = visibleItems + .slice(i + 1) + .some((candidate) => candidate.type === "message"); rows.push( , ); + previousMessage = group[group.length - 1]; continue; } @@ -126,10 +163,16 @@ export const MessageList = memo(function MessageList({ // Still "Thinking…" only while this is the last row and the turn is // streaming; once the answer arrives (or history loads) it becomes // a completed "Thought". - active={isLoading && i === visibleMessages.length - 1} + active={ + isLoading && + !visibleItems + .slice(i + 1) + .some((candidate) => candidate.type === "message") + } showAvatar={showAvatar} />, ); + previousMessage = msg; continue; } @@ -141,6 +184,7 @@ export const MessageList = memo(function MessageList({ onResolved={onClarifyResolved} />, ); + previousMessage = msg; continue; } @@ -149,13 +193,24 @@ export const MessageList = memo(function MessageList({ candidate.type === "message") + } isLoading={isLoading} onApprove={onApprove} onDeny={onDeny} showAvatar={showAvatar} />, ); + // A visually relocated queued user bubble is an annotation inside the + // earlier agent turn, not a boundary for grouping that turn's remaining + // reasoning/tool rows. The next real turn still gets a fresh avatar via + // its distinct turnId at the canonical end of the transcript. + if (!(isBubble(msg) && msg.role === "user" && msg.queueAnchor)) { + previousMessage = msg; + } } return ( diff --git a/src/renderer/src/screens/Chat/MessageRow.tsx b/src/renderer/src/screens/Chat/MessageRow.tsx index 3c906a58e..fc2139111 100644 --- a/src/renderer/src/screens/Chat/MessageRow.tsx +++ b/src/renderer/src/screens/Chat/MessageRow.tsx @@ -1,5 +1,5 @@ import { memo, useMemo, useState, useCallback, useEffect, useRef } from "react"; -import { Copy, Check } from "lucide-react"; +import { Copy, Check, CircleDashed } from "lucide-react"; import loadingGif from "../../assets/loadingo.gif"; import { AgentMarkdown } from "../../components/AgentMarkdown"; import { AttachmentChip } from "../../components/AttachmentChip"; @@ -238,6 +238,12 @@ export const MessageRow = memo(function MessageRow({ msg.error ? " chat-bubble-error" : "" }`} > + {msg.role === "user" && msg.queueAnchor && ( +
+ + {t("chat.queuedSentFromHere")} +
+ )} {msg.content && !isLoading && (
+
+
+ ); +}); + /** * Pending-send queue indicator shown above the input while the agent is busy. * Each queued message can be individually cancelled via an X button. @@ -20,6 +60,8 @@ interface QueuedMessagesProps { export const QueuedMessages = memo(function QueuedMessages({ messages, onRemove, + paused = false, + onRetry, }: QueuedMessagesProps): React.JSX.Element | null { const { t } = useI18n(); const [expanded, setExpanded] = useState(false); @@ -46,12 +88,23 @@ export const QueuedMessages = memo(function QueuedMessages({ + {paused && onRetry && ( + + )} ); } @@ -70,17 +123,13 @@ export const QueuedMessages = memo(function QueuedMessages({ {expanded && (
    - {messages.map((m, i) => ( -
  • + {messages.map((m) => ( +
  • {preview(m)}
)} + {paused && onRetry && ( + + )} ); }); diff --git a/src/renderer/src/screens/Chat/hooks/useChatActions.test.tsx b/src/renderer/src/screens/Chat/hooks/useChatActions.test.tsx new file mode 100644 index 000000000..997e3fae9 --- /dev/null +++ b/src/renderer/src/screens/Chat/hooks/useChatActions.test.tsx @@ -0,0 +1,174 @@ +import { act, render, waitFor } from "@testing-library/react"; +import { useEffect, useRef, useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import type { ChatInputHandle } from "../ChatInput"; +import type { ActiveTurn, ChatMessage, QueueAnchor } from "../types"; +import type { SlashExecOutcome } from "../slashExec"; +import { useChatActions } from "./useChatActions"; + +interface HarnessApi { + actions?: ReturnType; + activeTurn?: React.MutableRefObject; + messages?: ChatMessage[]; +} + +interface HarnessProps { + api: HarnessApi; + enqueueMessage?: (text: string) => void; + execSlash?: ( + command: string, + sys: (text: string) => void, + ) => Promise; + initialLoading?: boolean; + sendViaDashboard?: (text: string) => Promise; + setIsLoading?: (loading: boolean) => void; +} + +const initialMessages: ChatMessage[] = [ + { + id: "user-active", + role: "user", + content: "active task", + turnId: "turn-active", + }, + { + id: "tool-active", + kind: "tool_call", + role: "agent", + callId: "call-active", + name: "terminal", + args: "working", + }, +]; + +function Harness({ + api, + enqueueMessage, + execSlash, + initialLoading = true, + sendViaDashboard, + setIsLoading = vi.fn(), +}: HarnessProps): null { + const [messages, setMessages] = useState(initialMessages); + const activeTurnRef = useRef({ + turnId: "turn-active", + userId: "user-active", + startIndex: 0, + status: "running", + }); + const chatInputRef = useRef(null); + const actions = useChatActions({ + runId: "run-test", + hermesSessionId: "session-test", + messages, + isLoading: initialLoading, + setIsLoading, + setMessages, + chatInputRef, + localCommands: { + isLocal: () => false, + executeLocal: vi.fn(async () => false), + }, + activeTurnRef, + contextFolder: null, + sendViaDashboard, + execSlashViaDashboard: execSlash, + enqueueMessage, + addAgentMessage: (content) => + setMessages((prev) => [ + ...prev, + { id: `agent-${prev.length}`, role: "agent", content }, + ]), + }); + + useEffect(() => { + Object.assign(api, { actions, activeTurn: activeTurnRef, messages }); + }, [actions, activeTurnRef, api, messages]); + return null; +} + +describe("useChatActions queue routing", () => { + it("queues the resolved /queue prompt without inserting a user bubble mid-turn", async () => { + const enqueueMessage = vi.fn(); + const execSlash = vi.fn( + async (): Promise => ({ + kind: "send", + message: "resolved follow-up", + }), + ); + const api: HarnessApi = {}; + render( + , + ); + + await act(async () => { + await api.actions?.handleSend("/queue follow-up", [], true); + }); + + expect(enqueueMessage).toHaveBeenCalledWith("resolved follow-up", []); + expect(api.messages).toEqual(initialMessages); + }); + + it("keeps non-queue slash command rendering unchanged", async () => { + const execSlash = vi.fn( + async ( + _command: string, + sys: (text: string) => void, + ): Promise => { + sys("status ok"); + return { kind: "done" }; + }, + ); + const api: HarnessApi = {}; + render(); + + await act(async () => { + await api.actions?.handleSend("/status", [], true); + }); + + await waitFor(() => + expect(api.messages?.map((message) => message.id)).toHaveLength(4), + ); + expect( + api.messages?.map((message) => + "content" in message ? message.content : "", + ), + ).toEqual(["active task", "", "/status", "status ok"]); + }); + + it("removes a failed queued turn so the caller can safely requeue it", async () => { + const sendError = new Error("send failed before IPC accepted it"); + const setIsLoading = vi.fn(); + const api: HarnessApi = {}; + render( + { + throw sendError; + })} + setIsLoading={setIsLoading} + />, + ); + const anchor: QueueAnchor = { + afterMessageId: "tool-active", + afterMessageIndex: 1, + sequence: 1, + turnId: "turn-active", + }; + + await act(async () => { + await expect( + api.actions?.handleSend("retry me", [], true, anchor), + ).rejects.toThrow(sendError); + }); + + await waitFor(() => expect(api.messages).toEqual(initialMessages)); + expect(api.activeTurn?.current).toBeNull(); + expect(setIsLoading).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/renderer/src/screens/Chat/hooks/useChatActions.ts b/src/renderer/src/screens/Chat/hooks/useChatActions.ts index 1370e73a8..d215f12db 100644 --- a/src/renderer/src/screens/Chat/hooks/useChatActions.ts +++ b/src/renderer/src/screens/Chat/hooks/useChatActions.ts @@ -2,7 +2,12 @@ import { useCallback, useEffect, useRef } from "react"; import type { ChatInputHandle } from "../ChatInput"; import { createTurn, shouldSendToAgent } from "../chatMessages"; import type { SlashExecOutcome } from "../slashExec"; -import type { ActiveTurn, Attachment, ChatMessage } from "../types"; +import type { + ActiveTurn, + Attachment, + ChatMessage, + QueueAnchor, +} from "../types"; import type { SessionModelOverride } from "../../../../../shared/model-override"; /** Slash commands the desktop handles through its own renderer flow rather @@ -69,7 +74,7 @@ interface UseChatActionsArgs { addAgentMessage?: (content: string) => void; /** Defer a message onto the busy queue. Used when a slash command resolves to * an agent prompt while a turn is already in flight. */ - enqueueMessage?: (text: string) => void; + enqueueMessage?: (text: string, attachments?: Attachment[]) => void; abortDashboard?: () => void; } @@ -78,6 +83,7 @@ interface UseChatActionsResult { text: string, attachments?: Attachment[], skipLoadingCheck?: boolean, + queueAnchor?: QueueAnchor, ) => Promise; handleQuickAsk: (text: string, attachments?: Attachment[]) => Promise; /** Launch a side-question (`/btw`) background prompt. Bypasses the busy queue; @@ -129,7 +135,12 @@ export function useChatActions({ }); const pushUser = useCallback( - (content: string, idPrefix = "user", attachments?: Attachment[]) => { + ( + content: string, + idPrefix = "user", + attachments?: Attachment[], + queueAnchor?: QueueAnchor, + ) => { const turn = createTurn(idPrefix); setMessages((prev) => [ ...prev, @@ -139,6 +150,7 @@ export function useChatActions({ content, turnId: turn.turnId, ...(attachments && attachments.length > 0 ? { attachments } : {}), + ...(queueAnchor ? { queueAnchor } : {}), }, ]); return turn; @@ -147,7 +159,11 @@ export function useChatActions({ ); const sendToAgent = useCallback( - async (text: string, attachments?: Attachment[]): Promise => { + async ( + text: string, + attachments?: Attachment[], + propagateError = false, + ): Promise => { try { if (sendViaDashboard) { const handled = await sendViaDashboard(text, attachments); @@ -166,13 +182,54 @@ export function useChatActions({ runId, sessionModelRef.current || undefined, ); - } catch { + } catch (error) { // onChatError IPC already surfaces this to the user + if (propagateError) throw error; } }, [runId, profile, hermesSessionId, contextFolder, sendViaDashboard], ); + const runAgentTurn = useCallback( + async ( + prompt: string, + displayText: string, + attachments?: Attachment[], + queueAnchor?: QueueAnchor, + ): Promise => { + setIsLoading(true); + const turn = pushUser(displayText, "user", attachments, queueAnchor); + activeTurnRef.current = { + ...turn, + startIndex: messagesRef.current.length, + status: "running", + }; + onSessionStarted?.(); + try { + await sendToAgent(prompt, attachments, !!queueAnchor); + } catch (error) { + // A queued item remains retryable only if its optimistic user row is + // removed before the drain effect puts the item back at the front. + setMessages((prev) => + prev.filter((message) => message.id !== turn.userId), + ); + if (activeTurnRef.current?.turnId === turn.turnId) { + activeTurnRef.current = null; + } + setIsLoading(false); + throw error; + } + }, + [ + activeTurnRef, + onSessionStarted, + pushUser, + sendToAgent, + setIsLoading, + setMessages, + ], + ); + // Shared "side question" flow (the 💭 quick-ask button and a typed `/btw`). // Renders a distinct user bubble and sends `/btw ` so the agent // answers without folding it into the main context. @@ -221,6 +278,7 @@ export function useChatActions({ text: string, attachments?: Attachment[], skipLoadingCheck = false, + queueAnchor?: QueueAnchor, ): Promise => { const hasPayload = text.length > 0 || (attachments?.length ?? 0) > 0; if (!hasPayload) return; @@ -262,6 +320,34 @@ export function useChatActions({ !RENDERER_NATIVE_SLASH.has(cmdName) && (attachments?.length ?? 0) === 0 ) { + // `/queue` resolves to a normal prompt. Do not optimistically append + // the command itself while another turn is streaming: that user row + // would become the renderer's latest turn boundary and split the + // active reasoning/tool/final output. Only the resolved prompt enters + // the queue, whose separate visual marker preserves submission time. + if (cmdName === "/queue") { + let buffer = ""; + const collect = (chunk: string): void => { + buffer = buffer ? `${buffer}\n${chunk}` : chunk; + }; + const outcome = await execSlashViaDashboard(text, collect); + if (outcome.kind === "send") { + if (isLoadingRef.current) { + enqueueMessage?.(outcome.message, attachments); + } else { + await runAgentTurn(outcome.message, outcome.message, attachments); + } + } else { + pushUser(text); + addAgentMessage?.( + outcome.kind === "error" + ? `error: ${outcome.message}` + : buffer || "(done)", + ); + } + return; + } + const startIndex = messagesRef.current.length; const turn = pushUser(text); @@ -301,7 +387,7 @@ export function useChatActions({ removePending(); if (buffer) addAgentMessage?.(buffer); if (isLoadingRef.current) { - enqueueMessage?.(outcome.message); + enqueueMessage?.(outcome.message, attachments); } else { setIsLoading(true); activeTurnRef.current = { ...turn, startIndex, status: "running" }; @@ -314,15 +400,7 @@ export function useChatActions({ return; } - setIsLoading(true); - const turn = pushUser(text, "user", attachments); - activeTurnRef.current = { - ...turn, - startIndex: messagesRef.current.length, - status: "running", - }; - onSessionStarted?.(); - await sendToAgent(text, attachments); + await runAgentTurn(text, text, attachments, queueAnchor); }, [ activeTurnRef, @@ -330,6 +408,7 @@ export function useChatActions({ execSlashViaDashboard, addAgentMessage, enqueueMessage, + runAgentTurn, runBackground, pushUser, onSessionStarted, diff --git a/src/renderer/src/screens/Chat/queueAnchoring.test.ts b/src/renderer/src/screens/Chat/queueAnchoring.test.ts new file mode 100644 index 000000000..defe146ff --- /dev/null +++ b/src/renderer/src/screens/Chat/queueAnchoring.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { + buildQueueAwareRenderPlan, + createQueuedMessage, +} from "./queueAnchoring"; +import type { ActiveTurn, ChatMessage } from "./types"; + +const activeTurn: ActiveTurn = { + turnId: "turn-1", + userId: "user-1", + startIndex: 0, + status: "running", +}; + +function planIds(plan: ReturnType): string[] { + return plan.map((item) => + item.type === "queued" ? `queued:${item.message.id}` : item.message.id, + ); +} + +describe("queued prompt anchoring", () => { + it("keeps a pending follow-up beside the output visible at submission time", () => { + const atSubmission: ChatMessage[] = [ + { + id: "user-1", + role: "user", + content: "run a long task", + turnId: "turn-1", + }, + { id: "reasoning-1", kind: "reasoning", role: "agent", text: "plan" }, + { + id: "tool-1", + kind: "tool_call", + role: "agent", + callId: "call-1", + name: "terminal", + args: "first step", + }, + ]; + const queued = createQueuedMessage( + "check the second result too", + [], + atSubmission, + activeTurn, + 1, + ); + const afterMoreOutput: ChatMessage[] = [ + ...atSubmission, + { + id: "tool-2", + kind: "tool_call", + role: "agent", + callId: "call-2", + name: "terminal", + args: "second step", + }, + { + id: "answer-1", + role: "agent", + content: "original turn complete", + turnId: "turn-1", + }, + ]; + + expect(queued.anchor.afterMessageId).toBe("tool-1"); + expect( + planIds(buildQueueAwareRenderPlan(afterMoreOutput, [queued])), + ).toEqual([ + "user-1", + "reasoning-1", + "tool-1", + `queued:${queued.id}`, + "tool-2", + "answer-1", + ]); + }); + + it("renders a dequeued prompt once at its captured anchor without reordering state", () => { + const messages: ChatMessage[] = [ + { + id: "user-1", + role: "user", + content: "run a long task", + turnId: "turn-1", + }, + { + id: "tool-1", + kind: "tool_call", + role: "agent", + callId: "call-1", + name: "terminal", + args: "first step", + }, + { + id: "answer-1", + role: "agent", + content: "original turn complete", + turnId: "turn-1", + }, + { + id: "user-2", + role: "user", + content: "follow up", + turnId: "turn-2", + queueAnchor: { + afterMessageId: "tool-1", + afterMessageIndex: 1, + sequence: 1, + turnId: "turn-1", + }, + }, + ]; + + expect(planIds(buildQueueAwareRenderPlan(messages, []))).toEqual([ + "user-1", + "tool-1", + "user-2", + "answer-1", + ]); + expect(messages.map((message) => message.id)).toEqual([ + "user-1", + "tool-1", + "answer-1", + "user-2", + ]); + }); +}); diff --git a/src/renderer/src/screens/Chat/queueAnchoring.ts b/src/renderer/src/screens/Chat/queueAnchoring.ts new file mode 100644 index 000000000..9ad4a2557 --- /dev/null +++ b/src/renderer/src/screens/Chat/queueAnchoring.ts @@ -0,0 +1,148 @@ +import { isBubbleMessage } from "./chatMessages"; +import type { + ActiveTurn, + Attachment, + ChatMessage, + QueueAnchor, + QueuedMessage, +} from "./types"; + +export type QueueAwareRenderItem = + | { type: "message"; message: ChatMessage } + | { type: "queued"; message: QueuedMessage }; + +function activeTurnAnchorIndex( + messages: ReadonlyArray, + activeTurn: ActiveTurn | null, +): number { + if (!activeTurn) return messages.length - 1; + + const userIndex = messages.findIndex( + (message) => + message.id === activeTurn.userId || + (isBubbleMessage(message) && + message.role === "user" && + message.turnId === activeTurn.turnId), + ); + if (userIndex < 0) return messages.length - 1; + + let anchorIndex = userIndex; + for (let index = userIndex + 1; index < messages.length; index++) { + const message = messages[index]; + if (isBubbleMessage(message) && message.role === "user") break; + anchorIndex = index; + } + return anchorIndex; +} + +export function captureQueueAnchor( + messages: ReadonlyArray, + activeTurn: ActiveTurn | null, + sequence: number, +): QueueAnchor { + const afterMessageIndex = activeTurnAnchorIndex(messages, activeTurn); + return { + afterMessageId: messages[afterMessageIndex]?.id ?? null, + afterMessageIndex, + sequence, + ...(activeTurn?.turnId ? { turnId: activeTurn.turnId } : {}), + }; +} + +export function createQueuedMessage( + text: string, + attachments: Attachment[], + messages: ReadonlyArray, + activeTurn: ActiveTurn | null, + sequence: number, +): QueuedMessage { + return { + id: `queued-${Date.now()}-${sequence}`, + text, + attachments, + anchor: captureQueueAnchor(messages, activeTurn, sequence), + }; +} + +interface IndexedMessage { + message: ChatMessage; + originalIndex: number; +} + +interface AnchoredInsertion { + anchor: QueueAnchor; + item: QueueAwareRenderItem; +} + +function messageQueueAnchor(message: ChatMessage): QueueAnchor | undefined { + return isBubbleMessage(message) && message.role === "user" + ? message.queueAnchor + : undefined; +} + +/** + * Build a visual-only transcript order. Canonical `messages` are never + * mutated or reordered, so live stream reducers and backend history retain + * their real turn boundaries while queued follow-ups remain visibly anchored + * to the output that prompted them. + */ +// @lat: [[chat-commands#Slash command execution#Mid-turn queue anchoring]] +export function buildQueueAwareRenderPlan( + messages: ReadonlyArray, + queuedMessages: ReadonlyArray, +): QueueAwareRenderItem[] { + const base: IndexedMessage[] = []; + const insertions: AnchoredInsertion[] = queuedMessages.map((message) => ({ + anchor: message.anchor, + item: { type: "queued", message }, + })); + + messages.forEach((message, originalIndex) => { + const anchor = messageQueueAnchor(message); + if (anchor) { + insertions.push({ + anchor, + item: { type: "message", message }, + }); + return; + } + base.push({ message, originalIndex }); + }); + + const insertionsByBaseIndex = new Map(); + for (const insertion of insertions) { + let targetIndex = base.findIndex( + ({ message }) => message.id === insertion.anchor.afterMessageId, + ); + if (targetIndex < 0) { + targetIndex = -1; + for (let index = 0; index < base.length; index++) { + if (base[index].originalIndex > insertion.anchor.afterMessageIndex) { + break; + } + targetIndex = index; + } + } + const group = insertionsByBaseIndex.get(targetIndex) ?? []; + group.push(insertion); + insertionsByBaseIndex.set(targetIndex, group); + } + + for (const group of insertionsByBaseIndex.values()) { + group.sort((a, b) => a.anchor.sequence - b.anchor.sequence); + } + + const plan: QueueAwareRenderItem[] = []; + const appendInsertions = (index: number): void => { + for (const insertion of insertionsByBaseIndex.get(index) ?? []) { + plan.push(insertion.item); + } + }; + + appendInsertions(-1); + base.forEach(({ message }, index) => { + plan.push({ type: "message", message }); + appendInsertions(index); + }); + return plan; +} diff --git a/src/renderer/src/screens/Chat/types.ts b/src/renderer/src/screens/Chat/types.ts index 58507ea40..558d53ad3 100644 --- a/src/renderer/src/screens/Chat/types.ts +++ b/src/renderer/src/screens/Chat/types.ts @@ -5,6 +5,25 @@ export type { import type { Attachment } from "../../../../shared/attachments"; +/** + * Renderer-only placement metadata for a prompt submitted while another turn + * was active. The canonical message stays in backend order; MessageList uses + * this anchor to preserve where the user actually submitted the follow-up. + */ +export interface QueueAnchor { + afterMessageId: string | null; + afterMessageIndex: number; + sequence: number; + turnId?: string; +} + +export interface QueuedMessage { + id: string; + text: string; + attachments: Attachment[]; + anchor: QueueAnchor; +} + /** * Visible chat bubble (user or assistant). Used for live streaming and as * one of the variants of the broader `ChatMessage` history union. @@ -23,6 +42,8 @@ export interface ChatBubbleMessage { localOnly?: boolean; /** Renderer-local turn identity used to anchor local failures. */ turnId?: string; + /** Original visual position for a prompt that was submitted mid-turn. */ + queueAnchor?: QueueAnchor; } /** diff --git a/src/shared/i18n/locales/en/chat.ts b/src/shared/i18n/locales/en/chat.ts index 13f9821d7..527714f1e 100644 --- a/src/shared/i18n/locales/en/chat.ts +++ b/src/shared/i18n/locales/en/chat.ts @@ -134,6 +134,10 @@ export default { queuedCount: "{{count}} queued", queuedAttachment: "{{count}} attachment(s)", queuedCancel: "Remove from queue", + queuedSubmittedHere: "Queued during this turn", + queuedSentFromHere: "Queued here · sent after the turn finished", + queuedRetry: "Retry queued message", + queuedSendFailed: "Send failed — retry queue", copyMessage: "Copy message", worktree: { loading: "Loading",