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/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 = [ + '