diff --git a/chat-agent-run-gate.mjs b/chat-agent-run-gate.mjs
index 7e3705a..db33a32 100644
--- a/chat-agent-run-gate.mjs
+++ b/chat-agent-run-gate.mjs
@@ -72,6 +72,49 @@ export function shouldScheduleMissingActiveRequestGrace({
return allowMissingGrace && !agentRunPending;
}
+export const QUEUED_CHAT_SUBMIT_NOTICE =
+ '已收到你的消息,将在当前任务完成后自动继续执行。';
+
+/**
+ * @param {string | undefined | null} chatState
+ * @returns {boolean}
+ */
+export function isChatSubmitBusy(chatState) {
+ return (
+ chatState === 'streaming' ||
+ chatState === 'loading' ||
+ chatState === 'connecting' ||
+ chatState === 'waiting'
+ );
+}
+
+/**
+ * @param {number | undefined | null} queueLength
+ * @returns {string}
+ */
+export function buildQueuedChatSubmitNotice(queueLength = 1) {
+ const length = Math.max(1, Number(queueLength) || 1);
+ if (length <= 1) return QUEUED_CHAT_SUBMIT_NOTICE;
+ return `已收到你的消息,当前还有 ${length} 条待执行,将在任务完成后按顺序继续。`;
+}
+
+/**
+ * @param {{ chatState?: string; agentRunPending?: boolean; pendingTool?: boolean; queueLength?: number }} input
+ * @returns {boolean}
+ */
+export function canFlushQueuedChatSubmit({
+ chatState = 'idle',
+ agentRunPending = false,
+ pendingTool = false,
+ queueLength = 0,
+} = {}) {
+ if (!queueLength || queueLength <= 0) return false;
+ if (isChatSubmitBusy(chatState)) return false;
+ if (agentRunPending) return false;
+ if (pendingTool) return false;
+ return true;
+}
+
/**
* Only transport uncertainty may continue a run after submit fails. A
* deterministic gateway error already has a terminal outcome and must return
diff --git a/chat-agent-run-gate.test.mjs b/chat-agent-run-gate.test.mjs
index df43ee5..1fe0b78 100644
--- a/chat-agent-run-gate.test.mjs
+++ b/chat-agent-run-gate.test.mjs
@@ -1,6 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
+ buildQueuedChatSubmitNotice,
+ canFlushQueuedChatSubmit,
+ isChatSubmitBusy,
reconcileSessionEventRequestContext,
resolvePostAgentRunChatState,
shouldIgnoreZeroActivityFinish,
@@ -145,6 +148,52 @@ test('missing ActiveRequests cannot unlock while the Portal agent-run is pending
);
});
+test('isChatSubmitBusy covers active composer states', () => {
+ assert.equal(isChatSubmitBusy('idle'), false);
+ assert.equal(isChatSubmitBusy('error'), false);
+ assert.equal(isChatSubmitBusy('waiting'), true);
+ assert.equal(isChatSubmitBusy('streaming'), true);
+ assert.equal(isChatSubmitBusy('connecting'), true);
+});
+
+test('buildQueuedChatSubmitNotice reflects queue depth', () => {
+ assert.match(buildQueuedChatSubmitNotice(1), /当前任务完成后/);
+ assert.match(buildQueuedChatSubmitNotice(2), /2 条待执行/);
+});
+
+test('canFlushQueuedChatSubmit waits for idle composer without pending tool or run', () => {
+ assert.equal(
+ canFlushQueuedChatSubmit({
+ chatState: 'idle',
+ queueLength: 1,
+ }),
+ true,
+ );
+ assert.equal(
+ canFlushQueuedChatSubmit({
+ chatState: 'waiting',
+ queueLength: 1,
+ }),
+ false,
+ );
+ assert.equal(
+ canFlushQueuedChatSubmit({
+ chatState: 'idle',
+ agentRunPending: true,
+ queueLength: 1,
+ }),
+ false,
+ );
+ assert.equal(
+ canFlushQueuedChatSubmit({
+ chatState: 'idle',
+ pendingTool: true,
+ queueLength: 1,
+ }),
+ false,
+ );
+});
+
test('reconcileSessionEventRequestContext adopts Goose request id while agent-run gate waits', () => {
assert.deepEqual(
reconcileSessionEventRequestContext({
diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx
index 6ce0a81..ed7903a 100644
--- a/src/components/ChatPanel.tsx
+++ b/src/components/ChatPanel.tsx
@@ -492,6 +492,7 @@ export function ChatPanel({
chatState === 'connecting' ||
chatState === 'waiting';
const offlineBlocked = !online;
+ const inputBlocked = !!pendingTool || chatState === 'error' || offlineBlocked;
const publishSkillName = user?.publishSkillName ?? 'static-page-publish';
const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false;
const hasPageDataCollectSkill = grantedSkills?.includes('page-data-collect') ?? false;
@@ -525,9 +526,9 @@ export function ChatPanel({
? '上传中…'
: chatState === 'connecting'
? '连接中…'
- : chatState === 'waiting'
- ? '提交中…'
- : null;
+ : busy && canSubmit
+ ? '排队发送'
+ : null;
const applyTemplatePrefill = useCallback((prompt: string, skillId: string, label: string) => {
pendingSkillRef.current = skillId;
@@ -577,7 +578,7 @@ export function ChatPanel({
setVoiceNotice('已识别,可编辑后发送');
};
- const voiceDisabled = busy || !!pendingTool || chatState === 'error' || offlineBlocked;
+ const voiceDisabled = inputBlocked || uploadingImage || uploadingFile;
const canSubmit = input.trim() || pendingImages.length > 0 || pendingFiles.length > 0;
const uploadDisabled = !canUpload || busy || uploadingImage || uploadingFile || offlineBlocked || !!pendingTool;
@@ -719,7 +720,7 @@ export function ChatPanel({
skillIdOverride ?? pendingSkillRef.current ?? templateSelection?.skillId ?? undefined;
pendingSkillRef.current = null;
setActiveTemplatePrefill(null);
- if ((!trimmed && pendingImages.length === 0 && pendingFiles.length === 0) || voiceDisabled) return;
+ if ((!trimmed && pendingImages.length === 0 && pendingFiles.length === 0) || inputBlocked) return;
if (pendingImages.length > 0 && !onUploadImage) {
setImageError('当前会话暂不支持图片发送');
return;
@@ -1462,16 +1463,15 @@ export function ChatPanel({
- ) : (
-
- )}
+ ) : null}
+
{compact && onClose && (