feat: message queue upgrade, full payload snapshot, StreamStatusBar queue count, config badge
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Has been cancelled
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Has been cancelled

- Replace single pendingSubmitRef with pendingQueueRef array supporting multiple queued messages
- Add SubmitPayload interface to snapshot text/file/tools/modelMode at submission time
- Rewrite handleSubmit/doSubmit to use payload snapshots instead of closures over mutable state
- Rewrite handleConcurrentQueue with polling loop that drains the queue sequentially
- Rewrite handleConcurrentInterrupt and handleConcurrentCancel for queue awareness
- StreamStatusBar queued state now shows count (N 条消息已排队) and inline cancel button
- Add key={concurrentStatus} to StreamStatusBar div for fade-in animation on status change
- Config panel summary button shows numeric badge instead of dot when tools are active

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-12 15:28:16 +08:00
co-authored by Claude Sonnet 4.6
parent 964e0dd305
commit 4895ff5465
+114 -41
View File
@@ -134,9 +134,20 @@ function App() {
// ── Concurrent submit state ───────────────────────────────────────────────
type ConcurrentStatus = "idle" | "generating" | "queued" | "stopping" | "auto-sending";
// 完整 payload 快照类型
interface SubmitPayload {
text: string;
attachedFile: { name: string; mimeType: string; base64: string; size: number } | null;
activeTools: Set<ToolKey>;
modelMode: ModelMode;
}
const [showConcurrentDialog, setShowConcurrentDialog] = useState(false);
const [concurrentStatus, setConcurrentStatus] = useState<ConcurrentStatus>("idle");
const pendingSubmitRef = useRef<(() => void) | null>(null);
const pendingQueueRef = useRef<Array<() => void>>([]);
const isPollingRef = useRef(false);
const [pendingQueueCount, setPendingQueueCount] = useState(0);
const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({
apiUrl: LANGGRAPH_URL,
@@ -179,6 +190,14 @@ function App() {
return () => window.removeEventListener("soc:retry-tool", handler);
}, []);
// Cleanup queue on unmount
useEffect(() => {
return () => {
pendingQueueRef.current = [];
isPollingRef.current = false;
};
}, []);
// Listen for prefill-input events from ActionBar
useEffect(() => {
const handler = (e: Event) => {
@@ -316,55 +335,67 @@ function App() {
const text = input.trim();
if (!text && !attachedFile) return;
// If AI is still generating, show concurrent choice dialog
if (thread.isLoading) {
pendingSubmitRef.current = () => doSubmit(text);
// 立即 snapshot
const snapshot: SubmitPayload = {
text,
attachedFile: attachedFile
? { name: attachedFile.name, mimeType: attachedFile.mimeType, base64: attachedFile.base64, size: attachedFile.size }
: null,
activeTools: new Set(activeTools),
modelMode,
};
setInput("");
setAttachedFile(null);
pendingQueueRef.current.push(() => doSubmit(snapshot));
setPendingQueueCount(pendingQueueRef.current.length);
setShowConcurrentDialog(true);
return;
}
const payload: SubmitPayload = {
text,
attachedFile: attachedFile
? { name: attachedFile.name, mimeType: attachedFile.mimeType, base64: attachedFile.base64, size: attachedFile.size }
: null,
activeTools: new Set(activeTools),
modelMode,
};
setConcurrentStatus("generating");
doSubmit(text);
doSubmit(payload);
}
async function doSubmit(text: string) {
async function doSubmit(payload: SubmitPayload) {
const { text, attachedFile: file, activeTools: tools, modelMode: mode } = payload;
setInput("");
setSourceLabel(null);
// Clear draft and update lastActive for this thread
if (currentThreadId) {
try { localStorage.removeItem(`draft_${currentThreadId}`); } catch { /* ignore */ }
updateThreadLastActive(currentThreadId);
}
const enabledTools =
activeTools.size > 0
? TOOL_GROUPS.filter((g) => activeTools.has(g.key)).flatMap((g) => [...g.tools])
tools.size > 0
? TOOL_GROUPS.filter((g) => tools.has(g.key)).flatMap((g) => [...g.tools])
: [];
const file = attachedFile;
setAttachedFile(null);
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let messageContent: any;
if (file && IMAGE_TYPES.includes(file.mimeType)) {
messageContent = [
{ type: "image_url", image_url: { url: `data:${file.mimeType};base64,${file.base64}` } },
{ type: "text", text: text || "请分析这张图片" },
];
} else if (file) {
messageContent = [
{ type: "text", text: `[附件: ${file.name}]\n\n${text || "请分析这个文件"}` },
];
messageContent = [{ type: "text", text: `[附件: ${file.name}]\n\n${text || "请分析这个文件"}` }];
} else {
messageContent = text;
}
// Auto-name thread on first message
const isFirstMessage = activeMessages.length === 0;
// Consume pending retry tool hint (set by soc:retry-tool event)
const retryTool = pendingRetryToolRef.current;
pendingRetryToolRef.current = null;
@@ -374,7 +405,7 @@ function App() {
config: {
configurable: {
enabledTools,
modelMode,
modelMode: mode,
...(retryTool ? { retryTool } : {}),
...(file ? { attachedFile: { name: file.name, mimeType: file.mimeType, base64: file.base64, size: file.size } } : {}),
},
@@ -385,9 +416,7 @@ function App() {
if (isFirstMessage && currentThreadId) {
const titleText = text.slice(0, 20);
client.threads.update(currentThreadId, { metadata: { title: titleText } }).catch(() => {});
// Persist title to localStorage for instant display
try { localStorage.setItem(`title_${currentThreadId}`, titleText); } catch { /* ignore */ }
// Optimistically update local thread title
setThreads((prev) =>
prev.map((t) =>
t.thread_id === currentThreadId
@@ -403,26 +432,39 @@ function App() {
setShowConcurrentDialog(false);
setConcurrentStatus("stopping");
thread.stop();
const submit = pendingSubmitRef.current;
pendingSubmitRef.current = null;
setTimeout(() => {
setConcurrentStatus("generating");
submit?.();
const next = pendingQueueRef.current.shift();
if (next) {
setPendingQueueCount(pendingQueueRef.current.length);
setConcurrentStatus("generating");
next();
} else {
setConcurrentStatus("idle");
}
}, 300);
}
function handleConcurrentQueue() {
setShowConcurrentDialog(false);
setConcurrentStatus("queued");
const submit = pendingSubmitRef.current;
pendingSubmitRef.current = null;
if (isPollingRef.current) return;
isPollingRef.current = true;
const poll = () => {
if (!thread.isLoading) {
setConcurrentStatus("auto-sending");
submit?.();
} else {
setTimeout(poll, 500);
if (pendingQueueRef.current.length === 0) {
isPollingRef.current = false;
setPendingQueueCount(0);
setConcurrentStatus("idle");
return;
}
if (thread.isLoading) {
setTimeout(poll, 500);
return;
}
const next = pendingQueueRef.current.shift()!;
setPendingQueueCount(pendingQueueRef.current.length);
setConcurrentStatus("auto-sending");
next();
setTimeout(poll, 500);
};
setTimeout(poll, 500);
}
@@ -430,7 +472,9 @@ function App() {
function handleConcurrentCancel() {
setShowConcurrentDialog(false);
setConcurrentStatus(thread.isLoading ? "generating" : "idle");
pendingSubmitRef.current = null;
pendingQueueRef.current = [];
setPendingQueueCount(0);
isPollingRef.current = false;
}
// ── Regenerate last AI message ───────────────────────────────────────────
@@ -881,7 +925,11 @@ function App() {
>
<ChevronDown className={cn("size-3.5 transition-transform duration-200", configOpen && "rotate-180")} />
<span>{summary}</span>
{activeTools.size > 0 && <span className="size-1.5 rounded-full bg-primary" />}
{activeTools.size > 0 && (
<span className="inline-flex items-center justify-center size-4 rounded-full bg-primary text-primary-foreground text-[10px] font-medium leading-none">
{activeTools.size}
</span>
)}
</button>
{/* Expanded panel — opens upward */}
@@ -961,8 +1009,8 @@ function App() {
{/* Stream status bar */}
{concurrentStatus !== "idle" && (
<div className="max-w-3xl mx-auto w-full px-5 mb-1">
<div className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs",
<div key={concurrentStatus} className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs animate-in fade-in duration-200",
concurrentStatus === "generating" && "bg-blue-50/80 dark:bg-blue-950/30 text-blue-600 dark:text-blue-400",
concurrentStatus === "queued" && "bg-amber-50/80 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400",
concurrentStatus === "stopping" && "bg-red-50/80 dark:bg-red-950/30 text-red-600 dark:text-red-400",
@@ -971,12 +1019,33 @@ function App() {
{(concurrentStatus === "generating" || concurrentStatus === "auto-sending") && <Loader2 className="size-3 animate-spin shrink-0" />}
{concurrentStatus === "queued" && <Clock className="size-3 shrink-0" />}
{concurrentStatus === "stopping" && <Square className="size-3 shrink-0 fill-current" />}
<span>
{concurrentStatus === "generating" && "正在生成回复..."}
{concurrentStatus === "queued" && "下一条消息已排队,等待当前回复完成"}
{concurrentStatus === "stopping" && "正在停止上一轮..."}
{concurrentStatus === "auto-sending" && "正在自动发送下一条..."}
</span>
{concurrentStatus === "queued" ? (
<>
<span>
{pendingQueueCount > 1
? `${pendingQueueCount} 条消息已排队,等待当前回复完成`
: "下一条消息已排队,等待当前回复完成"}
</span>
<button
type="button"
onClick={() => {
pendingQueueRef.current = [];
setPendingQueueCount(0);
isPollingRef.current = false;
setConcurrentStatus(thread.isLoading ? "generating" : "idle");
}}
className="ml-auto shrink-0 text-[11px] underline underline-offset-2 opacity-70 hover:opacity-100 transition-opacity"
>
取消排队
</button>
</>
) : (
<span>
{concurrentStatus === "generating" && "正在生成回复..."}
{concurrentStatus === "stopping" && "正在停止上一轮..."}
{concurrentStatus === "auto-sending" && "正在自动发送下一条..."}
</span>
)}
</div>
</div>
)}
@@ -1068,7 +1137,11 @@ function App() {
<div className="bg-background border border-border rounded-2xl shadow-xl p-6 w-80 flex flex-col gap-4">
<div className="flex flex-col gap-1">
<p className="font-semibold text-sm">AI 正在处理上一条消息</p>
<p className="text-xs text-muted-foreground">请选择如何处理新消息:</p>
<p className="text-xs text-muted-foreground">
{pendingQueueCount > 0
? `队列中还有 ${pendingQueueCount} 条待发送`
: "请选择如何处理新消息:"}
</p>
</div>
<div className="flex flex-col gap-2">
<button