From fe9a9039280dbf243ec77e80959b5b934dd1f04d Mon Sep 17 00:00:00 2001 From: bryan Date: Tue, 28 Apr 2026 18:16:47 -0700 Subject: [PATCH 1/3] feat: surface ask_user questions in chat transcript --- core/frontend/src/lib/chat-helpers.test.ts | 38 +++++++++++++++++-- core/frontend/src/lib/chat-helpers.ts | 44 ++++++++++++++++++++-- core/frontend/src/pages/queen-dm.tsx | 5 +++ 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/core/frontend/src/lib/chat-helpers.test.ts b/core/frontend/src/lib/chat-helpers.test.ts index 9103c93b..3ba20cf0 100644 --- a/core/frontend/src/lib/chat-helpers.test.ts +++ b/core/frontend/src/lib/chat-helpers.test.ts @@ -309,12 +309,44 @@ describe("sseEventToChatMessage", () => { expect(result!.id).toMatch(/^stream-t-\d+-chat$/); }); - it("returns null for client_input_requested (handled in workspace.tsx)", () => { + it("converts single client_input_requested question to a queen-style bubble", () => { const event = makeEvent({ type: "client_input_requested", - node_id: "chat", + node_id: "queen", execution_id: "abc", - data: { prompt: "What next?" }, + data: { + questions: [{ id: "q0", prompt: "Which folder?" }], + }, + }); + const result = sseEventToChatMessage(event, "t"); + expect(result).not.toBeNull(); + expect(result!.content).toBe("Which folder?"); + expect(result!.id).toMatch(/^ask-user-abc-/); + }); + + it("converts multi-question client_input_requested to a numbered list", () => { + const event = makeEvent({ + type: "client_input_requested", + node_id: "queen", + execution_id: "abc", + data: { + questions: [ + { id: "q0", prompt: "Which folder?" }, + { id: "q1", prompt: "Which date range?" }, + ], + }, + }); + const result = sseEventToChatMessage(event, "t"); + expect(result).not.toBeNull(); + expect(result!.content).toBe("1. Which folder?\n2. Which date range?"); + }); + + it("returns null for client_input_requested with no questions (auto-wait park)", () => { + const event = makeEvent({ + type: "client_input_requested", + node_id: "queen", + execution_id: "abc", + data: {}, }); expect(sseEventToChatMessage(event, "t")).toBeNull(); }); diff --git a/core/frontend/src/lib/chat-helpers.ts b/core/frontend/src/lib/chat-helpers.ts index 40844854..ab44b076 100644 --- a/core/frontend/src/lib/chat-helpers.ts +++ b/core/frontend/src/lib/chat-helpers.ts @@ -140,9 +140,47 @@ export function sseEventToChatMessage( }; } - case "client_input_requested": - // Handled explicitly in handleSSEEvent (workspace.tsx) for queen input widgets. - return null; + case "client_input_requested": { + // Surface the question(s) as a queen bubble in the chat history so the + // transcript records what was asked alongside the user's answer. The + // input widget at the bottom of the panel still drives the actual + // answer flow — this bubble is read-only context. + const rawQuestions = event.data?.questions; + if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) return null; + const prompts: string[] = []; + for (const q of rawQuestions) { + if (!q || typeof q !== "object") continue; + const qo = q as Record; + const prompt = + typeof qo.prompt === "string" + ? qo.prompt + : typeof qo.question === "string" + ? (qo.question as string) + : null; + if (prompt) prompts.push(prompt); + } + if (prompts.length === 0) return null; + const content = + prompts.length === 1 + ? prompts[0] + : prompts.map((p, i) => `${i + 1}. ${p}`).join("\n"); + return { + // Stable per-request id so live + replay paths upsert the same row. + id: `ask-user-${event.execution_id ?? ""}-${event.timestamp ?? createdAt}`, + agent: agentDisplayName || event.node_id || "Agent", + agentColor: "", + content, + timestamp: "", + // Default to worker; the replayEvent wrapper upgrades to "queen" + // when stream_id === "queen". Mirrors llm_text_delta's pattern. + role: "worker", + thread, + createdAt, + nodeId: event.node_id || undefined, + executionId: event.execution_id || undefined, + streamId: event.stream_id || undefined, + }; + } case "client_input_received": { const userContent = (event.data?.content as string) || ""; diff --git a/core/frontend/src/pages/queen-dm.tsx b/core/frontend/src/pages/queen-dm.tsx index 7ede3429..9474eb4b 100644 --- a/core/frontend/src/pages/queen-dm.tsx +++ b/core/frontend/src/pages/queen-dm.tsx @@ -662,6 +662,11 @@ export default function QueenDM() { queenAboutToResumeRef.current = false; break; } + // Drop the queen's question into the transcript so it lives + // alongside the user's answer when scrolling back. Synthesized + // by replayEvent above; upsert by id so cold-replay doesn't + // duplicate it. + for (const m of emittedMessages) upsertMessage(m); setAwaitingInput(true); setIsTyping(false); setIsStreaming(false); From 062a4e3166fd45ef9aad19fedab2638c20deed8a Mon Sep 17 00:00:00 2001 From: bryan Date: Tue, 28 Apr 2026 18:17:25 -0700 Subject: [PATCH 2/3] feat: new-session navigation with queen warm-up UI --- core/frontend/src/App.tsx | 2 + core/frontend/src/components/ChatPanel.tsx | 31 +++++--- core/frontend/src/pages/home.tsx | 54 ++++--------- core/frontend/src/pages/queen-dm.tsx | 20 ++--- core/frontend/src/pages/queen-routing.tsx | 92 ++++++++++++++++++++++ 5 files changed, 136 insertions(+), 63 deletions(-) create mode 100644 core/frontend/src/pages/queen-routing.tsx diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 66319e4e..7dfb7888 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -3,6 +3,7 @@ import AppLayout from "./layouts/AppLayout"; import Home from "./pages/home"; import ColonyChat from "./pages/colony-chat"; import QueenDM from "./pages/queen-dm"; +import QueenRouting from "./pages/queen-routing"; import OrgChart from "./pages/org-chart"; import PromptLibrary from "./pages/prompt-library"; import SkillsLibrary from "./pages/skills-library"; @@ -16,6 +17,7 @@ function App() { }> } /> } /> + } /> } /> } /> } /> diff --git a/core/frontend/src/components/ChatPanel.tsx b/core/frontend/src/components/ChatPanel.tsx index 53820b4f..c922c061 100644 --- a/core/frontend/src/components/ChatPanel.tsx +++ b/core/frontend/src/components/ChatPanel.tsx @@ -91,6 +91,10 @@ interface ChatPanelProps { activeThread: string; /** When true, the input is disabled (e.g. during loading) */ disabled?: boolean; + /** When true, only the send button is locked — the textarea stays typable. + * Used during new-session bootstrap so the user can compose a follow-up + * while the queen finishes warming up / streaming her first reply. */ + sendLocked?: boolean; /** When false, the image attach button is hidden (model lacks vision support) */ supportsImages?: boolean; /** Called when user clicks the stop button to cancel the queen's current turn */ @@ -916,6 +920,7 @@ export default function ChatPanel({ isBusy, activeThread, disabled, + sendLocked, onCancel, onSteer, onCancelQueued, @@ -1401,8 +1406,10 @@ export default function ChatPanel({ ); })} - {/* Show typing indicator while waiting for first queen response (disabled + empty chat) */} - {(isWaiting || (disabled && threadMessages.length === 0)) && ( + {/* Show typing indicator while waiting for first queen response + (disabled / sendLocked + empty chat counts as warm-up). */} + {(isWaiting || + ((disabled || sendLocked) && threadMessages.length === 0)) && (
(null); const textareaRef = useRef(null); const displayName = userProfile.displayName || "there"; - const startQueenSession = async (text: string) => { + // Stash the prompt and bounce to /queen-routing immediately. The classify + // LLM call (2-5s) runs on the routing screen rather than blocking nav, so + // the user never watches a spinner on the home page. + const startQueenSession = (text: string) => { const trimmed = text.trim(); - if (!trimmed || submitting) return; - setSubmitting(true); - setActivePrompt(trimmed); + if (!trimmed) return; try { - const { queen_id } = await messagesApi.classify(trimmed); - // Hand the first message to queen-dm via sessionStorage so it - // survives the navigation without leaking into the URL/history. - sessionStorage.setItem(`queenFirstMessage:${queen_id}`, trimmed); - refresh(); - navigate(`/queen/${queen_id}?new=1`); + sessionStorage.setItem(PENDING_CLASSIFY_KEY, trimmed); } catch { - // Keep the user on home if bootstrap fails. - } finally { - setSubmitting(false); - setActivePrompt(null); + // sessionStorage disabled — fall through; the routing page will + // redirect back to home when the key is missing. } + navigate("/queen-routing"); }; const handlePromptHint = (text: string) => { @@ -97,14 +90,10 @@ export default function Home() {
@@ -116,25 +105,12 @@ export default function Home() { ))}
- {submitting && activePrompt && ( -

- - The queens are debating who should take this on - - -

- )} ); diff --git a/core/frontend/src/pages/queen-dm.tsx b/core/frontend/src/pages/queen-dm.tsx index 9474eb4b..51d3bb76 100644 --- a/core/frontend/src/pages/queen-dm.tsx +++ b/core/frontend/src/pages/queen-dm.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { useParams, useSearchParams } from "react-router-dom"; -import { Loader2, Minus, Plus } from "lucide-react"; +import { Minus, Plus } from "lucide-react"; import ChatPanel, { type ChatMessage, type ImageContent, @@ -923,19 +923,6 @@ export default function QueenDM() {
{/* Chat */}
- {loading && ( -
-
- - - {selectedSessionParam?.startsWith("session_") - ? "Connecting to session..." - : `Connecting to ${queenName}...`} - -
-
- )} - (null); + // Re-runs of this effect (StrictMode, fast re-mounts) must not re-fire the + // classify call — once we've grabbed the pending message we own it. + const startedRef = useRef(false); + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + + let pending: string | null = null; + try { + pending = sessionStorage.getItem(PENDING_CLASSIFY_KEY); + if (pending) sessionStorage.removeItem(PENDING_CLASSIFY_KEY); + } catch { + pending = null; + } + + if (!pending || !pending.trim()) { + navigate("/", { replace: true }); + return; + } + + const trimmed = pending.trim(); + let cancelled = false; + (async () => { + try { + const { queen_id } = await messagesApi.classify(trimmed); + if (cancelled) return; + // Hand the prompt off to queen-dm via the same key its bootstrap + // path already consumes. Avoids leaking the message into the URL. + sessionStorage.setItem(`queenFirstMessage:${queen_id}`, trimmed); + refresh(); + navigate(`/queen/${queen_id}?new=1`, { replace: true }); + } catch { + if (cancelled) return; + setError("Couldn't route your request. Try again from the home screen."); + } + })(); + + return () => { + cancelled = true; + }; + }, [navigate, refresh]); + + return ( +
+
+ + + The queens are debating who should take this on + + +
+ {error && ( +
+

{error}

+ +
+ )} +
+ ); +} From 965264c9739b1c8ed34fbaca762519161023fe4d Mon Sep 17 00:00:00 2001 From: bryan Date: Wed, 29 Apr 2026 16:31:19 -0700 Subject: [PATCH 3/3] fix: defer ask_user question bubble until user answers --- core/frontend/src/pages/colony-chat.tsx | 31 +++++++++++++++++ core/frontend/src/pages/queen-dm.tsx | 45 +++++++++++++++---------- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/core/frontend/src/pages/colony-chat.tsx b/core/frontend/src/pages/colony-chat.tsx index 50ac04b2..fbca1bbc 100644 --- a/core/frontend/src/pages/colony-chat.tsx +++ b/core/frontend/src/pages/colony-chat.tsx @@ -326,6 +326,11 @@ export default function ColonyChat() { // client_input_requested so we don't flicker the typing bubble off while // the queen is about to resume on the flushed input. const queenAboutToResumeRef = useRef(false); + // Question bubble for an ask_user that's actively awaiting an answer. + // Stashed instead of pushed into messages so the user only sees ONE copy + // of the question (the popup widget) while answering. Committed to the + // transcript on client_input_received so it lands above the user's reply. + const pendingAskUserBubbleRef = useRef(null); const suppressIntroRef = useRef(false); const loadingRef = useRef(false); @@ -710,8 +715,34 @@ export default function ColonyChat() { case "client_input_received": case "client_input_requested": case "llm_text_delta": { + // Defer the queen's ask_user bubble so it doesn't render alongside + // the popup widget. Stash on request, commit on receive — see + // pendingAskUserBubbleRef declaration above for rationale. + let stashedAskUserBubble: ChatMessage | null = null; + if ( + event.type === "client_input_requested" && + isQueen && + emittedMessages.length > 0 + ) { + const rawQuestions = event.data?.questions; + if (Array.isArray(rawQuestions) && rawQuestions.length > 0) { + stashedAskUserBubble = emittedMessages[0]; + pendingAskUserBubbleRef.current = stashedAskUserBubble; + } + } + if ( + event.type === "client_input_received" && + pendingAskUserBubbleRef.current && + !suppressQueenMessages + ) { + // Commit the stashed bubble first; createdAt predates this + // event so timestamp-ordered insert places it above the answer. + upsertMessage(pendingAskUserBubbleRef.current); + pendingAskUserBubbleRef.current = null; + } if (!suppressQueenMessages) { for (const msg of emittedMessages) { + if (msg === stashedAskUserBubble) continue; if (isQueen) { msg.phase = queenPhaseRef.current as ChatMessage["phase"]; } diff --git a/core/frontend/src/pages/queen-dm.tsx b/core/frontend/src/pages/queen-dm.tsx index 51d3bb76..4f68db3e 100644 --- a/core/frontend/src/pages/queen-dm.tsx +++ b/core/frontend/src/pages/queen-dm.tsx @@ -117,6 +117,12 @@ export default function QueenDM() { // client_input_requested so we don't flicker the typing bubble off while // the queen is about to resume on the flushed input. const queenAboutToResumeRef = useRef(false); + // Question bubble for an ask_user that's actively awaiting an answer. We + // stash it here instead of pushing it into messages so the user only sees + // ONE copy of the question (the popup widget) while answering. Committed + // to the transcript on client_input_received so the bubble lands right + // above the user's answer for scroll-back context. + const pendingAskUserBubbleRef = useRef(null); const [queenPhase, setQueenPhase] = useState< "independent" | "incubating" | "working" | "reviewing" >("independent"); @@ -541,19 +547,11 @@ export default function QueenDM() { const handleCreateNewSession = useCallback(() => { if (!queenId) return; - setCreatingNewSession(true); - const request = queensApi.createNewSession( - queenId, - undefined, - "independent", - ); - request - .then((result) => { - setSearchParams({ session: result.session_id }); - }) - .catch(() => { - setCreatingNewSession(false); - }); + // Bounce through the ?new=1 bootstrap path so the chat shell appears + // immediately with a typing indicator while createNewSession runs in + // the background. URL is replaced with ?session= when it resolves. + // Avoids the 5s "nothing happens, then chat appears" dead window. + setSearchParams({ new: "1" }); }, [queenId, setSearchParams]); useEffect(() => { @@ -662,11 +660,14 @@ export default function QueenDM() { queenAboutToResumeRef.current = false; break; } - // Drop the queen's question into the transcript so it lives - // alongside the user's answer when scrolling back. Synthesized - // by replayEvent above; upsert by id so cold-replay doesn't - // duplicate it. - for (const m of emittedMessages) upsertMessage(m); + // Stash the question bubble (synthesized by replayEvent) instead + // of upserting now: while the popup widget is open the user only + // wants to see ONE copy of the question. We commit the bubble on + // client_input_received so it lands right above the user's + // answer in the transcript. + if (emittedMessages.length > 0) { + pendingAskUserBubbleRef.current = emittedMessages[0]; + } setAwaitingInput(true); setIsTyping(false); setIsStreaming(false); @@ -675,6 +676,14 @@ export default function QueenDM() { } case "client_input_received": { + // Commit the stashed ask_user bubble first so it appears above + // the user's reply in scroll-back. Its createdAt predates this + // event's, so the timestamp-ordered insert in upsertMessage + // places it correctly. + if (pendingAskUserBubbleRef.current) { + upsertMessage(pendingAskUserBubbleRef.current); + pendingAskUserBubbleRef.current = null; + } for (const msg of emittedMessages) { upsertMessage(msg, { reconcileOptimisticUser: true }); }