From 252a7577b7de9a43e4be473b3ceef959c6890c2c Mon Sep 17 00:00:00 2001 From: coder-hhx <51896858+coder-hhx@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:49:54 +0800 Subject: [PATCH 1/2] feat(chat): add queued turns to composer --- .../test/webui/chat-turn-queue.test.mjs | 124 ++++++ .../agent-gateway/web/src/app/GatewayApp.tsx | 335 +++++++++++++- crates/agent-gateway/web/src/i18n/config.ts | 20 + .../web/src/pages/chat/ChatComposerBar.tsx | 205 +++++++-- .../web/src/pages/chat/queue/chatTurnQueue.ts | 189 ++++++++ crates/agent-gui/src/i18n/config.ts | 20 + crates/agent-gui/src/pages/ChatPage.tsx | 415 ++++++++++++++++-- .../pages/chat/components/ChatComposerBar.tsx | 242 ++++++++-- .../pages/chat/gateway/gatewayBridgeTypes.ts | 5 +- crates/agent-gui/src/pages/chat/index.ts | 2 +- .../src/pages/chat/queue/chatTurnQueue.ts | 193 ++++++++ .../pages/chat/transcript/ChatTranscript.tsx | 6 +- .../pages/chat/transcript/transcriptTypes.ts | 1 + .../test/chat/chat-turn-queue.test.mjs | 144 ++++++ 14 files changed, 1776 insertions(+), 125 deletions(-) create mode 100644 crates/agent-gateway/test/webui/chat-turn-queue.test.mjs create mode 100644 crates/agent-gateway/web/src/pages/chat/queue/chatTurnQueue.ts create mode 100644 crates/agent-gui/src/pages/chat/queue/chatTurnQueue.ts create mode 100644 crates/agent-gui/test/chat/chat-turn-queue.test.mjs diff --git a/crates/agent-gateway/test/webui/chat-turn-queue.test.mjs b/crates/agent-gateway/test/webui/chat-turn-queue.test.mjs new file mode 100644 index 00000000..da612f6a --- /dev/null +++ b/crates/agent-gateway/test/webui/chat-turn-queue.test.mjs @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader(); +const queue = loader.loadModule("src/pages/chat/queue/chatTurnQueue.ts"); + +function draft(text, segments = [{ type: "text", text }]) { + return { + segments, + text, + textWithoutLargePastes: text, + largePastes: [], + skillMentions: [], + commitMentions: [], + gitFileMentions: [], + isEmpty: text.trim() === "", + }; +} + +function turn(id, conversationId, text) { + return queue.createQueuedChatTurn({ + id, + conversationId, + draft: draft(text), + uploadedFiles: [], + workdir: "/workspace", + runtimeControls: { + thinkingEnabled: false, + reasoning: "off", + nativeWebSearchEnabled: false, + }, + createdAt: 1, + }); +} + +test("gateway web queued chat turns append, promote, remove, and take the next turn", () => { + const first = turn("a1", "conversation-a", "first"); + const second = turn("a2", "conversation-a", "second"); + + const appended = queue.appendQueuedChatTurn(queue.appendQueuedChatTurn([], first), second); + assert.deepEqual( + appended.map((item) => item.id), + ["a1", "a2"], + ); + + const promoted = queue.promoteQueuedChatTurn(appended, "a2"); + assert.deepEqual( + promoted.map((item) => item.id), + ["a2", "a1"], + ); + + const taken = queue.takeNextQueuedChatTurn(promoted, "conversation-a"); + assert.equal(taken.item.id, "a2"); + assert.deepEqual( + taken.queue.map((item) => item.id), + ["a1"], + ); + + assert.deepEqual(queue.removeQueuedChatTurn(taken.queue, "a1"), []); +}); + +test("gateway web queued chat turn movement stays scoped to the same conversation", () => { + const mixed = [ + turn("a1", "conversation-a", "a one"), + turn("b1", "conversation-b", "b one"), + turn("a2", "conversation-a", "a two"), + ]; + + const moved = queue.moveQueuedChatTurn(mixed, "a2", "up"); + assert.deepEqual( + moved.map((item) => item.id), + ["a2", "b1", "a1"], + ); +}); + +test("gateway web edited queued chat turns return to their original priority slot", () => { + const first = turn("a1", "conversation-a", "first"); + const third = turn("a3", "conversation-a", "third"); + const editedSecond = turn("a2", "conversation-a", "edited second"); + + const reinserted = queue.insertQueuedChatTurnAtSlot([first, third], editedSecond, { + conversationId: "conversation-a", + previousId: "a1", + nextId: "a3", + index: 1, + }); + + assert.deepEqual( + reinserted.map((item) => item.id), + ["a1", "a2", "a3"], + ); + assert.equal(reinserted[1].draft.text, "edited second"); +}); + +test("gateway web queued chat turn preview keeps structured draft hints compact", () => { + const richDraft = draft("hello long paste", [ + { type: "text", text: "hello " }, + { + type: "largePaste", + paste: { + id: "paste-1", + label: "pasted.txt", + text: "large paste body", + charCount: 16, + lineCount: 1, + preview: "large paste body", + }, + }, + { + type: "skillMention", + skill: { + name: "reviewer", + description: "", + skillFile: "SKILL.md", + baseDir: "/skills/reviewer", + }, + }, + ]); + + assert.equal(queue.buildQueuedChatTurnPreview(richDraft), "hello pasted.txt$reviewer"); + assert.equal(queue.queuedChatTurnHasContent(richDraft, []), true); + assert.equal(queue.queuedChatTurnHasContent(draft(""), [{ fileName: "a.txt" }]), true); +}); diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 79fd2b71..80be43aa 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -30,12 +30,28 @@ import type { import { ChatHistorySidebar } from "@/components/chat/ChatHistorySidebar"; import { SharedHistoryManagerModal } from "@/components/chat/SharedHistoryManagerModal"; import { useConfirmDialog } from "@/components/ui/confirm-dialog"; -import { ChatComposerBar } from "@/pages/chat/ChatComposerBar"; +import { ChatComposerBar, type ChatQueueTurnPreview } from "@/pages/chat/ChatComposerBar"; import { ChatHeader } from "@/pages/chat/ChatHeader"; import { SkillsHubPage } from "@/pages/skills-hub/SkillsHubPage"; import { McpHubPage } from "@/pages/mcp-hub/McpHubPage"; import type { SectionId } from "@/pages/settings/types"; import { useChatSkills } from "@/pages/chat/useChatSkills"; +import { + appendQueuedChatTurn, + buildQueuedChatTurnPreview, + createQueuedChatTurn, + getQueuedConversationIds, + insertQueuedChatTurnAtSlot, + moveQueuedChatTurn, + promoteQueuedChatTurn, + queuedChatTurnHasContent, + removeQueuedChatTurn, + removeQueuedChatTurnsForConversation, + resolveQueuedChatTurnSlotIndex, + takeNextQueuedChatTurn, + type QueuedChatTurn, + type QueuedChatTurnEditSlot, +} from "@/pages/chat/queue/chatTurnQueue"; import { mergeAlwaysEnabledSkillNames } from "@/lib/skills"; import { buildModelOptions, sortHistoryItems, VIBING_STATUS } from "@/lib/chat/chatPageHelpers"; import { SettingsPage } from "@/pages/SettingsPage"; @@ -244,6 +260,7 @@ export default function GatewayApp() { const [localRunningConversationIds, setLocalRunningConversationIds] = useState< ReadonlySet >(() => new Set()); + const [queuedChatTurns, setQueuedChatTurns] = useState([]); const [remoteRunningConversationIds, setRemoteRunningConversationIds] = useState< ReadonlySet >(() => new Set()); @@ -355,6 +372,18 @@ export default function GatewayApp() { const chatErrorRef = useRef(chatError); const chatToolStatusRef = useRef(chatToolStatus); const chatToolStatusIsCompactionRef = useRef(chatToolStatusIsCompaction); + const queuedChatTurnsRef = useRef([]); + const queuedChatProcessingConversationIdsRef = useRef(new Set()); + const queuedChatTurnEditSlotRef = useRef< + | (QueuedChatTurnEditSlot & { + originalId: string; + createdAt: number; + workdir: string; + runtimeControls: ChatRuntimeControls; + }) + | null + >(null); + const previousRunningConversationIdsRef = useRef>(new Set()); const selectedHistoryRef = useRef(selectedHistory); const selectedHistoryEntriesRef = useRef(selectedHistoryEntries); const historyItemsRef = useRef(historyItems); @@ -442,6 +471,16 @@ export default function GatewayApp() { setChatError, }); + const setQueuedChatTurnsState = useCallback( + (updater: (current: QueuedChatTurn[]) => QueuedChatTurn[]) => { + const next = updater(queuedChatTurnsRef.current).slice(); + queuedChatTurnsRef.current = next; + setQueuedChatTurns(next); + return next; + }, + [], + ); + const recordProjectActivity = useCallback( (workdir?: string | null, updatedAt?: number | null) => { const pathKey = workspaceProjectPathKey(workdir ?? ""); @@ -3813,6 +3852,257 @@ export default function GatewayApp() { } } + function isConversationRunningForQueue(conversationId: string) { + const key = conversationId.trim(); + return Boolean( + key && + (localRunningConversationIdsRef.current.has(key) || + remoteRunningConversationIdsRef.current.has(key) || + chatStartLocksRef.current.has(key) || + getConversationAbortController(key) !== null), + ); + } + + async function materializeComposerDraftForSend( + draft: MentionComposerDraft, + files: PendingUploadedFile[], + workdir: string, + ) { + let text = ( + isAgentMode && draft.largePastes.length > 0 + ? draft.textWithoutLargePastes + : buildTextFromComposerDraft(draft) + ).trim(); + let uploadedFiles = files; + + if (isAgentMode && draft.largePastes.length > 0) { + setChatError(null); + isImportingPastedTextRef.current = true; + setIsUploadingFiles(true); + try { + const imported = await importPastedTextsAsFiles({ + token, + workdir, + pastes: draft.largePastes, + }); + text = buildTextFromComposerDraft(draft, imported.fileByPasteId).trim(); + uploadedFiles = mergePendingUploadedFiles(files, imported.files); + } finally { + isImportingPastedTextRef.current = false; + setIsUploadingFiles(false); + } + } + + return { text, uploadedFiles }; + } + + function clearCurrentComposerDraftForQueuedTurn(conversationId: string) { + const key = conversationId.trim(); + if (!key || getDisplayedConversationId() !== key) { + return; + } + composerRef.current?.clear(); + setPendingUploadsForConversation(key, []); + clearCachedComposerDraft(key); + } + + function enqueueCurrentComposerTurn(position: "end" | "front" | "edit") { + const conversationIdValue = getDisplayedConversationId(); + const draft = composerRef.current?.getDraft() ?? null; + const uploadedFiles = pendingUploadedFiles.slice(); + if (!conversationIdValue || !queuedChatTurnHasContent(draft, uploadedFiles)) { + return false; + } + + const cachedRuntime = conversationRuntimeCacheRef.current.get(conversationIdValue); + const editSlot = + position === "edit" && + queuedChatTurnEditSlotRef.current?.conversationId === conversationIdValue + ? queuedChatTurnEditSlotRef.current + : null; + const workdirForTurn = ( + editSlot?.workdir ?? + cachedRuntime?.workdir ?? + displayedConversationWorkdirRef.current ?? + activeWorkspaceProjectPath ?? + settings.system.workdir + ).trim(); + const queuedTurn = createQueuedChatTurn({ + id: editSlot?.originalId, + conversationId: conversationIdValue, + draft, + uploadedFiles, + workdir: workdirForTurn, + runtimeControls: editSlot?.runtimeControls ?? chatRuntimeControlsForCurrentProvider, + createdAt: editSlot?.createdAt, + }); + + setQueuedChatTurnsState((current) => { + if (editSlot) { + return insertQueuedChatTurnAtSlot(current, queuedTurn, editSlot); + } + return position === "front" + ? promoteQueuedChatTurn(appendQueuedChatTurn(current, queuedTurn), queuedTurn.id) + : appendQueuedChatTurn(current, queuedTurn); + }); + if (editSlot) { + queuedChatTurnEditSlotRef.current = null; + } + clearCurrentComposerDraftForQueuedTurn(conversationIdValue); + return true; + } + + function isQueuedChatTurnEditBlockingProcessing(conversationId: string) { + const slot = queuedChatTurnEditSlotRef.current; + const key = conversationId.trim(); + if (!slot || slot.conversationId !== key) return false; + const queue = queuedChatTurnsRef.current; + const firstQueuedIndex = queue.findIndex((item) => item.conversationId === slot.conversationId); + if (firstQueuedIndex < 0) return false; + return resolveQueuedChatTurnSlotIndex(queue, slot) <= firstQueuedIndex; + } + + function requestQueuedChatTurnProcessing(conversationId: string) { + const targetConversationId = conversationId.trim(); + if (!api || !targetConversationId) return; + if (queuedChatProcessingConversationIdsRef.current.has(targetConversationId)) return; + if (isConversationRunningForQueue(targetConversationId)) return; + if (isQueuedChatTurnEditBlockingProcessing(targetConversationId)) return; + if (!queuedChatTurnsRef.current.some((item) => item.conversationId === targetConversationId)) { + return; + } + + queuedChatProcessingConversationIdsRef.current.add(targetConversationId); + let inFlightQueuedTurn: QueuedChatTurn | null = null; + void Promise.resolve() + .then(async () => { + if (isConversationRunningForQueue(targetConversationId)) return false; + const taken = takeNextQueuedChatTurn(queuedChatTurnsRef.current, targetConversationId); + if (!taken.item) return false; + const queuedTurn = taken.item; + inFlightQueuedTurn = queuedTurn; + queuedChatTurnsRef.current = taken.queue; + setQueuedChatTurns(taken.queue); + const materialized = await materializeComposerDraftForSend( + queuedTurn.draft, + queuedTurn.uploadedFiles, + queuedTurn.workdir, + ); + if (!materialized.text && materialized.uploadedFiles.length === 0) { + return true; + } + await sendChat(materialized.text, { + conversationId: targetConversationId, + uploadedFiles: materialized.uploadedFiles, + runtimeControls: queuedTurn.runtimeControls, + workdir: queuedTurn.workdir, + }); + inFlightQueuedTurn = null; + return true; + }) + .then((accepted) => { + queuedChatProcessingConversationIdsRef.current.delete(targetConversationId); + if ( + accepted && + !isConversationRunningForQueue(targetConversationId) && + queuedChatTurnsRef.current.some((item) => item.conversationId === targetConversationId) + ) { + requestQueuedChatTurnProcessing(targetConversationId); + } + }) + .catch((error) => { + const failedQueuedTurn = inFlightQueuedTurn; + if (failedQueuedTurn) { + setQueuedChatTurnsState((current) => + promoteQueuedChatTurn( + appendQueuedChatTurn(current, failedQueuedTurn), + failedQueuedTurn.id, + ), + ); + } + const message = asErrorMessage(error, "queued chat request failed"); + updateConversationRuntimeEntry(targetConversationId, (current) => ({ + ...current, + error: message, + })); + queuedChatProcessingConversationIdsRef.current.delete(targetConversationId); + }); + } + + function enqueueCurrentComposerTurnAndMaybeInterrupt() { + const conversationIdValue = getDisplayedConversationId(); + if (!enqueueCurrentComposerTurn("front")) return; + if (isConversationRunningForQueue(conversationIdValue)) { + void cancelChat(conversationIdValue); + return; + } + requestQueuedChatTurnProcessing(conversationIdValue); + } + + function runQueuedTurnNow(id: string) { + const queuedTurn = queuedChatTurnsRef.current.find((item) => item.id === id.trim()); + if (!queuedTurn) return; + setQueuedChatTurnsState((current) => promoteQueuedChatTurn(current, queuedTurn.id)); + if (isConversationRunningForQueue(queuedTurn.conversationId)) { + void cancelChat(queuedTurn.conversationId); + return; + } + requestQueuedChatTurnProcessing(queuedTurn.conversationId); + } + + function moveQueuedTurn(id: string, direction: "up" | "down") { + setQueuedChatTurnsState((current) => moveQueuedChatTurn(current, id, direction)); + } + + function editQueuedTurn(id: string) { + const key = id.trim(); + const queuedTurnIndex = queuedChatTurnsRef.current.findIndex((item) => item.id === key); + const queuedTurn = queuedTurnIndex >= 0 ? queuedChatTurnsRef.current[queuedTurnIndex] : null; + if (!queuedTurn) return; + const targetConversationId = queuedTurn.conversationId.trim(); + if (!targetConversationId || getDisplayedConversationId() !== targetConversationId) { + return; + } + + const currentDraft = composerRef.current?.getDraft() ?? null; + const currentUploads = pendingUploadedFiles.slice(); + if (queuedChatTurnHasContent(currentDraft, currentUploads)) { + enqueueCurrentComposerTurn(queuedChatTurnEditSlotRef.current ? "edit" : "end"); + } + + const sameConversationQueue = queuedChatTurnsRef.current.filter( + (item) => item.conversationId === targetConversationId, + ); + const sameConversationIndex = sameConversationQueue.findIndex((item) => item.id === key); + const previousId = + sameConversationIndex > 0 + ? (sameConversationQueue[sameConversationIndex - 1]?.id ?? null) + : null; + const nextId = + sameConversationIndex >= 0 + ? (sameConversationQueue[sameConversationIndex + 1]?.id ?? null) + : null; + queuedChatTurnEditSlotRef.current = { + conversationId: targetConversationId, + previousId, + nextId, + index: sameConversationIndex >= 0 ? sameConversationIndex : undefined, + originalId: queuedTurn.id, + createdAt: queuedTurn.createdAt, + workdir: queuedTurn.workdir, + runtimeControls: { ...queuedTurn.runtimeControls }, + }; + setQueuedChatTurnsState((current) => removeQueuedChatTurn(current, key)); + composerRef.current?.setDraft(queuedTurn.draft); + setPendingUploadsForConversation(targetConversationId, queuedTurn.uploadedFiles.slice()); + clearCachedComposerDraft(targetConversationId); + window.requestAnimationFrame(() => composerRef.current?.focus()); + } + + function removeQueuedTurn(id: string) { + setQueuedChatTurnsState((current) => removeQueuedChatTurn(current, id)); + } + function startNewConversation(options?: { workdir?: string }) { const currentConversationId = conversationIdRef.current.trim(); if (currentConversationId) { @@ -4753,6 +5043,11 @@ export default function GatewayApp() { setHistoryListLoadingMore(false); setHistoryDetailLoading(false); setHistoryMutating(false); + queuedChatTurnsRef.current = []; + queuedChatProcessingConversationIdsRef.current.clear(); + queuedChatTurnEditSlotRef.current = null; + previousRunningConversationIdsRef.current = new Set(); + setQueuedChatTurns([]); setLocalRunningConversationIds(new Set()); setRemoteRunningConversationIds(new Set()); setRemoteRunningConversationRuntime(new Map()); @@ -4919,6 +5214,21 @@ export default function GatewayApp() { } return next; }, [historyItems, remoteRunningConversationRuntime, sidebarRunningConversationIds]); + + useEffect(() => { + const currentRunningConversationIds = new Set(sidebarRunningConversationIds); + const previousRunningConversationIds = previousRunningConversationIdsRef.current; + previousRunningConversationIdsRef.current = currentRunningConversationIds; + for (const conversationIdValue of getQueuedConversationIds(queuedChatTurnsRef.current)) { + if ( + previousRunningConversationIds.has(conversationIdValue) && + !currentRunningConversationIds.has(conversationIdValue) + ) { + requestQueuedChatTurnProcessing(conversationIdValue); + } + } + }, [queuedChatTurns, sidebarRunningConversationIds]); + const projectActivityUpdatedAts = useMemo(() => { const updatedAts = buildWorkspaceProjectActivityUpdatedAts([ ...historyWorkdirs, @@ -4960,6 +5270,17 @@ export default function GatewayApp() { currentConversationRuntimeWorkdir || (isAgentMode ? activeWorkspaceProjectPath || settings.system.workdir.trim() : ""); displayedConversationWorkdirRef.current = displayedConversationWorkdir; + const queuedChatTurnsForDisplayedConversation = useMemo( + () => + queuedChatTurns + .filter((item) => item.conversationId === displayedConversationId) + .map((item) => ({ + id: item.id, + previewText: buildQueuedChatTurnPreview(item.draft), + fileCount: item.uploadedFiles.length, + })), + [displayedConversationId, queuedChatTurns], + ); const terminalProjectPath = isAgentMode ? activeWorkspaceProjectPath.trim() : ""; const terminalProjectPathKey = terminalProjectPath ? workspaceProjectPathKey(terminalProjectPath) @@ -5754,14 +6075,16 @@ export default function GatewayApp() { onSend={() => { if ( submitInFlightRef.current || - chatBusyRef.current || - isObservingRemoteLiveConversation || isUploadingFiles || isImportingPastedTextRef.current || composerInputDisabled ) { return; } + if (chatBusyRef.current || isObservingRemoteLiveConversation) { + enqueueCurrentComposerTurn("end"); + return; + } submitInFlightRef.current = true; void (async () => { try { @@ -5841,6 +6164,7 @@ export default function GatewayApp() { isObservingRemoteLiveConversation ? displayedConversationId : undefined, ); }} + onInterruptAndSend={enqueueCurrentComposerTurnAndMaybeInterrupt} onPrepareChatRuntime={() => { if (!api || historyShareToken) { return; @@ -5861,6 +6185,11 @@ export default function GatewayApp() { current.filter((file) => file.relativePath !== relativePath), ); }} + queuedTurns={queuedChatTurnsForDisplayedConversation} + onRunQueuedTurnNow={runQueuedTurnNow} + onMoveQueuedTurn={moveQueuedTurn} + onEditQueuedTurn={editQueuedTurn} + onRemoveQueuedTurn={removeQueuedTurn} /> {isFileDropActive ? ( > = { "chat.compactingContextWait": "正在压缩上下文,请稍候...", "chat.editMessage": "编辑消息", "chat.cancel": "取消", + "chat.queue.title": "等待队列 {count}", + "chat.queue.addToQueue": "加入队列", + "chat.queue.interruptAndSend": "打断并发送", + "chat.queue.runNow": "打断并执行", + "chat.queue.moveUp": "上移", + "chat.queue.moveDown": "下移", + "chat.queue.edit": "编辑等待项", + "chat.queue.delete": "删除等待项", + "chat.queue.emptyMessage": "附件消息", + "chat.queue.fileCount": "{count} 个附件", "chat.composer.commitTooltipUnknownAuthor": "未知作者", "chat.composer.commitTooltipFilesChanged": "已更改 {count} 个文件", "chat.composer.commitTooltipInsertions": "{count} 行插入(+)", @@ -1535,6 +1545,16 @@ export const translations: Record> = { "chat.compactingContextWait": "Compressing context, please wait...", "chat.editMessage": "Edit Message", "chat.cancel": "Cancel", + "chat.queue.title": "Queue {count}", + "chat.queue.addToQueue": "Add to queue", + "chat.queue.interruptAndSend": "Interrupt and send", + "chat.queue.runNow": "Interrupt and run", + "chat.queue.moveUp": "Move up", + "chat.queue.moveDown": "Move down", + "chat.queue.edit": "Edit queued message", + "chat.queue.delete": "Delete queued message", + "chat.queue.emptyMessage": "Attachment message", + "chat.queue.fileCount": "{count} attachments", "chat.composer.commitTooltipUnknownAuthor": "Unknown author", "chat.composer.commitTooltipFilesChanged": "{count} files changed", "chat.composer.commitTooltipInsertions": "{count} insertions(+)", diff --git a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx index c766102e..89de75a6 100644 --- a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx +++ b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx @@ -8,13 +8,20 @@ import { } from "react"; import { Brain, + ChevronDown, + ChevronUp, + Clock3, Globe2, Lightbulb, Loader2, Paperclip, + Play, Send, Square, + SquarePen, + Trash2, X, + Zap, } from "../../components/icons"; import { @@ -59,7 +66,7 @@ function RuntimeControlTooltip(props: { label: string; children: ReactNode }) { {props.children} {props.label} @@ -67,6 +74,12 @@ function RuntimeControlTooltip(props: { label: string; children: ReactNode }) { ); } +export type ChatQueueTurnPreview = { + id: string; + previewText: string; + fileCount: number; +}; + export const ChatComposerBar = memo(function ChatComposerBar(props: { composerRef: MutableRefObject; isSending: boolean; @@ -84,6 +97,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { onGitChanged?: (workdir: string) => void; onSend: () => void; onStop: () => void; + onInterruptAndSend: () => void; onPrepareChatRuntime?: () => void; onComposerBusyChange: (isBusy: boolean) => void; onChatRuntimeControlsChange: (patch: Partial) => void; @@ -91,6 +105,11 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { onPasteFiles: (files: File[]) => void; pendingUploadedFiles: PendingUploadedFile[]; onRemovePendingUpload: (relativePath: string) => void; + queuedTurns: ChatQueueTurnPreview[]; + onRunQueuedTurnNow: (id: string) => void; + onMoveQueuedTurn: (id: string, direction: "up" | "down") => void; + onEditQueuedTurn: (id: string) => void; + onRemoveQueuedTurn: (id: string) => void; }) { const { composerRef, @@ -109,6 +128,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { onGitChanged, onSend, onStop, + onInterruptAndSend, onPrepareChatRuntime, onComposerBusyChange, onChatRuntimeControlsChange, @@ -116,19 +136,20 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { onPasteFiles, pendingUploadedFiles, onRemovePendingUpload, + queuedTurns, + onRunQueuedTurnNow, + onMoveQueuedTurn, + onEditQueuedTurn, + onRemoveQueuedTurn, } = props; const { t } = useLocale(); const [composerIsEmpty, setComposerIsEmpty] = useState(true); const composerLayerRef = useRef(null); - const uploadDisabled = isInputDisabled || isSending || isUploadingFiles || !isAgentMode || !workdir; - const controlsDisabled = isInputDisabled || isSending; + const uploadDisabled = isInputDisabled || isUploadingFiles || !isAgentMode || !workdir; + const controlsDisabled = isInputDisabled; + const hasSendableDraft = !composerIsEmpty || pendingUploadedFiles.length > 0; const thinkingSupported = reasoningOptions.length > 0; - const sendDisabled = - isSending - ? false - : isInputDisabled || - isUploadingFiles || - (composerIsEmpty && pendingUploadedFiles.length === 0); + const sendDisabled = isInputDisabled || isUploadingFiles || !hasSendableDraft; const selectedReasoning = reasoningOptions.includes(chatRuntimeControls.reasoning) ? chatRuntimeControls.reasoning : DEFAULT_CHAT_RUNTIME_CONTROLS.reasoning; @@ -218,7 +239,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { + + + + + + + + + + + + + + + + ))} + + + ) : null} + +
{/* macOS material rim-light */}
@@ -406,36 +514,55 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { />
- + + }} + className="h-8 w-8 shrink-0 rounded-full shadow-[0_1px_2px_rgba(15,23,42,0.12)] transition-all hover:opacity-90 active:scale-95" + > + + + + ) : null} + +
diff --git a/crates/agent-gateway/web/src/pages/chat/queue/chatTurnQueue.ts b/crates/agent-gateway/web/src/pages/chat/queue/chatTurnQueue.ts new file mode 100644 index 00000000..0063ab0e --- /dev/null +++ b/crates/agent-gateway/web/src/pages/chat/queue/chatTurnQueue.ts @@ -0,0 +1,189 @@ +import type { MentionComposerDraft } from "@/components/chat/MentionComposer"; +import type { PendingUploadedFile } from "@/lib/chat/uploadedFiles"; +import type { ChatRuntimeControls } from "@/lib/settings"; + +export type QueuedChatTurn = { + id: string; + conversationId: string; + draft: MentionComposerDraft; + uploadedFiles: PendingUploadedFile[]; + workdir: string; + runtimeControls: ChatRuntimeControls; + createdAt: number; +}; + +export type QueuedChatTurnInput = Omit & { + createdAt?: number; + id?: string; +}; + +export type QueuedChatTurnEditSlot = { + conversationId: string; + previousId: string | null; + nextId: string | null; + index?: number; +}; + +export function createQueuedChatTurn(input: QueuedChatTurnInput): QueuedChatTurn { + const createdAt = input.createdAt ?? Date.now(); + return { + id: input.id?.trim() || `queued-chat-${createdAt}-${Math.random().toString(36).slice(2, 8)}`, + conversationId: input.conversationId.trim(), + draft: input.draft, + uploadedFiles: input.uploadedFiles.slice(), + workdir: input.workdir.trim(), + runtimeControls: { ...input.runtimeControls }, + createdAt, + }; +} + +export function queuedChatTurnHasContent( + draft: MentionComposerDraft | null | undefined, + uploadedFiles: readonly PendingUploadedFile[], +): draft is MentionComposerDraft { + return Boolean(draft && (!draft.isEmpty || draft.text.trim() || uploadedFiles.length > 0)); +} + +export function buildQueuedChatTurnPreview(draft: MentionComposerDraft) { + const parts = draft.segments.map((segment) => { + switch (segment.type) { + case "largePaste": + return segment.paste.label; + case "skillMention": + return `$${segment.skill.name}`; + case "commitMention": + return segment.commit.subject || segment.commit.shortSha || segment.commit.sha; + case "gitFileMention": + return segment.file.path; + case "text": + return segment.text; + } + return ""; + }); + return parts.join("").replace(/\s+/g, " ").trim() || draft.text.replace(/\s+/g, " ").trim(); +} + +function withoutTurn(queue: readonly QueuedChatTurn[], id: string) { + const key = id.trim(); + return queue.filter((item) => item.id !== key); +} + +export function appendQueuedChatTurn( + queue: readonly QueuedChatTurn[], + item: QueuedChatTurn, +): QueuedChatTurn[] { + return [...withoutTurn(queue, item.id), item]; +} + +export function prependQueuedChatTurn( + queue: readonly QueuedChatTurn[], + item: QueuedChatTurn, +): QueuedChatTurn[] { + return [item, ...withoutTurn(queue, item.id)]; +} + +export function resolveQueuedChatTurnSlotIndex( + queue: readonly QueuedChatTurn[], + slot: QueuedChatTurnEditSlot, +) { + const compactQueue = queue.slice(); + const nextIndex = slot.nextId ? compactQueue.findIndex((item) => item.id === slot.nextId) : -1; + if (nextIndex >= 0) return nextIndex; + + const previousIndex = slot.previousId + ? compactQueue.findIndex((item) => item.id === slot.previousId) + : -1; + if (previousIndex >= 0) return previousIndex + 1; + + if (Number.isInteger(slot.index) && slot.index !== undefined && slot.index >= 0) { + let scopedIndex = 0; + let lastConversationIndex = -1; + for (let index = 0; index < compactQueue.length; index += 1) { + if (compactQueue[index]?.conversationId !== slot.conversationId) continue; + if (scopedIndex >= slot.index) return index; + scopedIndex += 1; + lastConversationIndex = index; + } + if (lastConversationIndex >= 0) return lastConversationIndex + 1; + } + + const firstConversationIndex = compactQueue.findIndex( + (item) => item.conversationId === slot.conversationId, + ); + if (firstConversationIndex >= 0) return firstConversationIndex; + return compactQueue.length; +} + +export function insertQueuedChatTurnAtSlot( + queue: readonly QueuedChatTurn[], + item: QueuedChatTurn, + slot: QueuedChatTurnEditSlot, +): QueuedChatTurn[] { + const next = withoutTurn(queue, item.id); + const index = resolveQueuedChatTurnSlotIndex(next, slot); + return [...next.slice(0, index), item, ...next.slice(index)]; +} + +export function removeQueuedChatTurn( + queue: readonly QueuedChatTurn[], + id: string, +): QueuedChatTurn[] { + return withoutTurn(queue, id); +} + +export function removeQueuedChatTurnsForConversation( + queue: readonly QueuedChatTurn[], + conversationId: string, +): QueuedChatTurn[] { + const key = conversationId.trim(); + if (!key) return queue.slice(); + return queue.filter((item) => item.conversationId !== key); +} + +export function moveQueuedChatTurn( + queue: readonly QueuedChatTurn[], + id: string, + direction: "up" | "down", +): QueuedChatTurn[] { + const key = id.trim(); + const index = queue.findIndex((item) => item.id === key); + if (index < 0) return queue.slice(); + const item = queue[index]; + let swapIndex = index; + while (true) { + swapIndex = direction === "up" ? swapIndex - 1 : swapIndex + 1; + if (swapIndex < 0 || swapIndex >= queue.length) return queue.slice(); + if (queue[swapIndex]?.conversationId === item?.conversationId) break; + } + if (swapIndex < 0 || swapIndex >= queue.length) return queue.slice(); + const next = queue.slice(); + [next[index], next[swapIndex]] = [next[swapIndex], next[index]]; + return next; +} + +export function promoteQueuedChatTurn( + queue: readonly QueuedChatTurn[], + id: string, +): QueuedChatTurn[] { + const key = id.trim(); + const item = queue.find((candidate) => candidate.id === key); + if (!item) return queue.slice(); + return prependQueuedChatTurn(queue, item); +} + +export function takeNextQueuedChatTurn( + queue: readonly QueuedChatTurn[], + conversationId: string, +): { item: QueuedChatTurn | null; queue: QueuedChatTurn[] } { + const key = conversationId.trim(); + if (!key) return { item: null, queue: queue.slice() }; + const index = queue.findIndex((item) => item.conversationId === key); + if (index < 0) return { item: null, queue: queue.slice() }; + const next = queue.slice(); + const [item] = next.splice(index, 1); + return { item: item ?? null, queue: next }; +} + +export function getQueuedConversationIds(queue: readonly QueuedChatTurn[]) { + return Array.from(new Set(queue.map((item) => item.conversationId).filter(Boolean))); +} diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 70f09d0e..dc950872 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -108,6 +108,16 @@ export const translations: Record> = { "chat.compactingContextWait": "正在压缩上下文,请稍候...", "chat.editMessage": "编辑消息", "chat.cancel": "取消", + "chat.queue.title": "等待队列 {count}", + "chat.queue.addToQueue": "加入队列", + "chat.queue.interruptAndSend": "打断并发送", + "chat.queue.runNow": "打断并执行", + "chat.queue.moveUp": "上移", + "chat.queue.moveDown": "下移", + "chat.queue.edit": "编辑等待项", + "chat.queue.delete": "删除等待项", + "chat.queue.emptyMessage": "附件消息", + "chat.queue.fileCount": "{count} 个附件", "chat.composer.commitTooltipUnknownAuthor": "未知作者", "chat.composer.commitTooltipFilesChanged": "已更改 {count} 个文件", "chat.composer.commitTooltipInsertions": "{count} 行插入(+)", @@ -1604,6 +1614,16 @@ export const translations: Record> = { "chat.compactingContextWait": "Compressing context, please wait...", "chat.editMessage": "Edit Message", "chat.cancel": "Cancel", + "chat.queue.title": "Queue {count}", + "chat.queue.addToQueue": "Add to queue", + "chat.queue.interruptAndSend": "Interrupt and send", + "chat.queue.runNow": "Interrupt and run", + "chat.queue.moveUp": "Move up", + "chat.queue.moveDown": "Move down", + "chat.queue.edit": "Edit queued message", + "chat.queue.delete": "Delete queued message", + "chat.queue.emptyMessage": "Attachment message", + "chat.queue.fileCount": "{count} attachments", "chat.composer.commitTooltipUnknownAuthor": "Unknown author", "chat.composer.commitTooltipFilesChanged": "{count} files changed", "chat.composer.commitTooltipInsertions": "{count} insertions(+)", diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 66b7265f..615c6075 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -177,6 +177,7 @@ import { buildResumeContext as buildResumeConversationContext, ChatComposerBar, ChatHeader, + type ChatQueueTurnPreview, ChatTranscript, clearSilentMemoryExtractionState, createChatRuntimeHost, @@ -200,6 +201,22 @@ import { useLiveTranscriptController, usePendingUploads, } from "./chat"; +import { + appendQueuedChatTurn, + buildQueuedChatTurnPreview, + createQueuedChatTurn, + getQueuedConversationIds, + insertQueuedChatTurnAtSlot, + moveQueuedChatTurn, + promoteQueuedChatTurn, + type QueuedChatTurn, + type QueuedChatTurnEditSlot, + queuedChatTurnHasContent, + removeQueuedChatTurn, + removeQueuedChatTurnsForConversation, + resolveQueuedChatTurnSlotIndex, + takeNextQueuedChatTurn, +} from "./chat/queue/chatTurnQueue"; import { McpHubPage } from "./mcp-hub/McpHubPage"; import type { SectionId } from "./settings/types"; import { SkillsHubPage } from "./skills-hub/SkillsHubPage"; @@ -1370,7 +1387,7 @@ export function ChatPage(props: ChatPageProps) { async () => undefined, ); const deleteConversationActionRef = useRef<(id: string) => Promise>(async () => undefined); - const sendActionRef = useRef(async () => undefined); + const sendActionRef = useRef(async () => false); const ensureGatewayBridgeConversationReadyRef = useRef< (id: string, options?: EnsureGatewayBridgeConversationReadyOptions) => Promise >(async (id) => id.trim()); @@ -1779,6 +1796,44 @@ export function ChatPage(props: ChatPageProps) { addNotify, }); const [isFileDropActive, setIsFileDropActive] = useState(false); + const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); + const [queuedChatTurns, setQueuedChatTurns] = useState([]); + const queuedChatTurnsRef = useRef([]); + const queuedChatProcessingConversationIdsRef = useRef(new Set()); + const queuedChatTurnEditSlotRef = useRef< + | (QueuedChatTurnEditSlot & { + originalId: string; + createdAt: number; + executionMode: ExecutionMode; + workdir: string; + selectedSystemToolIds: SystemToolId[]; + runtimeControls: ChatRuntimeControls; + }) + | null + >(null); + const previousRunningConversationIdsRef = useRef>(new Set()); + + const setQueuedChatTurnsState = useCallback( + (updater: (current: QueuedChatTurn[]) => QueuedChatTurn[]) => { + const next = updater(queuedChatTurnsRef.current).slice(); + queuedChatTurnsRef.current = next; + setQueuedChatTurns(next); + return next; + }, + [], + ); + + const queuedChatTurnsForCurrentConversation = useMemo( + () => + queuedChatTurns + .filter((item) => item.conversationId === currentConversationId) + .map((item) => ({ + id: item.id, + previewText: buildQueuedChatTurnPreview(item.draft), + fileCount: item.uploadedFiles.length, + })), + [currentConversationId, queuedChatTurns], + ); const deleteConversationLocalCaches = useCallback( (conversationId: string) => { @@ -1792,8 +1847,9 @@ export function ChatPage(props: ChatPageProps) { clearSilentMemoryExtractionState(key); clearSilentMemoryDecisions(key); deleteConversationArtifacts(key); + setQueuedChatTurnsState((current) => removeQueuedChatTurnsForConversation(current, key)); }, - [deleteConversationArtifacts, pendingUploadsByConversationRef], + [deleteConversationArtifacts, pendingUploadsByConversationRef, setQueuedChatTurnsState], ); function resetVisibleTransientState(targetConversationId = currentConversationIdRef.current) { @@ -1861,10 +1917,15 @@ export function ChatPage(props: ChatPageProps) { const pruneIdleConversationCaches = useCallback( (extraKeepIds: Iterable = []) => { + const queuedConversationIds = getQueuedConversationIds(queuedChatTurnsRef.current); pruneIdleConversationRuntimeCaches({ runtimeCache: conversationRuntimeCacheRef.current, persistedStateCache: persistedConversationStateRef.current, - keepConversationIds: [currentConversationIdRef.current, ...extraKeepIds], + keepConversationIds: [ + currentConversationIdRef.current, + ...extraKeepIds, + ...queuedConversationIds, + ], isConversationRunning, onPruneConversation: (conversationId) => { deleteConversationLocalCaches(conversationId); @@ -1987,14 +2048,252 @@ export function ChatPage(props: ChatPageProps) { [currentConversationIdRef, historyItemsRef], ); - function stopSending() { - const conversationId = currentConversationIdRef.current; - const controller = getConversationAbortController(conversationId); - if (!controller) return; - const transcriptStore = getConversationLiveTranscriptStore(conversationId); + function stopConversation(conversationId: string) { + const targetConversationId = conversationId.trim(); + if (!targetConversationId) return false; + const controller = getConversationAbortController(targetConversationId); + if (!controller) return false; + const transcriptStore = getConversationLiveTranscriptStore(targetConversationId); captureAbortSnapshot(transcriptStore); updateToolStatus("正在停止当前任务...", transcriptStore, true); controller.abort(); + return true; + } + + function stopSending() { + stopConversation(currentConversationIdRef.current); + } + + function clearCurrentComposerDraftForQueuedTurn(conversationId: string) { + const targetConversationId = conversationId.trim(); + if (!targetConversationId || currentConversationIdRef.current !== targetConversationId) { + return; + } + composerRef.current?.clear(); + pendingUploadsByConversationRef.current.delete(targetConversationId); + setPendingUploadedFiles([]); + clearCachedComposerDraft(targetConversationId); + } + + function enqueueCurrentComposerTurn(position: "end" | "front" | "edit") { + const conversationId = currentConversationIdRef.current.trim(); + const draft = composerRef.current?.getDraft() ?? null; + const uploadedFiles = pendingUploadedFiles.slice(); + if (!conversationId || !queuedChatTurnHasContent(draft, uploadedFiles)) { + return false; + } + + const runtimeEntry = + conversationRuntimeCacheRef.current.get(conversationId) ?? + buildRuntimeEntryFromVisibleState(); + const editSlot = + position === "edit" && queuedChatTurnEditSlotRef.current?.conversationId === conversationId + ? queuedChatTurnEditSlotRef.current + : null; + const executionMode = editSlot?.executionMode ?? settings.system.executionMode; + const workdirForTurn = isAgentExecutionMode(executionMode) + ? ( + editSlot?.workdir ?? + runtimeEntry.workdir ?? + displayedConversationWorkdir ?? + settings.system.workdir + ).trim() + : ""; + const queuedTurn = createQueuedChatTurn({ + id: editSlot?.originalId, + conversationId, + draft, + uploadedFiles, + executionMode, + workdir: workdirForTurn, + selectedSystemToolIds: editSlot?.selectedSystemToolIds ?? settings.system.selectedSystemTools, + runtimeControls: editSlot?.runtimeControls ?? settings.chatRuntimeControls, + createdAt: editSlot?.createdAt, + }); + + setQueuedChatTurnsState((current) => { + if (editSlot) { + return insertQueuedChatTurnAtSlot(current, queuedTurn, editSlot); + } + return position === "front" + ? promoteQueuedChatTurn(appendQueuedChatTurn(current, queuedTurn), queuedTurn.id) + : appendQueuedChatTurn(current, queuedTurn); + }); + if (editSlot) { + queuedChatTurnEditSlotRef.current = null; + } + clearCurrentComposerDraftForQueuedTurn(conversationId); + return true; + } + + function isQueuedChatTurnEditBlockingProcessing(conversationId: string) { + const slot = queuedChatTurnEditSlotRef.current; + if (!slot || slot.conversationId !== conversationId.trim()) return false; + const queue = queuedChatTurnsRef.current; + const firstQueuedIndex = queue.findIndex((item) => item.conversationId === slot.conversationId); + if (firstQueuedIndex < 0) return false; + return resolveQueuedChatTurnSlotIndex(queue, slot) <= firstQueuedIndex; + } + + function requestQueuedChatTurnProcessing(conversationId: string) { + const targetConversationId = conversationId.trim(); + if (!targetConversationId) return; + if (queuedChatProcessingConversationIdsRef.current.has(targetConversationId)) return; + if (isConversationRunning(targetConversationId)) return; + if (isQueuedChatTurnEditBlockingProcessing(targetConversationId)) return; + if (!queuedChatTurnsRef.current.some((item) => item.conversationId === targetConversationId)) { + return; + } + + queuedChatProcessingConversationIdsRef.current.add(targetConversationId); + let inFlightQueuedTurn: QueuedChatTurn | null = null; + void Promise.resolve() + .then(async () => { + if (isConversationRunning(targetConversationId)) return; + const taken = takeNextQueuedChatTurn(queuedChatTurnsRef.current, targetConversationId); + if (!taken.item) return false; + const queuedTurn = taken.item; + inFlightQueuedTurn = queuedTurn; + queuedChatTurnsRef.current = taken.queue; + setQueuedChatTurns(taken.queue); + const accepted = await sendActionRef.current({ + composerDraftOverride: queuedTurn.draft, + uploadedFilesOverride: queuedTurn.uploadedFiles, + conversationIdOverride: targetConversationId, + executionModeOverride: queuedTurn.executionMode, + workdirOverride: queuedTurn.workdir, + selectedSystemToolIdsOverride: queuedTurn.selectedSystemToolIds, + runtimeControlsOverride: queuedTurn.runtimeControls, + preserveComposerOnStart: true, + }); + if (!accepted) { + setQueuedChatTurnsState((current) => + promoteQueuedChatTurn(appendQueuedChatTurn(current, queuedTurn), queuedTurn.id), + ); + inFlightQueuedTurn = null; + } + return accepted; + }) + .then((accepted) => { + queuedChatProcessingConversationIdsRef.current.delete(targetConversationId); + if ( + accepted && + !isConversationRunning(targetConversationId) && + queuedChatTurnsRef.current.some((item) => item.conversationId === targetConversationId) + ) { + requestQueuedChatTurnProcessing(targetConversationId); + } + }) + .catch(() => { + const failedQueuedTurn = inFlightQueuedTurn; + if (failedQueuedTurn) { + setQueuedChatTurnsState((current) => + promoteQueuedChatTurn( + appendQueuedChatTurn(current, failedQueuedTurn), + failedQueuedTurn.id, + ), + ); + inFlightQueuedTurn = null; + } + queuedChatProcessingConversationIdsRef.current.delete(targetConversationId); + }); + } + + useEffect(() => { + const previousRunningConversationIds = previousRunningConversationIdsRef.current; + previousRunningConversationIdsRef.current = runningConversationIds; + for (const conversationId of getQueuedConversationIds(queuedChatTurnsRef.current)) { + if ( + previousRunningConversationIds.has(conversationId) && + !runningConversationIds.has(conversationId) + ) { + requestQueuedChatTurnProcessing(conversationId); + } + } + }, [runningConversationIds, queuedChatTurns]); + + function enqueueCurrentComposerTurnAndMaybeInterrupt() { + const conversationId = currentConversationIdRef.current.trim(); + if (!enqueueCurrentComposerTurn("front")) return; + if (isConversationRunning(conversationId)) { + stopConversation(conversationId); + return; + } + requestQueuedChatTurnProcessing(conversationId); + } + + function runQueuedTurnNow(id: string) { + const queuedTurn = queuedChatTurnsRef.current.find((item) => item.id === id.trim()); + if (!queuedTurn) return; + setQueuedChatTurnsState((current) => promoteQueuedChatTurn(current, queuedTurn.id)); + if (isConversationRunning(queuedTurn.conversationId)) { + stopConversation(queuedTurn.conversationId); + return; + } + requestQueuedChatTurnProcessing(queuedTurn.conversationId); + } + + function moveQueuedTurn(id: string, direction: "up" | "down") { + setQueuedChatTurnsState((current) => moveQueuedChatTurn(current, id, direction)); + } + + function editQueuedTurn(id: string) { + const key = id.trim(); + const queuedTurnIndex = queuedChatTurnsRef.current.findIndex((item) => item.id === key); + const queuedTurn = queuedTurnIndex >= 0 ? queuedChatTurnsRef.current[queuedTurnIndex] : null; + if (!queuedTurn) return; + const targetConversationId = queuedTurn.conversationId.trim(); + if (!targetConversationId || currentConversationIdRef.current.trim() !== targetConversationId) { + return; + } + + const currentDraft = composerRef.current?.getDraft() ?? null; + const currentUploads = pendingUploadedFiles.slice(); + if (queuedChatTurnHasContent(currentDraft, currentUploads)) { + enqueueCurrentComposerTurn(queuedChatTurnEditSlotRef.current ? "edit" : "end"); + } + + const sameConversationQueue = queuedChatTurnsRef.current.filter( + (item) => item.conversationId === targetConversationId, + ); + const sameConversationIndex = sameConversationQueue.findIndex((item) => item.id === key); + const previousId = + sameConversationIndex > 0 + ? (sameConversationQueue[sameConversationIndex - 1]?.id ?? null) + : null; + const nextId = + sameConversationIndex >= 0 + ? (sameConversationQueue[sameConversationIndex + 1]?.id ?? null) + : null; + queuedChatTurnEditSlotRef.current = { + conversationId: targetConversationId, + previousId, + nextId, + index: sameConversationIndex >= 0 ? sameConversationIndex : undefined, + originalId: queuedTurn.id, + createdAt: queuedTurn.createdAt, + executionMode: queuedTurn.executionMode, + workdir: queuedTurn.workdir, + selectedSystemToolIds: queuedTurn.selectedSystemToolIds.slice(), + runtimeControls: { ...queuedTurn.runtimeControls }, + }; + setQueuedChatTurnsState((current) => removeQueuedChatTurn(current, key)); + composerRef.current?.setDraft(queuedTurn.draft); + if (queuedTurn.uploadedFiles.length > 0) { + pendingUploadsByConversationRef.current.set( + targetConversationId, + queuedTurn.uploadedFiles.slice(), + ); + } else { + pendingUploadsByConversationRef.current.delete(targetConversationId); + } + setPendingUploadedFiles(queuedTurn.uploadedFiles.slice()); + clearCachedComposerDraft(targetConversationId); + window.requestAnimationFrame(() => composerRef.current?.focus()); + } + + function removeQueuedTurn(id: string) { + setQueuedChatTurnsState((current) => removeQueuedChatTurn(current, id)); } const { @@ -2681,6 +2980,7 @@ export function ChatPage(props: ChatPageProps) { async function send(overrides?: { textOverride?: string; + composerDraftOverride?: MentionComposerDraft; uploadedFilesOverride?: PendingUploadedFile[]; conversationIdOverride?: string; executionModeOverride?: ExecutionMode; @@ -2688,13 +2988,14 @@ export function ChatPage(props: ChatPageProps) { selectedSystemToolIdsOverride?: SystemToolId[]; runtimeControlsOverride?: ChatRuntimeControls; gatewayBridgeRequestOverride?: ActiveGatewayBridgeRequest | null; + preserveComposerOnStart?: boolean; beforeRuntimeStart?: () => Promise; afterInitialHistoryPersist?: () => Promise; }) { const overrideConversationId = overrides?.conversationIdOverride?.trim() ?? ""; const conversationId = overrideConversationId || currentConversationIdRef.current; if (!conversationId) { - return; + return false; } const runtimeEntry = @@ -2763,22 +3064,22 @@ export function ChatPage(props: ChatPageProps) { gatewayBridgeEvents.emitError(message, conversationId); gatewayBridgeEvents.close(); } - return; + return false; } if (isImportingPastedTextRef.current && typeof overrides?.textOverride !== "string") { - return; + return false; } if (hydratingConversationIdRef.current === conversationId) { const message = "当前会话仍在补全完整历史,请稍候。"; setConversationErrorState(message); gatewayBridgeEvents.emitError(message, conversationId); - return; + return false; } if (hydrationFailedConversationIdRef.current === conversationId) { const message = "当前会话完整历史加载失败,请重新打开该会话后再继续。"; setConversationErrorState(message); gatewayBridgeEvents.emitError(message, conversationId); - return; + return false; } if (runtimeEntry.compactionStatus.phase !== "idle") { updateConversationRuntimeEntry(conversationId, (prev) => ({ @@ -2797,7 +3098,7 @@ export function ChatPage(props: ChatPageProps) { const message = asErrorMessage(error, "当前模型配置不可用,请重新选择后重试。"); setConversationErrorState(message); gatewayBridgeEvents.emitError(message); - return; + return false; } const { selectedModel, provider, providerId, model } = effectiveSelectedModel; @@ -2838,26 +3139,27 @@ export function ChatPage(props: ChatPageProps) { providerConfig.modelConfig, ); - const composerDraft = - typeof overrides?.textOverride === "string" - ? null - : (composerRef.current?.getDraft() ?? null); - let text = - typeof overrides?.textOverride === "string" - ? overrides.textOverride.trim() - : composerDraft - ? (effectiveIsAgentMode && composerDraft.largePastes.length > 0 - ? composerDraft.textWithoutLargePastes - : buildTextFromComposerDraft(composerDraft) - ).trim() - : ""; + const textOverride = + typeof overrides?.textOverride === "string" ? overrides.textOverride : null; + const hasTextOverride = textOverride !== null; + const composerDraft = hasTextOverride + ? null + : (overrides?.composerDraftOverride ?? composerRef.current?.getDraft() ?? null); + let text = hasTextOverride + ? textOverride.trim() + : composerDraft + ? (effectiveIsAgentMode && composerDraft.largePastes.length > 0 + ? composerDraft.textWithoutLargePastes + : buildTextFromComposerDraft(composerDraft) + ).trim() + : ""; let uploadedFiles = overrides?.uploadedFilesOverride ?? pendingUploadedFiles; if ( effectiveIsAgentMode && composerDraft && composerDraft.largePastes.length > 0 && - typeof overrides?.textOverride !== "string" + !hasTextOverride ) { isImportingPastedTextRef.current = true; setIsImportingPastedText(true); @@ -2874,7 +3176,7 @@ export function ChatPage(props: ChatPageProps) { setErrorMessage(message); gatewayBridgeEvents.emitError(message, conversationId); gatewayBridgeEvents.close(); - return; + return false; } finally { isImportingPastedTextRef.current = false; setIsImportingPastedText(false); @@ -2888,7 +3190,7 @@ export function ChatPage(props: ChatPageProps) { gatewayBridgeEvents.emitError(message, conversationId); gatewayBridgeEvents.close(); } - return; + return false; } const pendingUserMessage = userMessage; const content = @@ -3064,7 +3366,7 @@ export function ChatPage(props: ChatPageProps) { gatewayBridgeEvents.emitError(message, conversationId); gatewayBridgeEvents.close(); markConversationRunStopped(); - return; + return true; } try { await overrides.afterInitialHistoryPersist(); @@ -3074,7 +3376,7 @@ export function ChatPage(props: ChatPageProps) { gatewayBridgeEvents.emitError(message, conversationId); gatewayBridgeEvents.close(); markConversationRunStopped(); - return; + return true; } } else { const initialPersistConfirmation = initialPersist @@ -3103,7 +3405,7 @@ export function ChatPage(props: ChatPageProps) { gatewayBridgeEvents.emitError(message, conversationId); gatewayBridgeEvents.close(); markConversationRunStopped(); - return; + return true; } } void initialPersistConfirmation; @@ -3261,7 +3563,7 @@ export function ChatPage(props: ChatPageProps) { gatewayBridgeEvents.emitError(message, conversationId); gatewayBridgeEvents.close(); markConversationRunStopped(); - return; + return true; } const selectedSkills = selectedSkillNames.map((n) => byName.get(n)!).filter(Boolean); @@ -3664,10 +3966,18 @@ export function ChatPage(props: ChatPageProps) { } } - if (typeof overrides?.textOverride !== "string") { + if (!hasTextOverride && !overrides?.composerDraftOverride) { clearCachedComposerDraft(conversationId); } - resetVisibleTransientState(conversationId); + if (!overrides?.preserveComposerOnStart) { + resetVisibleTransientState(conversationId); + } else { + setConversationErrorState(null); + updateConversationRuntimeEntry(conversationId, (prev) => ({ + ...prev, + hookWarning: null, + })); + } try { if (effectiveIsAgentMode) { @@ -3835,7 +4145,9 @@ export function ChatPage(props: ChatPageProps) { clearAbortSnapshot(transcriptStore); markConversationRunStopped(); pruneIdleConversationCaches([conversationId]); + requestQueuedChatTurnProcessing(conversationId); } + return true; } sendActionRef.current = send; @@ -4248,13 +4560,29 @@ export function ChatPage(props: ChatPageProps) { ); const handleSend = useCallback(() => { + const conversationId = currentConversationIdRef.current.trim(); + const runtimeEntry = conversationRuntimeCacheRef.current.get(conversationId); + if (queuedChatTurnEditSlotRef.current?.conversationId === conversationId) { + if (enqueueCurrentComposerTurn("edit")) { + requestQueuedChatTurnProcessing(conversationId); + } + return; + } + if (conversationId && (isConversationRunning(conversationId) || runtimeEntry?.isSending)) { + enqueueCurrentComposerTurn("end"); + return; + } void sendActionRef.current(); - }, []); + }, [enqueueCurrentComposerTurn, isConversationRunning]); const handleStopSending = useCallback(() => { stopSendingActionRef.current(); }, []); + const handleInterruptAndSend = useCallback(() => { + enqueueCurrentComposerTurnAndMaybeInterrupt(); + }, [enqueueCurrentComposerTurnAndMaybeInterrupt]); + const handleComposerBusyChange = useCallback((isBusy: boolean) => { composerBusyRef.current = isBusy; }, []); @@ -4332,10 +4660,7 @@ export function ChatPage(props: ChatPageProps) { isImportingPastedText || isUploadingFiles; const canDropUpload = - isAgentMode && - Boolean(displayedConversationWorkdir.trim()) && - !isSending && - !isComposerInputDisabled; + isAgentMode && Boolean(displayedConversationWorkdir.trim()) && !isComposerInputDisabled; const fileDropTitle = canDropUpload ? t("chat.upload.dropReady") : !isAgentMode @@ -4603,6 +4928,7 @@ export function ChatPage(props: ChatPageProps) { usageContextWindow={currentModelContextWindow} liveTranscriptStore={liveTranscriptStore} isCompactionRunning={isCompactionRunning} + bottomReservePx={composerOverlayHeight} copiedMessageKey={copiedMessageKey} setCopiedMessageKey={setCopiedMessageKey} onResendFromEdit={handleResendFromEdit} @@ -4630,12 +4956,19 @@ export function ChatPage(props: ChatPageProps) { } onSend={handleSend} onStop={handleStopSending} + onInterruptAndSend={handleInterruptAndSend} onComposerBusyChange={handleComposerBusyChange} onChatRuntimeControlsChange={handleChatRuntimeControlsChange} onPickReadableFiles={pickReadableFiles} onPasteFiles={importReadableFiles} pendingUploadedFiles={pendingUploadedFiles} onRemovePendingUpload={removePendingUpload} + queuedTurns={queuedChatTurnsForCurrentConversation} + onRunQueuedTurnNow={runQueuedTurnNow} + onMoveQueuedTurn={moveQueuedTurn} + onEditQueuedTurn={editQueuedTurn} + onRemoveQueuedTurn={removeQueuedTurn} + onHeightChange={setComposerOverlayHeight} /> {isFileDropActive ? (
; isSending: boolean; @@ -80,12 +93,19 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { onGitChanged?: (workdir: string) => void; onSend: () => void; onStop: () => void; + onInterruptAndSend: () => void; onComposerBusyChange: (isBusy: boolean) => void; onChatRuntimeControlsChange: (patch: Partial) => void; onPickReadableFiles: () => void; onPasteFiles: (files: File[]) => void; pendingUploadedFiles: PendingUploadedFile[]; onRemovePendingUpload: (relativePath: string) => void; + queuedTurns: ChatQueueTurnPreview[]; + onRunQueuedTurnNow: (id: string) => void; + onMoveQueuedTurn: (id: string, direction: "up" | "down") => void; + onEditQueuedTurn: (id: string) => void; + onRemoveQueuedTurn: (id: string) => void; + onHeightChange?: (height: number) => void; }) { const { composerRef, @@ -104,23 +124,28 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { onGitChanged, onSend, onStop, + onInterruptAndSend, onComposerBusyChange, onChatRuntimeControlsChange, onPickReadableFiles, onPasteFiles, pendingUploadedFiles, onRemovePendingUpload, + queuedTurns, + onRunQueuedTurnNow, + onMoveQueuedTurn, + onEditQueuedTurn, + onRemoveQueuedTurn, + onHeightChange, } = props; const { t } = useLocale(); + const rootRef = useRef(null); const [composerIsEmpty, setComposerIsEmpty] = useState(true); - const uploadDisabled = - isInputDisabled || isSending || isUploadingFiles || !isAgentMode || !workdir; - const controlsDisabled = isInputDisabled || isSending; + const uploadDisabled = isInputDisabled || isUploadingFiles || !isAgentMode || !workdir; + const controlsDisabled = isInputDisabled; + const hasSendableDraft = !composerIsEmpty || pendingUploadedFiles.length > 0; const thinkingSupported = reasoningOptions.length > 0; - const sendDisabled = - isInputDisabled || - isUploadingFiles || - (!isSending && composerIsEmpty && pendingUploadedFiles.length === 0); + const sendDisabled = isInputDisabled || isUploadingFiles || !hasSendableDraft; const selectedReasoning = reasoningOptions.includes(chatRuntimeControls.reasoning) ? chatRuntimeControls.reasoning : DEFAULT_CHAT_RUNTIME_CONTROLS.reasoning; @@ -143,8 +168,41 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { onChatRuntimeControlsChange({ reasoning: DEFAULT_CHAT_RUNTIME_CONTROLS.reasoning }); }, [chatRuntimeControls.reasoning, onChatRuntimeControlsChange, reasoningOptions]); + useEffect(() => { + const root = rootRef.current; + if (!root || !onHeightChange) return; + + let animationFrame: number | null = null; + const measure = () => { + animationFrame = null; + onHeightChange(Math.ceil(root.getBoundingClientRect().height)); + }; + const scheduleMeasure = () => { + if (animationFrame !== null) return; + animationFrame = window.requestAnimationFrame(measure); + }; + + scheduleMeasure(); + const resizeObserver = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleMeasure); + resizeObserver?.observe(root); + window.addEventListener("resize", scheduleMeasure); + + return () => { + if (animationFrame !== null) { + window.cancelAnimationFrame(animationFrame); + } + resizeObserver?.disconnect(); + window.removeEventListener("resize", scheduleMeasure); + onHeightChange(0); + }; + }, [onHeightChange]); + return ( -
+
{/* Pending uploaded files — above the composer card */} {pendingUploadedFiles.length > 0 && ( @@ -168,7 +226,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
)} -
+ {queuedTurns.length > 0 ? ( +
+
    + {queuedTurns.map((item, index) => ( +
  • + +
    + + {item.previewText || t("chat.queue.emptyMessage")} + + {item.fileCount > 0 ? ( + + {t("chat.queue.fileCount").replace("{count}", String(item.fileCount))} + + ) : null} +
    +
    + + + + + + + + + + + + + + + +
    +
  • + ))} +
+
+ ) : null} + +
{/* macOS material rim-light */}
@@ -364,36 +509,55 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { />
- + + }} + className="h-8 w-8 shrink-0 rounded-full shadow-[0_1px_2px_rgba(15,23,42,0.12)] transition-all hover:opacity-90 active:scale-95" + > + + + + ) : null} + +
diff --git a/crates/agent-gui/src/pages/chat/gateway/gatewayBridgeTypes.ts b/crates/agent-gui/src/pages/chat/gateway/gatewayBridgeTypes.ts index da57dd4d..10731c59 100644 --- a/crates/agent-gui/src/pages/chat/gateway/gatewayBridgeTypes.ts +++ b/crates/agent-gui/src/pages/chat/gateway/gatewayBridgeTypes.ts @@ -1,4 +1,5 @@ import type { MutableRefObject } from "react"; +import type { MentionComposerDraft } from "../../../components/chat/MentionComposer"; import type { HistoryMessageRef } from "../../../lib/chat/conversation/conversationState"; import type { ChatHistorySummary } from "../../../lib/chat/history/chatHistory"; import type { PendingUploadedFile } from "../../../lib/chat/messages/uploadedFiles"; @@ -76,6 +77,7 @@ export type ActiveGatewayBridgeRequest = { export type SendChatAction = (overrides?: { textOverride?: string; + composerDraftOverride?: MentionComposerDraft; uploadedFilesOverride?: PendingUploadedFile[]; conversationIdOverride?: string; executionModeOverride?: ExecutionMode; @@ -83,9 +85,10 @@ export type SendChatAction = (overrides?: { selectedSystemToolIdsOverride?: SystemToolId[]; runtimeControlsOverride?: ChatRuntimeControls; gatewayBridgeRequestOverride?: ActiveGatewayBridgeRequest | null; + preserveComposerOnStart?: boolean; beforeRuntimeStart?: () => Promise; afterInitialHistoryPersist?: () => Promise; -}) => Promise; +}) => Promise; export type GatewayBridgeRuntimeRefs = { currentConversationIdRef: MutableRefObject; diff --git a/crates/agent-gui/src/pages/chat/index.ts b/crates/agent-gui/src/pages/chat/index.ts index 00b2d9c0..55a69c94 100644 --- a/crates/agent-gui/src/pages/chat/index.ts +++ b/crates/agent-gui/src/pages/chat/index.ts @@ -1,4 +1,4 @@ -export { ChatComposerBar } from "./components/ChatComposerBar"; +export { ChatComposerBar, type ChatQueueTurnPreview } from "./components/ChatComposerBar"; export { ChatHeader } from "./components/ChatHeader"; export type { ActiveGatewayBridgeRequest, diff --git a/crates/agent-gui/src/pages/chat/queue/chatTurnQueue.ts b/crates/agent-gui/src/pages/chat/queue/chatTurnQueue.ts new file mode 100644 index 00000000..64d70a12 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/queue/chatTurnQueue.ts @@ -0,0 +1,193 @@ +import type { MentionComposerDraft } from "../../../components/chat/MentionComposer"; +import type { PendingUploadedFile } from "../../../lib/chat/messages/uploadedFiles"; +import type { ChatRuntimeControls, ExecutionMode, SystemToolId } from "../../../lib/settings"; + +export type QueuedChatTurn = { + id: string; + conversationId: string; + draft: MentionComposerDraft; + uploadedFiles: PendingUploadedFile[]; + executionMode: ExecutionMode; + workdir: string; + selectedSystemToolIds: SystemToolId[]; + runtimeControls: ChatRuntimeControls; + createdAt: number; +}; + +export type QueuedChatTurnInput = Omit & { + createdAt?: number; + id?: string; +}; + +export type QueuedChatTurnEditSlot = { + conversationId: string; + previousId: string | null; + nextId: string | null; + index?: number; +}; + +export function createQueuedChatTurn(input: QueuedChatTurnInput): QueuedChatTurn { + const createdAt = input.createdAt ?? Date.now(); + return { + id: input.id?.trim() || `queued-chat-${createdAt}-${Math.random().toString(36).slice(2, 8)}`, + conversationId: input.conversationId.trim(), + draft: input.draft, + uploadedFiles: input.uploadedFiles.slice(), + executionMode: input.executionMode, + workdir: input.workdir.trim(), + selectedSystemToolIds: input.selectedSystemToolIds.slice(), + runtimeControls: { ...input.runtimeControls }, + createdAt, + }; +} + +export function queuedChatTurnHasContent( + draft: MentionComposerDraft | null | undefined, + uploadedFiles: readonly PendingUploadedFile[], +): draft is MentionComposerDraft { + return Boolean(draft && (!draft.isEmpty || draft.text.trim() || uploadedFiles.length > 0)); +} + +export function buildQueuedChatTurnPreview(draft: MentionComposerDraft) { + const parts = draft.segments.map((segment) => { + switch (segment.type) { + case "largePaste": + return segment.paste.label; + case "skillMention": + return `$${segment.skill.name}`; + case "commitMention": + return segment.commit.subject || segment.commit.shortSha || segment.commit.sha; + case "gitFileMention": + return segment.file.path; + case "text": + return segment.text; + } + return ""; + }); + return parts.join("").replace(/\s+/g, " ").trim() || draft.text.replace(/\s+/g, " ").trim(); +} + +function withoutTurn(queue: readonly QueuedChatTurn[], id: string) { + const key = id.trim(); + return queue.filter((item) => item.id !== key); +} + +export function appendQueuedChatTurn( + queue: readonly QueuedChatTurn[], + item: QueuedChatTurn, +): QueuedChatTurn[] { + return [...withoutTurn(queue, item.id), item]; +} + +export function prependQueuedChatTurn( + queue: readonly QueuedChatTurn[], + item: QueuedChatTurn, +): QueuedChatTurn[] { + return [item, ...withoutTurn(queue, item.id)]; +} + +export function resolveQueuedChatTurnSlotIndex( + queue: readonly QueuedChatTurn[], + slot: QueuedChatTurnEditSlot, +) { + const compactQueue = queue.slice(); + const nextIndex = slot.nextId ? compactQueue.findIndex((item) => item.id === slot.nextId) : -1; + if (nextIndex >= 0) return nextIndex; + + const previousIndex = slot.previousId + ? compactQueue.findIndex((item) => item.id === slot.previousId) + : -1; + if (previousIndex >= 0) return previousIndex + 1; + + if (Number.isInteger(slot.index) && slot.index !== undefined && slot.index >= 0) { + let scopedIndex = 0; + let lastConversationIndex = -1; + for (let index = 0; index < compactQueue.length; index += 1) { + if (compactQueue[index]?.conversationId !== slot.conversationId) continue; + if (scopedIndex >= slot.index) return index; + scopedIndex += 1; + lastConversationIndex = index; + } + if (lastConversationIndex >= 0) return lastConversationIndex + 1; + } + + const firstConversationIndex = compactQueue.findIndex( + (item) => item.conversationId === slot.conversationId, + ); + if (firstConversationIndex >= 0) return firstConversationIndex; + return compactQueue.length; +} + +export function insertQueuedChatTurnAtSlot( + queue: readonly QueuedChatTurn[], + item: QueuedChatTurn, + slot: QueuedChatTurnEditSlot, +): QueuedChatTurn[] { + const next = withoutTurn(queue, item.id); + const index = resolveQueuedChatTurnSlotIndex(next, slot); + return [...next.slice(0, index), item, ...next.slice(index)]; +} + +export function removeQueuedChatTurn( + queue: readonly QueuedChatTurn[], + id: string, +): QueuedChatTurn[] { + return withoutTurn(queue, id); +} + +export function removeQueuedChatTurnsForConversation( + queue: readonly QueuedChatTurn[], + conversationId: string, +): QueuedChatTurn[] { + const key = conversationId.trim(); + if (!key) return queue.slice(); + return queue.filter((item) => item.conversationId !== key); +} + +export function moveQueuedChatTurn( + queue: readonly QueuedChatTurn[], + id: string, + direction: "up" | "down", +): QueuedChatTurn[] { + const key = id.trim(); + const index = queue.findIndex((item) => item.id === key); + if (index < 0) return queue.slice(); + const item = queue[index]; + let swapIndex = index; + while (true) { + swapIndex = direction === "up" ? swapIndex - 1 : swapIndex + 1; + if (swapIndex < 0 || swapIndex >= queue.length) return queue.slice(); + if (queue[swapIndex]?.conversationId === item?.conversationId) break; + } + if (swapIndex < 0 || swapIndex >= queue.length) return queue.slice(); + const next = queue.slice(); + [next[index], next[swapIndex]] = [next[swapIndex], next[index]]; + return next; +} + +export function promoteQueuedChatTurn( + queue: readonly QueuedChatTurn[], + id: string, +): QueuedChatTurn[] { + const key = id.trim(); + const item = queue.find((candidate) => candidate.id === key); + if (!item) return queue.slice(); + return prependQueuedChatTurn(queue, item); +} + +export function takeNextQueuedChatTurn( + queue: readonly QueuedChatTurn[], + conversationId: string, +): { item: QueuedChatTurn | null; queue: QueuedChatTurn[] } { + const key = conversationId.trim(); + if (!key) return { item: null, queue: queue.slice() }; + const index = queue.findIndex((item) => item.conversationId === key); + if (index < 0) return { item: null, queue: queue.slice() }; + const next = queue.slice(); + const [item] = next.splice(index, 1); + return { item: item ?? null, queue: next }; +} + +export function getQueuedConversationIds(queue: readonly QueuedChatTurn[]) { + return Array.from(new Set(queue.map((item) => item.conversationId).filter(Boolean))); +} diff --git a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx index 46f9e1c5..f3680dd3 100644 --- a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx @@ -43,6 +43,7 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript usageContextWindow, liveTranscriptStore, isCompactionRunning, + bottomReservePx = 0, copiedMessageKey, setCopiedMessageKey, onResendFromEdit, @@ -52,6 +53,9 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript const showNoModelsState = !hasModels; const showStartChatState = hasModels && historyItems.length === 0 && !isSending; const shouldReserveTranscriptBottomSpace = !(showNoModelsState || showStartChatState); + const transcriptBottomReservePx = shouldReserveTranscriptBottomSpace + ? Math.max(192, Math.ceil(bottomReservePx) + 12) + : 0; const [scrollViewport, setScrollViewport] = useState(null); const transcriptRootRef = useRef(null); const transcriptContextMenuRef = useRef(null); @@ -239,7 +243,7 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript />
-
+
{transcriptContextMenu && transcriptContextMenuPosition diff --git a/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts b/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts index 38e82f21..32ea2711 100644 --- a/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts +++ b/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts @@ -24,6 +24,7 @@ export type ChatTranscriptProps = { usageContextWindow?: number; liveTranscriptStore: LiveTranscriptStore; isCompactionRunning: boolean; + bottomReservePx?: number; copiedMessageKey: string | null; setCopiedMessageKey: (key: string | null) => void; onResendFromEdit: ( diff --git a/crates/agent-gui/test/chat/chat-turn-queue.test.mjs b/crates/agent-gui/test/chat/chat-turn-queue.test.mjs new file mode 100644 index 00000000..6dff1cd8 --- /dev/null +++ b/crates/agent-gui/test/chat/chat-turn-queue.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const queue = loader.loadModule("src/pages/chat/queue/chatTurnQueue.ts"); + +function draft(text, segments = [{ type: "text", text }]) { + return { + segments, + text, + textWithoutLargePastes: text, + largePastes: [], + skillMentions: [], + commitMentions: [], + gitFileMentions: [], + isEmpty: text.trim() === "", + }; +} + +function turn(id, conversationId, text) { + return queue.createQueuedChatTurn({ + id, + conversationId, + draft: draft(text), + uploadedFiles: [], + executionMode: "tools", + workdir: "/workspace", + selectedSystemToolIds: ["shell"], + runtimeControls: { + thinkingEnabled: false, + reasoning: "off", + nativeWebSearchEnabled: false, + }, + createdAt: 1, + }); +} + +test("queued chat turns append, promote, remove, and take the next turn", () => { + const first = turn("a1", "conversation-a", "first"); + const second = turn("a2", "conversation-a", "second"); + + const appended = queue.appendQueuedChatTurn(queue.appendQueuedChatTurn([], first), second); + assert.deepEqual( + appended.map((item) => item.id), + ["a1", "a2"], + ); + + const promoted = queue.promoteQueuedChatTurn(appended, "a2"); + assert.deepEqual( + promoted.map((item) => item.id), + ["a2", "a1"], + ); + + const taken = queue.takeNextQueuedChatTurn(promoted, "conversation-a"); + assert.equal(taken.item.id, "a2"); + assert.deepEqual( + taken.queue.map((item) => item.id), + ["a1"], + ); + + assert.deepEqual(queue.removeQueuedChatTurn(taken.queue, "a1"), []); +}); + +test("queued chat turn movement stays scoped to the same conversation", () => { + const mixed = [ + turn("a1", "conversation-a", "a one"), + turn("b1", "conversation-b", "b one"), + turn("a2", "conversation-a", "a two"), + ]; + + const moved = queue.moveQueuedChatTurn(mixed, "a2", "up"); + assert.deepEqual( + moved.map((item) => item.id), + ["a2", "b1", "a1"], + ); +}); + +test("edited queued chat turns return to their original priority slot", () => { + const first = turn("a1", "conversation-a", "first"); + const second = turn("a2", "conversation-a", "second"); + const third = turn("a3", "conversation-a", "third"); + const editedSecond = turn("a2", "conversation-a", "edited second"); + + const reinserted = queue.insertQueuedChatTurnAtSlot([first, third], editedSecond, { + conversationId: "conversation-a", + previousId: "a1", + nextId: "a3", + index: 1, + }); + + assert.deepEqual( + reinserted.map((item) => item.id), + ["a1", "a2", "a3"], + ); + assert.equal(reinserted[1].draft.text, "edited second"); +}); + +test("edited queued chat turns keep their scoped priority when anchors disappear", () => { + const remaining = turn("a4", "conversation-a", "remaining"); + const editedSecond = turn("a2", "conversation-a", "edited second"); + + const reinserted = queue.insertQueuedChatTurnAtSlot([remaining], editedSecond, { + conversationId: "conversation-a", + previousId: "missing-previous", + nextId: null, + index: 1, + }); + + assert.deepEqual( + reinserted.map((item) => item.id), + ["a4", "a2"], + ); +}); + +test("queued chat turn preview keeps structured draft hints compact", () => { + const richDraft = draft("hello long paste", [ + { type: "text", text: "hello " }, + { + type: "largePaste", + paste: { + id: "paste-1", + label: "pasted.txt", + text: "large paste body", + charCount: 16, + lineCount: 1, + preview: "large paste body", + }, + }, + { + type: "skillMention", + skill: { + name: "reviewer", + description: "", + skillFile: "SKILL.md", + baseDir: "/skills/reviewer", + }, + }, + ]); + + assert.equal(queue.buildQueuedChatTurnPreview(richDraft), "hello pasted.txt$reviewer"); + assert.equal(queue.queuedChatTurnHasContent(richDraft, []), true); + assert.equal(queue.queuedChatTurnHasContent(draft(""), [{ fileName: "a.txt" }]), true); +}); From 28352efd78306befe4b35838624a34f5b75ffd08 Mon Sep 17 00:00:00 2001 From: coder-hhx <51896858+coder-hhx@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:55:43 +0800 Subject: [PATCH 2/2] fix(gui): sandbox html preview storage --- .../WorkspaceFilePreviewOverlay.tsx | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/agent-gui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx b/crates/agent-gui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx index 680814b5..90c2f87b 100644 --- a/crates/agent-gui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx +++ b/crates/agent-gui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx @@ -153,6 +153,61 @@ function hashString(value: string) { return Math.abs(hash).toString(36); } +const SANDBOXED_HTML_PREVIEW_BOOTSTRAP = [ + '