From 9153f36f0bcaea170ba7c92e16fd85b93a2862d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87a=C4=9Fda=C5=9F=20Y=C3=BCrekli?= <25122236+cagdasyurekli@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:39:16 +0200 Subject: [PATCH 1/2] feat(composer): queue follow-up messages while task is running (#608) - Define QueuedMessage in types.ts - Allow composing and queueing follow-ups via button or Enter while running - Display queued messages strip above composer with count and remove action - Automatically drain queue and send next message sequentially when task finishes - Add i18n translations in English and Chinese --- surfaces/gui/src/App.tsx | 41 +++++++++++ surfaces/gui/src/components/Composer.tsx | 86 ++++++++++++++++++++++-- surfaces/gui/src/locales/en.json | 6 +- surfaces/gui/src/locales/zh.json | 6 +- surfaces/gui/src/types.ts | 10 +++ 5 files changed, 142 insertions(+), 7 deletions(-) diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 67cff675c..09d9c72a5 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -44,6 +44,7 @@ import type { ApprovalDecision, Attachment, Item, + QueuedMessage, SessionInfo, SessionUsage, TodoItem, @@ -249,6 +250,9 @@ export function App() { const [sessions, setSessions] = useState([]); const [projects, setProjects] = useState([]); const [sessionId, setSessionId] = useState(newId()); + // Follow-up messages queued while a task is running (#608) + const [queuedMessages, setQueuedMessages] = useState([]); + const isDrainingQueueRef = useRef(false); // Automation-run context (§ owner ask 2026-07-04): which task an open __run__ session belongs // to, driving the banner + "Back to runs". Best-effort — a run session without context still // shows a generic banner (detected by its __run__ id). @@ -1133,6 +1137,40 @@ export function App() { sessionRef.current?.userMessage(text, attachments, model, skill); followLatest(); // sending always re-engages stream-following, wherever the user had scrolled }; + + const handleQueue = (text: string, attachments?: Attachment[], skill?: string) => { + if (!text.trim() && (!attachments || attachments.length === 0) && !skill) return; + const newQueued: QueuedMessage = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + sessionId, + text, + attachments, + skill, + createdAt: Date.now(), + }; + setQueuedMessages((prev) => [...prev, newQueued]); + }; + + const handleRemoveQueued = (id: string) => { + setQueuedMessages((prev) => prev.filter((m) => m.id !== id)); + }; + + useEffect(() => { + if (running) { + isDrainingQueueRef.current = false; + return; + } + if (!connected || isDrainingQueueRef.current) return; + const sessionQueue = queuedMessages.filter((m) => m.sessionId === sessionId); + if (sessionQueue.length === 0) return; + + isDrainingQueueRef.current = true; + const next = sessionQueue[0]; + setQueuedMessages((prev) => prev.filter((m) => m.id !== next.id)); + setRunning(true); + send(next.text, next.attachments, next.skill); + }, [running, connected, sessionId, queuedMessages]); + // Resolving a LIVE prompt also resolves its parked Inbox mirror server-side, but the polled // `sessionInbox` copy stays "pending" for up to a poll cycle — long enough for the docked // answer-in-context card to flash the SAME request again right after the user answered it @@ -2090,6 +2128,9 @@ export function App() { onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined} prefill={composerPrefill} resetKey={sessionId} + queuedItems={queuedMessages.filter((m) => m.sessionId === sessionId)} + onQueue={handleQueue} + onRemoveQueued={handleRemoveQueued} usage={usage} contextWindow={modelContextWindows[model]} contextBar={contextBar} diff --git a/surfaces/gui/src/components/Composer.tsx b/surfaces/gui/src/components/Composer.tsx index 5e4b0e1bf..b662b5a15 100644 --- a/surfaces/gui/src/components/Composer.tsx +++ b/surfaces/gui/src/components/Composer.tsx @@ -1,6 +1,6 @@ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { getI18n, useTranslation } from "react-i18next"; -import type { Attachment, SessionUsage } from "../types"; +import type { Attachment, QueuedMessage, SessionUsage } from "../types"; import { isPdfFile, readFile } from "../attach"; import { ProjectBindMenu } from "./ProjectBindMenu"; import { getSettings, inspectPdf, sessionSkills, type SessionSkillRow } from "../api"; @@ -133,6 +133,10 @@ interface Props { // §8.4 breaker tripped this turn: the mode chip says so quietly until the turn ends // or an ask_user answer resets the streak. reviewerPaused?: boolean; + // Follow-up messages queued while a task is running (#608) + queuedItems?: QueuedMessage[]; + onQueue?: (text: string, attachments?: Attachment[], skill?: string) => void; + onRemoveQueued?: (id: string) => void; } export function Composer(props: Props) { @@ -387,11 +391,20 @@ export function Composer(props: Props) { const body = (skill ? text.slice(skill.length + 1) : text).trim(); if ( (!body && attachments.length === 0 && !skill) || - (props.running && !props.gateOpen) || dictation?.recording || dictationBusy ) return; + // Follow-up queued while a task is running (#608) + if (props.running && !props.gateOpen) { + if (props.onQueue) { + props.onQueue(body, attachments, skill); + setText(""); + setAttachments([]); + setPendingSkill(null); + } + return; + } // No model connected: keep the draft (don't drop it) and send the user to setup instead. if (needsModel) { props.onConnectModel?.(); @@ -532,6 +545,54 @@ export function Composer(props: Props) { )} + {/* Queued follow-up messages strip (#608) */} + {props.queuedItems && props.queuedItems.length > 0 && ( +
+
+ + + + {t("composer.queued_count", { + count: props.queuedItems.length, + defaultValue: `${props.queuedItems.length} queued`, + })} + + +
+
+ {props.queuedItems.map((item, idx) => ( +
+ #{idx + 1} + {item.skill && ( + /{item.skill} + )} + + {item.text || (item.attachments?.length ? `[${item.attachments.length} files]` : "")} + + {props.onRemoveQueued && ( + + )} +
+ ))} +
+
+ )} +
- {t("composer.stop")} - +
+ {hasContent && props.onQueue && ( + + )} + +
) : (