Skip to content

Commit 5b1e6f7

Browse files
committed
Queue user messages while a turn is running
- Add local queued-message state and drain it after the current turn settles - Show queued messages distinctly in the timeline and improve bottom scrolling
1 parent 39ac501 commit 5b1e6f7

6 files changed

Lines changed: 259 additions & 19 deletions

File tree

.claude/worktrees/agent-a634e4db

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit 39ac501c60736f65c3b45b6e2842d8ae9ea74e07

.claude/worktrees/agent-afc59fb8

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit 39ac501c60736f65c3b45b6e2842d8ae9ea74e07

apps/web/src/components/ChatView.logic.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[
7676

7777
export type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
7878

79+
/** A message queued locally while the agent is busy processing a turn. */
80+
export interface QueuedMessage {
81+
id: string;
82+
text: string;
83+
images: ComposerImageAttachment[];
84+
terminalContexts: TerminalContextDraft[];
85+
createdAt: string;
86+
}
87+
7988
export interface PullRequestDialogState {
8089
initialReference: string | null;
8190
key: number;

apps/web/src/components/ChatView.tsx

Lines changed: 234 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ import {
179179
LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
180180
LastInvokedScriptByProjectSchema,
181181
PullRequestDialogState,
182+
QueuedMessage,
182183
readFileAsDataUrl,
183184
revokeBlobPreviewUrl,
184185
revokeUserMessagePreviewUrls,
@@ -347,6 +348,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
347348
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
348349
const optimisticUserMessagesRef = useRef(optimisticUserMessages);
349350
optimisticUserMessagesRef.current = optimisticUserMessages;
351+
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
352+
const queuedMessagesRef = useRef(queuedMessages);
353+
queuedMessagesRef.current = queuedMessages;
350354
const composerTerminalContextsRef = useRef<TerminalContextDraft[]>(composerTerminalContexts);
351355
const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
352356
Record<ThreadId, string | null>
@@ -1874,6 +1878,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
18741878
scheduleStickToBottom();
18751879
}, [phase, scheduleStickToBottom, timelineEntries]);
18761880

1881+
// Aggressively scroll to bottom after the user submits a new message.
1882+
// The virtualizer may not have settled by the time the first scroll fires,
1883+
// so we schedule multiple backup attempts similar to the thread-change handler.
1884+
const optimisticUserMessageCount = optimisticUserMessages.length;
1885+
useEffect(() => {
1886+
if (optimisticUserMessageCount === 0) return;
1887+
shouldAutoScrollRef.current = true;
1888+
setShowScrollToBottom(false);
1889+
forceStickToBottom();
1890+
const t1 = window.setTimeout(() => {
1891+
forceStickToBottom();
1892+
}, 50);
1893+
const t2 = window.setTimeout(() => {
1894+
forceStickToBottom();
1895+
}, 150);
1896+
return () => {
1897+
window.clearTimeout(t1);
1898+
window.clearTimeout(t2);
1899+
};
1900+
}, [optimisticUserMessageCount, forceStickToBottom]);
1901+
18771902
useEffect(() => {
18781903
setExpandedWorkGroups({});
18791904
setPullRequestDialogState(null);
@@ -1960,6 +1985,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19601985
}
19611986
return [];
19621987
});
1988+
setQueuedMessages([]);
19631989
setSendPhase("idle");
19641990
setSendStartedAt(null);
19651991
setComposerHighlightedItemId(null);
@@ -2132,6 +2158,99 @@ export default function ChatView({ threadId }: ChatViewProps) {
21322158
sendPhase,
21332159
]);
21342160

