feat: add pending-queue hook and Steer/Cancel UI in ChatPanel

This commit is contained in:
bryan
2026-04-20 12:45:14 -07:00
parent b762020793
commit dcbfd4ab01
2 changed files with 181 additions and 16 deletions
+82 -16
View File
@@ -9,6 +9,7 @@ import {
Loader2,
Paperclip,
X,
Zap,
} from "lucide-react";
import WorkerRunBubble from "@/components/WorkerRunBubble";
import type { WorkerRunGroup } from "@/components/WorkerRunBubble";
@@ -90,6 +91,12 @@ interface ChatPanelProps {
supportsImages?: boolean;
/** Called when user clicks the stop button to cancel the queen's current turn */
onCancel?: () => void;
/** Called when the user steers a queued message into the current turn —
* the message is sent to the backend immediately so it influences the
* agent after the next tool call completes. */
onSteer?: (messageId: string) => void;
/** Called when the user cancels a still-queued (not-yet-sent) message. */
onCancelQueued?: (messageId: string) => void;
/** Pending question from ask_user — replaces textarea when present */
pendingQuestion?: string | null;
/** Options for the pending question */
@@ -482,12 +489,16 @@ const MessageBubble = memo(
showQueenPhaseBadge = true,
queenProfileId,
queenAvatarUrl,
onSteer,
onCancelQueued,
}: {
msg: ChatMessage;
queenPhase?: "independent" | "working" | "reviewing";
showQueenPhaseBadge?: boolean;
queenProfileId?: string | null;
queenAvatarUrl?: string | null;
onSteer?: (messageId: string) => void;
onCancelQueued?: (messageId: string) => void;
}) {
const isUser = msg.type === "user";
const isQueen = msg.role === "queen";
@@ -574,9 +585,9 @@ const MessageBubble = memo(
if (isUser) {
return (
<div className="flex justify-end">
<div className="flex flex-col items-end gap-1">
<div
className={`max-w-[75%] bg-primary text-primary-foreground text-sm leading-relaxed rounded-2xl rounded-br-md px-4 py-3${msg.queued ? " animate-pulse opacity-80" : ""}`}
className={`max-w-[75%] bg-primary text-primary-foreground text-sm leading-relaxed rounded-2xl rounded-br-md px-4 py-3${msg.queued ? " ring-1 ring-amber-500/50" : ""}`}
>
{msg.images && msg.images.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
@@ -595,11 +606,42 @@ const MessageBubble = memo(
)}
{(msg.queued || msg.createdAt) && (
<div className="flex justify-end items-center gap-1.5 mt-1 text-[10px] opacity-60">
{msg.queued && <span>queued</span>}
{msg.queued && (
<span className="inline-flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-amber-400 animate-pulse" />
queued
</span>
)}
{msg.createdAt && <span>{formatMessageTime(msg.createdAt)}</span>}
</div>
)}
</div>
{msg.queued && (onSteer || onCancelQueued) && (
<div className="flex items-center gap-1.5">
{onSteer && (
<button
type="button"
onClick={() => onSteer(msg.id)}
className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 hover:bg-amber-500/25 border border-amber-500/30 transition-colors"
title="Send now — influence the current turn after the next tool call"
>
<Zap className="w-3 h-3" />
Steer
</button>
)}
{onCancelQueued && (
<button
type="button"
onClick={() => onCancelQueued(msg.id)}
className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full bg-muted/60 text-muted-foreground hover:bg-muted border border-border transition-colors"
title="Remove this queued message"
>
<X className="w-3 h-3" />
Cancel
</button>
)}
</div>
)}
</div>
);
}
@@ -685,8 +727,11 @@ const MessageBubble = memo(
prev.msg.id === next.msg.id &&
prev.msg.content === next.msg.content &&
prev.msg.phase === next.msg.phase &&
prev.msg.queued === next.msg.queued &&
prev.queenPhase === next.queenPhase &&
prev.showQueenPhaseBadge === next.showQueenPhaseBadge,
prev.showQueenPhaseBadge === next.showQueenPhaseBadge &&
prev.onSteer === next.onSteer &&
prev.onCancelQueued === next.onCancelQueued,
);
export default function ChatPanel({
@@ -698,6 +743,8 @@ export default function ChatPanel({
activeThread,
disabled,
onCancel,
onSteer,
onCancelQueued,
pendingQuestion,
pendingOptions,
pendingQuestions,
@@ -1151,6 +1198,8 @@ export default function ChatPanel({
showQueenPhaseBadge={showQueenPhaseBadge}
queenProfileId={queenProfileId}
queenAvatarUrl={queenAvatarUrl}
onSteer={onSteer}
onCancelQueued={onCancelQueued}
/>
</div>
);
@@ -1384,30 +1433,47 @@ export default function ChatPanel({
}
}}
placeholder={
disabled ? "Connecting to agent..." : "Message Queen Bee..."
disabled
? "Connecting to agent..."
: isBusy
? "Queue a message — or click Steer to inject now..."
: "Message Queen Bee..."
}
disabled={disabled}
className="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-50 disabled:cursor-not-allowed resize-none overflow-y-auto"
/>
{isBusy && onCancel ? (
{isBusy && onCancel && (
<button
type="button"
onClick={onCancel}
title="Stop the queen's current turn"
className="p-2 rounded-lg bg-amber-500/15 text-amber-400 border border-amber-500/40 hover:bg-amber-500/25 transition-colors"
>
<Square className="w-4 h-4" />
</button>
) : (
<button
type="submit"
disabled={
(!input.trim() && pendingImages.length === 0) || disabled
}
className="p-2 rounded-lg bg-primary text-primary-foreground disabled:opacity-30 hover:opacity-90 transition-opacity"
>
<Send className="w-4 h-4" />
</button>
)}
<button
type="submit"
disabled={
(!input.trim() && pendingImages.length === 0) || disabled
}
title={
isBusy
? "Queue message — sent after the current turn, or click Steer on the bubble to send now"
: "Send"
}
className={`p-2 rounded-lg disabled:opacity-30 hover:opacity-90 transition-opacity ${
isBusy
? "bg-amber-500/20 text-amber-600 border border-amber-500/40"
: "bg-primary text-primary-foreground"
}`}
>
{isBusy ? (
<Zap className="w-4 h-4" />
) : (
<Send className="w-4 h-4" />
)}
</button>
</div>
</form>
)}
@@ -0,0 +1,99 @@
import { useCallback, useRef } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { ChatMessage, ImageContent } from "@/components/ChatPanel";
interface QueuedPayload {
text: string;
images?: ImageContent[];
}
interface UsePendingQueueArgs {
/** Sends a message to the backend. Must handle its own errors. */
sendToBackend: (text: string, images?: ImageContent[]) => void;
/** Setter for the chat message list — used to flip/strip the `queued` flag. */
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
/** Fires once per flush, before any message is sent. Typically sets
* isTyping/queenIsTyping so the UI reflects that the queen is busy again. */
onFlushStart?: () => void;
}
/**
* Client-side queue for user messages typed while the queen is mid-turn.
*
* - `enqueue` stores a message locally keyed by its optimistic UI id.
* - `steer` pulls one message out and sends it now — backend injects at the
* next iteration boundary.
* - `cancelQueued` drops a queued message entirely (no backend call).
* - `flushNext` pops and sends one; wire this to `llm_turn_complete` (the
* real per-turn boundary — execution_completed only fires at session
* shutdown because the queen's loop parks in _await_user_input between
* turns). Do NOT call on pause / cancel / fail.
*
* `flushRef` exposes the latest `flush` for capture-once SSE handlers.
*/
export function usePendingQueue({
sendToBackend,
setMessages,
onFlushStart,
}: UsePendingQueueArgs) {
const queueRef = useRef<Map<string, QueuedPayload>>(new Map());
const enqueue = useCallback(
(messageId: string, payload: QueuedPayload) => {
queueRef.current.set(messageId, payload);
},
[],
);
const steer = useCallback(
(messageId: string) => {
const pending = queueRef.current.get(messageId);
if (!pending) return;
queueRef.current.delete(messageId);
setMessages((prev) =>
prev.map((m) => (m.id === messageId ? { ...m, queued: false } : m)),
);
sendToBackend(pending.text, pending.images);
},
[sendToBackend, setMessages],
);
const cancelQueued = useCallback(
(messageId: string) => {
if (!queueRef.current.has(messageId)) return;
queueRef.current.delete(messageId);
setMessages((prev) => prev.filter((m) => m.id !== messageId));
},
[setMessages],
);
// Drop every queued payload without sending. Call on route-level resets
// (queen switch, colony switch) — the hook outlives those transitions,
// so without this, stale queue entries flush into the new session.
const clear = useCallback(() => {
queueRef.current.clear();
}, []);
// Pop and send the oldest queued message (Map iteration is insertion
// order in JS). One-at-a-time semantics: used for both the Stop-button
// path (cancel current turn, send next) and the natural-turn-end path
// (on `execution_completed`, pick up the next queued message).
const flushNext = useCallback(() => {
const first = queueRef.current.entries().next();
if (first.done) return;
const [firstId, payload] = first.value;
queueRef.current.delete(firstId);
setMessages((prev) =>
prev.map((m) => (m.id === firstId ? { ...m, queued: false } : m)),
);
onFlushStart?.();
sendToBackend(payload.text, payload.images);
}, [sendToBackend, setMessages, onFlushStart]);
// Ref to the latest flushNext so SSE handlers captured with narrow deps
// can still invoke the up-to-date closure.
const flushNextRef = useRef(flushNext);
flushNextRef.current = flushNext;
return { enqueue, steer, cancelQueued, flushNext, flushNextRef, clear };
}