Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lat.md/chat-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
65 changes: 65 additions & 0 deletions src/renderer/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
78 changes: 54 additions & 24 deletions src/renderer/src/screens/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -177,7 +178,9 @@ function Chat({
const dragCounter = useRef(0);
const chatInputRef = useRef<ChatInputHandle>(null);
const queueRef = useRef<QueuedMessage[]>([]);
const queueSequenceRef = useRef(0);
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
const [queuePaused, setQueuePaused] = useState(false);
const activeTurnRef = useRef<ActiveTurn | null>(null);
const dashboardChatEnabled = dashboardChatEnabledForConnection(
import.meta.env.VITE_HERMES_DESKTOP_DASHBOARD_CHAT,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -817,11 +843,13 @@ function Chat({
) : (
<MessageList
messages={messages}
queuedMessages={queuedMessages}
isLoading={isLoading}
toolProgress={toolProgress}
onApprove={actions.handleApprove}
onDeny={actions.handleDeny}
onClarifyResolved={handleClarifyResolved}
onRemoveQueued={handleRemoveQueued}
/>
)}
<div ref={bottomRef} />
Expand All @@ -844,6 +872,8 @@ function Chat({
<QueuedMessages
messages={queuedMessages}
onRemove={handleRemoveQueued}
paused={queuePaused}
onRetry={handleRetryQueued}
/>
<ChatInput
ref={chatInputRef}
Expand Down
125 changes: 125 additions & 0 deletions src/renderer/src/screens/Chat/MessageList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { render } from "@testing-library/react";
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import { MessageList } from "./MessageList";
import type { ChatMessage, QueuedMessage } from "./types";

vi.mock("../../components/useI18n", () => ({
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 } }) => <div>{msg.text}</div>,
ToolActivityGroup: ({
items,
}: {
items: Array<{ id: string; args?: string }>;
}) => <div>{items.map((item) => item.args || item.id).join(" ")}</div>,
}));

vi.mock("./ClarifyCard", () => ({
ClarifyCard: () => <div>clarify</div>,
}));

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<typeof MessageList> {
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(
<MessageList {...listProps(baseMessages, [queuedMessage])} />,
);
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(<MessageList {...listProps(sentMessages, [])} />);

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();
});
});
Loading