2161+
// ── Queue drain: dispatch next queued message when the current turn settles ──
2162+
const isDrainingQueueRef = useRef(false);
2163+
useEffect(() => {
2164+
if (!latestTurnSettled || queuedMessages.length === 0) return;
2165+
if (isSendBusy || isConnecting || sendInFlightRef.current || isDrainingQueueRef.current) return;
2166+
if (!activeThread || !activeProject) return;
2167+
const api = readNativeApi();
2168+
if (!api) return;
2169+
2170+
const [nextQueued, ...rest] = queuedMessages;
2171+
if (!nextQueued) return;
2172+
2173+
isDrainingQueueRef.current = true;
2174+
setQueuedMessages(rest);
2175+
2176+
// Mark the optimistic message as no longer queued
2177+
setOptimisticUserMessages((existing) =>
2178+
existing.map((msg) => (msg.id === nextQueued.id ? { ...msg, queued: false } : msg)),
2179+
);
2180+
2181+
const threadIdForSend = activeThread.id;
2182+
const messageIdForSend = nextQueued.id;
2183+
const messageCreatedAt = new Date().toISOString();
2184+
const composerTerminalContextsSnapshot = nextQueued.terminalContexts;
2185+
const messageTextForSend = appendTerminalContextsToPrompt(
2186+
nextQueued.text,
2187+
composerTerminalContextsSnapshot,
2188+
);
2189+
const outgoingMessageText = formatOutgoingPrompt({
2190+
provider: selectedProvider,
2191+
effort: selectedPromptEffort,
2192+
text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,
2193+
});
2194+
2195+
sendInFlightRef.current = true;
2196+
beginSendPhase("sending-turn");
2197+
2198+
(async () => {
2199+
await persistThreadSettingsForNextTurn({
2200+
threadId: threadIdForSend,
2201+
createdAt: messageCreatedAt,
2202+
...(selectedModel ? { model: selectedModel } : {}),
2203+
runtimeMode,
2204+
interactionMode,
2205+
});
2206+
const turnAttachments = await Promise.all(
2207+
nextQueued.images.map(async (image) => ({
2208+
type: "image" as const,
2209+
name: image.name,
2210+
mimeType: image.mimeType,
2211+
sizeBytes: image.sizeBytes,
2212+
dataUrl: await readFileAsDataUrl(image.file),
2213+
})),
2214+
);
2215+
await api.orchestration.dispatchCommand({
2216+
type: "thread.turn.start",
2217+
commandId: newCommandId(),
2218+
threadId: threadIdForSend,
2219+
message: {
2220+
messageId: messageIdForSend,
2221+
role: "user",
2222+
text: outgoingMessageText,
2223+
attachments: turnAttachments,
2224+
},
2225+
model: selectedModel || undefined,
2226+
...(selectedModelOptionsForDispatch
2227+
? { modelOptions: selectedModelOptionsForDispatch }
2228+
: {}),
2229+
...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),
2230+
provider: selectedProvider,
2231+
assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
2232+
runtimeMode,
2233+
interactionMode,
2234+
createdAt: messageCreatedAt,
2235+
});
2236+
})()
2237+
.catch((err: unknown) => {
2238+
setOptimisticUserMessages((existing) =>
2239+
existing.filter((msg) => msg.id !== nextQueued.id),
2240+
);
2241+
setThreadError(
2242+
threadIdForSend,
2243+
err instanceof Error ? err.message : "Failed to send queued message.",
2244+
);
2245+
resetSendPhase();
2246+
})
2247+
.finally(() => {
2248+
sendInFlightRef.current = false;
2249+
isDrainingQueueRef.current = false;
2250+
});
2251+
// eslint-disable-next-line react-hooks/exhaustive-deps
2252+
}, [latestTurnSettled, queuedMessages.length, isSendBusy, isConnecting]);
2253+
21352254
useEffect(() => {
21362255
if (!activeThreadId) return;
21372256
const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;
@@ -2461,6 +2580,72 @@ export default function ChatView({ threadId }: ChatViewProps) {
24612580
}
24622581
return;
24632582
}
2583+
2584+
// ── Queue message if a turn is already running ────────────────────
2585+
if (phase === "running") {
2586+
const composerImagesSnapshot = [...composerImages];
2587+
const messageTextForSend = appendTerminalContextsToPrompt(
2588+
promptForSend,
2589+
sendableComposerTerminalContexts,
2590+
);
2591+
const messageCreatedAt = new Date().toISOString();
2592+
const outgoingMessageText = formatOutgoingPrompt({
2593+
provider: selectedProvider,
2594+
effort: selectedPromptEffort,
2595+
text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,
2596+
});
2597+
const optimisticAttachments = composerImagesSnapshot.map((image) => ({
2598+
type: "image" as const,
2599+
id: image.id,
2600+
name: image.name,
2601+
mimeType: image.mimeType,
2602+
sizeBytes: image.sizeBytes,
2603+
previewUrl: image.previewUrl,
2604+
}));
2605+
const queuedId = newMessageId();
2606+
setQueuedMessages((existing) => [
2607+
...existing,
2608+
{
2609+
id: queuedId,
2610+
text: promptForSend,
2611+
images: composerImagesSnapshot,
2612+
terminalContexts: [...sendableComposerTerminalContexts],
2613+
createdAt: messageCreatedAt,
2614+
},
2615+
]);
2616+
setOptimisticUserMessages((existing) => [
2617+
...existing,
2618+
{
2619+
id: queuedId,
2620+
role: "user",
2621+
text: outgoingMessageText,
2622+
...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),
2623+
createdAt: messageCreatedAt,
2624+
streaming: false,
2625+
queued: true,
2626+
},
2627+
]);
2628+
shouldAutoScrollRef.current = true;
2629+
forceStickToBottom();
2630+
promptRef.current = "";
2631+
clearComposerDraftContent(activeThread.id);
2632+
setComposerHighlightedItemId(null);
2633+
setComposerCursor(0);
2634+
setComposerTrigger(null);
2635+
if (expiredTerminalContextCount > 0) {
2636+
const toastCopy = buildExpiredTerminalContextToastCopy(
2637+
expiredTerminalContextCount,
2638+
"omitted",
2639+
);
2640+
toastManager.add({
2641+
type: "warning",
2642+
title: toastCopy.title,
2643+
description: toastCopy.description,
2644+
});
2645+
}
2646+
return;
2647+
}
2648+
24642649
if (!activeProject) return;
24652650
const threadIdForSend = activeThread.id;
24662651
const isFirstMessage = !isServerThread || activeThread.messages.length === 0;
@@ -4158,6 +4343,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
41584343
Preparing worktree...
41594344
</span>
41604345
) : null}
4346+
{queuedMessages.length > 0 && phase === "running" ? (
4347+
<span className="text-muted-foreground/60 text-xs">
4348+
{queuedMessages.length} queued
4349+
</span>
4350+
) : null}
41614351
{activePendingProgress ? (
41624352
<div className="flex items-center gap-2">
41634353
{activePendingProgress.questionIndex > 0 ? (
@@ -4190,22 +4380,51 @@ export default function ChatView({ threadId }: ChatViewProps) {
41904380
</Button>
41914381
</div>
41924382
) : phase === "running" ? (
4193-
<button
4194-
type="button"
4195-
className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8"
4196-
onClick={() => void onInterrupt()}
4197-
aria-label="Stop generation"
4198-
>
4199-
<svg
4200-
width="12"
4201-
height="12"
4202-
viewBox="0 0 12 12"
4203-
fill="currentColor"
4204-
aria-hidden="true"
4383+
<div className="flex items-center gap-1.5">
4384+
<button
4385+
type="button"
4386+
className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8"
4387+
onClick={() => void onInterrupt()}
4388+
aria-label="Stop generation"
4389+
>
4390+
<svg
4391+
width="12"
4392+
height="12"
4393+
viewBox="0 0 12 12"
4394+
fill="currentColor"
4395+
aria-hidden="true"
4396+
>
4397+
<rect x="2" y="2" width="8" height="8" rx="1.5" />
4398+
</svg>
4399+
</button>
4400+
<button
4401+
type="submit"
4402+
className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
4403+
disabled={
4404+
isSendBusy ||
4405+
isConnecting ||
4406+
!composerSendState.hasSendableContent
4407+
}
4408+
aria-label="Queue message"
4409+
title="Queue message (will send after current turn completes)"
42054410
>
4206-
<rect x="2" y="2" width="8" height="8" rx="1.5" />
4207-
</svg>
4208-
</button>
4411+
<svg
4412+
width="14"
4413+
height="14"
4414+
viewBox="0 0 14 14"
4415+
fill="none"
4416+
aria-hidden="true"
4417+
>
4418+
<path
4419+
d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5"
4420+
stroke="currentColor"
4421+
strokeWidth="1.8"
4422+
strokeLinecap="round"
4423+
strokeLinejoin="round"
4424+
/>
4425+
</svg>
4426+
</button>
4427+
</div>
42094428
) : pendingUserInputs.length === 0 ? (
42104429
showPlanFollowUpPrompt ? (
42114430
prompt.trim().length > 0 ? (

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -370,9 +370,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
370370
const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);
371371
const terminalContexts = displayedUserMessage.contexts;
372372
const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);
373+
const isQueued = row.message.queued === true;
373374
return (
374375
<div className="flex justify-end">
375-
<div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
376+
<div className={cn("group relative max-w-[80%] rounded-2xl rounded-br-sm border px-4 py-3", isQueued ? "border-dashed border-border/60 bg-secondary/60" : "border-border bg-secondary")}>
376377
{userImages.length > 0 && (
377378
<div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
378379
{userImages.map(
@@ -435,9 +436,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({
435436
</Button>
436437
)}
437438
</div>
438-
<p className="text-right text-[10px] text-muted-foreground/30">
439-
{formatTimestamp(row.message.createdAt, timestampFormat)}
440-
</p>
439+
<div className="flex items-center gap-1.5">
440+
{isQueued && (
441+
<span className="inline-flex items-center rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-medium text-amber-600 dark:text-amber-400">
442+
Queued
443+
</span>
444+
)}
445+
<p className="text-right text-[10px] text-muted-foreground/30">
446+
{formatTimestamp(row.message.createdAt, timestampFormat)}
447+
</p>
448+
</div>
441449
</div>
442450
</div>
443451
</div>

apps/web/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export interface ChatMessage {
4747
createdAt: string;
4848
completedAt?: string | undefined;
4949
streaming: boolean;
50+
/** When true, this message is queued locally and has not yet been dispatched to the server. */
51+
queued?: boolean | undefined;
5052
}
5153

5254
export interface ProposedPlan {

0 commit comments

Comments
 (0)