From 2fe3382279c02bdc0963d92c3ba27aaf5e7dc249 Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Tue, 18 Aug 2026 23:43:16 +0000 Subject: [PATCH] FN-020: add queued Planner Chat message management Add browser-local queued follow-up management to Planner Chat while preserving ordered, session-scoped dispatch. - Queue follow-up messages during active responses and dispatch them FIFO after completion or Stop reconciliation. - Add editing, reordering, deletion, duplicate-safe force-send, persistence, responsive styling, documentation, translations, and regression coverage. Files changed: docs/dashboard-guide.md | 2 + .../app/components/TaskPlannerChatTab.css | 84 ++++++ .../app/components/TaskPlannerChatTab.tsx | 301 ++++++++++++++++++--- .../__tests__/TaskPlannerChatTab.test.tsx | 216 +++++++++++++++ packages/i18n/locales/en/app.json | 13 + packages/i18n/locales/es/app.json | 13 + packages/i18n/locales/fr/app.json | 13 + packages/i18n/locales/ko/app.json | 13 + packages/i18n/locales/pt-BR/app.json | 13 + packages/i18n/locales/zh-CN/app.json | 13 + packages/i18n/locales/zh-TW/app.json | 13 + packages/i18n/src/resources.d.ts | 13 + 12 files changed, 673 insertions(+), 34 deletions(-) Fusion-Task-Id: FN-020 Fusion-Task-Lineage: 5627cdbd-00dd-4e17-a4bd-53d49efa836d Co-authored-by: Fusion --- docs/dashboard-guide.md | 2 + .../app/components/TaskPlannerChatTab.css | 84 +++++ .../app/components/TaskPlannerChatTab.tsx | 301 ++++++++++++++++-- .../__tests__/TaskPlannerChatTab.test.tsx | 216 +++++++++++++ packages/i18n/locales/en/app.json | 13 + packages/i18n/locales/es/app.json | 13 + packages/i18n/locales/fr/app.json | 13 + packages/i18n/locales/ko/app.json | 13 + packages/i18n/locales/pt-BR/app.json | 13 + packages/i18n/locales/zh-CN/app.json | 13 + packages/i18n/locales/zh-TW/app.json | 13 + packages/i18n/src/resources.d.ts | 13 + 12 files changed, 673 insertions(+), 34 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index b530995ef0..69723dc6b4 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -743,6 +743,8 @@ Mailbox Inbox, Outbox, and agent lists exclude archived correspondence and unrea - Task-detail planner Chat conversations stay available from each task's **Chat** tab, including after the task is `done`. They are hidden from the common Direct/common Chat feed by default; enable **Settings → Project General → Show task chats in common Chat feed** to include populated task chats again. Empty task chat sessions stay hidden either way. Planner Chat can answer token-count, estimated-cost, runtime, timing-event, workflow-step duration, and per-model usage questions for the current task through a read-only task-scoped metrics tool; unknown/stale pricing is reported as uncertain instead of `$0`. On completed tasks, clear follow-up implementation or improvement requests can create a normal refinement task from the completed source task. + +- While a Planner Chat response streams, additional text turns remain sendable and appear in a pending-message list above the composer. The list persists FIFO order in browser storage for that planner session and supports editing, moving earlier/later, deleting, and selecting **Force send**. Ordinary completion or Stop sends the next front entry one at a time. **Force send** first closes the active stream, waits for the durable cancellation response and transcript reconciliation, then sends only the selected entry; if cancellation, reconciliation, or dispatch fails, the entry remains queued for retry. Queues are session-scoped and are not shared with Activity/Live steering comments or another task's Planner Chat. - On desktop/tablet Direct chat, the thread header shows an estimated token count against the active model's known context window (for example `~12.3k / 200k`). It is hidden on mobile, narrow floating chat, rooms, and unknown-context-window models. diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.css b/packages/dashboard/app/components/TaskPlannerChatTab.css index 6030352a2f..1f956b23f6 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.css +++ b/packages/dashboard/app/components/TaskPlannerChatTab.css @@ -210,6 +210,77 @@ Mobile Planner Chat should match regular task chat: keep the composer as a singl FNXC:TaskDetailPlannerChat 2026-07-07-00:00: The Planner Chat streaming Stop button must occupy the same width footprint as the Send button it replaces (no shift/shrink on swap) and mirror the regular Chat view's stop-button sizing (`.chat-input-row`'s `--chat-input-control-size` in ChatView.css). The shared `.chat-input-send` / `.chat-input-stop` classes read that custom property from `.chat-input-row`, which the Planner composer never renders inside of, so the property was undefined here and `width` fell back to `auto`, sizing each button from its own content only. Declare the same control-size formula scoped to `.task-planner-chat-composer` and give `.task-planner-chat-send` (present on both the send and stop button variants) a matching `min-inline-size` floor so neither button can render narrower than the other on desktop, without touching ChatView.css's own token. */ +/* +FNXC:TaskPlannerChatQueue 2026-08-18-23:13: +Planner follow-ups need a visible management surface above the composer without changing the transcript's scroll ownership. Rows wrap their controls on narrow screens so every edit, ordering, delete, and force-send action remains reachable on touch. +*/ +.task-planner-chat-pending { + display: flex; + flex: 0 0 auto; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; +} + +.task-planner-chat-pending-divider { + border-top: var(--btn-border-width) solid var(--border); +} + +.task-planner-chat-pending-heading { + margin: 0; + color: var(--text-muted); + font-size: var(--font-size-sm); + font-weight: 600; +} + +.task-planner-chat-pending-items { + display: flex; + flex-direction: column; + gap: var(--space-xs); + max-height: calc(var(--space-xl) * 6); + margin: 0; + padding-inline-start: var(--space-lg); + overflow-y: auto; +} + +.task-planner-chat-pending-item { + display: flex; + align-items: center; + gap: var(--space-sm); + min-width: 0; + color: var(--text); +} + +.task-planner-chat-pending-text { + min-width: 0; + flex: 1 1 auto; + overflow-wrap: anywhere; +} + +.task-planner-chat-pending-edit-input { + min-width: 0; + flex: 1 1 auto; + padding: var(--space-xs) var(--space-sm); +} + +.task-planner-chat-pending-actions { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + align-items: center; + gap: var(--space-xs); +} + +.task-planner-chat-pending-actions .btn-icon { + flex: 0 0 auto; +} + +.task-planner-chat-pending-force { + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + .task-planner-chat-composer { /* FN-7634: same formula as ChatView.css's `.chat-input-row { --chat-input-control-size: … }` so the Planner stop/send buttons share the regular Chat view's control-size floor. */ --chat-input-control-size: calc(var(--space-lg) * 2.5); @@ -254,6 +325,19 @@ height rather than relying on the global button minimum. min-height: 0; } + .task-planner-chat-pending-items { + max-height: calc(var(--space-xl) * 5); + } + + .task-planner-chat-pending-item { + align-items: flex-start; + flex-direction: column; + } + + .task-planner-chat-pending-actions { + width: 100%; + } + .task-planner-chat-expand-toggle { min-inline-size: var(--space-2xl); min-block-size: var(--space-2xl); diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index fda6b2119f..d81c38519a 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -2,10 +2,11 @@ import type { ChatInFlightGenerationState, ChatMessage, ResolvedModelSelection, import { isWipColumnRole } from "../utils/columnRoles"; import { getErrorMessage } from "@fusion/core"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Loader2, Maximize2, Minimize2 } from "lucide-react"; +import { ArrowDown, ArrowUp, Check, Loader2, Maximize2, Minimize2, Pencil, Send, Trash2, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { ToastType } from "../hooks/useToast"; import { useComposerDictation } from "../hooks/useComposerDictation"; +import { getPersistedPendingChatMessages, setPersistedPendingChatMessages } from "../hooks/chatPendingMessageStorage"; import { MicButton } from "./MicButton"; import type { ChatMessageInfo, ToolCallInfo } from "../hooks/chatTypes"; import { attachChatStream, cancelChatResponse, editChatMessage, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, type ChatFailureInfo, type ChatStreamErrorMeta } from "../api"; @@ -32,6 +33,12 @@ interface TaskPlannerChatTabProps { type ComposerState = "idle" | "sending"; +type PendingQueueReservation = { + sessionId: string; + text: string; + index: number; +}; + type PlannerQuestionRenderState = { parsed: ParsedQuestionToolCall; answered: boolean; @@ -55,6 +62,10 @@ function isTranscriptNearBottom(container: HTMLElement): boolean { return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD; } +function normalizePendingMessages(messages: readonly string[]): string[] { + return messages.map((message) => message.trim()).filter(Boolean); +} + const TASK_PLANNER_CHAT_STARTER_PROMPTS: StarterPromptDefinition[] = [ { id: "recent-activity", @@ -316,6 +327,10 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan const [sessionId, setSessionId] = useState(null); const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(""); + const [pendingMessages, setPendingMessages] = useState([]); + const [editingPendingIndex, setEditingPendingIndex] = useState(null); + const [editingPendingText, setEditingPendingText] = useState(""); + const [queueActionPending, setQueueActionPending] = useState(false); const composerTextareaRef = useRef(null); const dictation = useComposerDictation({ textareaRef: composerTextareaRef, value: draft, onChange: setDraft, projectId }); const [showCommandMenu, setShowCommandMenu] = useState(false); @@ -328,6 +343,9 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan const [historyLoaded, setHistoryLoaded] = useState(false); const [error, setError] = useState(null); const streamRef = useRef<{ close: () => void } | null>(null); + const pendingMessagesRef = useRef([]); + const sessionIdRef = useRef(null); + const queueDispatchRef = useRef<((sessionId: string, selectedIndex?: number) => void) | null>(null); const streamSnapshotRef = useRef<{ requestId: number; sessionId: string; @@ -363,6 +381,21 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan }, [planningModelId, planningModelProvider]); const plannerChatScopeKey = `${task.id}\u0000${projectId ?? ""}\u0000${planningModelProvider ?? ""}\u0000${planningModelId ?? ""}`; + const replacePendingMessages = useCallback((nextMessages: readonly string[], resolvedSessionId = sessionIdRef.current) => { + const normalizedMessages = normalizePendingMessages(nextMessages); + pendingMessagesRef.current = normalizedMessages; + setPendingMessages(normalizedMessages); + setPersistedPendingChatMessages(resolvedSessionId, normalizedMessages); + }, []); + + const restorePendingQueueReservation = useCallback((reservation: PendingQueueReservation) => { + if (sessionIdRef.current !== reservation.sessionId) return; + const current = pendingMessagesRef.current; + const insertionIndex = Math.min(Math.max(reservation.index, 0), current.length); + const next = [...current.slice(0, insertionIndex), reservation.text, ...current.slice(insertionIndex)]; + replacePendingMessages(next, reservation.sessionId); + }, [replacePendingMessages]); + /* * FNXC:TaskPlannerChatSlashCommands 2026-07-08-00:00: * /steer is only dispatchable when this task's bound agent is actively @@ -429,8 +462,9 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan inFlightGeneration?: ChatInFlightGenerationState | null; requestId: number; attach: boolean; + queueReservation?: PendingQueueReservation; }) => { - const { resolvedSessionId, content = "", inFlightGeneration, requestId, attach } = options; + const { resolvedSessionId, content = "", inFlightGeneration, requestId, attach, queueReservation } = options; const isCurrentStreamRequest = () => streamRequestRef.current === requestId; const inFlightSnapshot = attach ? inFlightGeneration : null; let accumulated = inFlightSnapshot?.streamingText ?? ""; @@ -520,6 +554,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan } else { void refreshMessagesForSession(resolvedSessionId, isCurrentStreamRequest, { mergeOptimistic: Boolean(content) }); } + queueDispatchRef.current?.(resolvedSessionId); }, onError: (streamError: string | ChatFailureInfo, meta?: ChatStreamErrorMeta) => { if (!isCurrentStreamRequest()) return; @@ -537,8 +572,12 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan } return withoutStreaming; }); - if (meta?.requestAccepted === false) return; + if (meta?.requestAccepted === false) { + if (queueReservation) restorePendingQueueReservation(queueReservation); + return; + } void refreshMessagesForSession(resolvedSessionId, isCurrentStreamRequest, { mergeOptimistic: Boolean(content) }); + queueDispatchRef.current?.(resolvedSessionId); }, }; @@ -559,7 +598,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan projectId, { taskId: task.id }, ); - }, [applyStreamingSnapshot, projectId, refreshMessagesForSession, refreshTaskAfterSteering, task.id, t]); + }, [applyStreamingSnapshot, projectId, refreshMessagesForSession, refreshTaskAfterSteering, restorePendingQueueReservation, task.id, t]); const loadSession = useCallback(async () => { const requestId = loadRequestRef.current + 1; @@ -571,12 +610,16 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan const { session: lookupSession } = await fetchTaskPlannerChatSession(task.id, modelPayload, projectId); if (loadRequestRef.current !== requestId) return; if (!lookupSession) { + sessionIdRef.current = null; setSessionId(null); + replacePendingMessages([], null); setMessages([]); setHistoryLoaded(true); return; } + sessionIdRef.current = lookupSession.id; setSessionId(lookupSession.id); + replacePendingMessages(getPersistedPendingChatMessages(lookupSession.id), lookupSession.id); const [{ messages: loadedMessages }, refreshedSessionResult] = await Promise.all([ fetchChatMessages(lookupSession.id, { order: "asc" }, projectId), fetchChatSession(lookupSession.id, projectId).catch(() => ({ session: lookupSession })), @@ -594,6 +637,8 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan requestId: streamRequestId, attach: true, }); + } else { + queueDispatchRef.current?.(lookupSession.id); } } catch (err) { if (loadRequestRef.current !== requestId) return; @@ -605,15 +650,20 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan setLoading(false); } } - }, [modelPayload, projectId, startPlannerStream, task.id, t]); + }, [modelPayload, projectId, replacePendingMessages, startPlannerStream, task.id, t]); useEffect(() => { loadRequestRef.current += 1; streamRequestRef.current += 1; streamRef.current?.close(); streamRef.current = null; + sessionIdRef.current = null; setSessionId(null); - setMessages([]); + pendingMessagesRef.current = []; + setPendingMessages([]); + setEditingPendingIndex(null); + setEditingPendingText(""); + setQueueActionPending(false); setDraft(""); composerStateRef.current = "idle"; setStreamingThinking(""); @@ -690,9 +740,56 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan previousMessageCountRef.current = messages.length; }, [active, anchorTranscriptToBottom, composerState, isTranscriptAtBottom, messages, setTranscriptAtBottom]); + const enqueuePendingMessage = useCallback((messageContent: string) => { + const content = messageContent.trim(); + if (!content) return; + const resolvedSessionId = sessionIdRef.current; + replacePendingMessages([...pendingMessagesRef.current, content], resolvedSessionId); + setDraft(""); + setError(null); + }, [replacePendingMessages]); + + const dispatchQueuedMessage = useCallback((resolvedSessionId: string, selectedIndex = 0) => { + if (sessionIdRef.current !== resolvedSessionId || composerStateRef.current === "sending" || cancellationInProgressRef.current) return; + const current = pendingMessagesRef.current; + const content = current[selectedIndex]?.trim(); + if (!content) return; + + const reservation: PendingQueueReservation = { sessionId: resolvedSessionId, text: content, index: selectedIndex }; + replacePendingMessages(current.filter((_, index) => index !== selectedIndex), resolvedSessionId); + const streamRequestId = streamRequestRef.current + 1; + streamRequestRef.current = streamRequestId; + composerStateRef.current = "sending"; + setComposerState("sending"); + setError(null); + setMessages((currentMessages) => [...currentMessages, makeOptimisticUserMessage(resolvedSessionId, content)]); + try { + startPlannerStream({ + resolvedSessionId, + content, + requestId: streamRequestId, + attach: false, + queueReservation: reservation, + }); + } catch (err) { + restorePendingQueueReservation(reservation); + composerStateRef.current = "idle"; + setComposerState("idle"); + const message = getErrorMessage(err) || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond"); + setError(message); + addToastRef.current(message, "error"); + } + }, [restorePendingQueueReservation, replacePendingMessages, startPlannerStream, t]); + + queueDispatchRef.current = dispatchQueuedMessage; + const sendMessageContent = useCallback(async (messageContent: string) => { const content = messageContent.trim(); - if (!content || composerStateRef.current === "sending") return; + if (!content) return; + if (composerStateRef.current === "sending" || cancellationInProgressRef.current) { + enqueuePendingMessage(content); + return; + } composerStateRef.current = "sending"; const streamRequestId = streamRequestRef.current + 1; @@ -704,12 +801,17 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan setError(null); try { - const { session } = sessionId - ? { session: { id: sessionId } } + const { session } = sessionIdRef.current + ? { session: { id: sessionIdRef.current } } : await ensureTaskPlannerChatSession(task.id, modelPayload, projectId); if (!isCurrentStreamRequest()) return; const resolvedSessionId = session.id; + sessionIdRef.current = resolvedSessionId; setSessionId(resolvedSessionId); + // FNXC:TaskPlannerChatQueue 2026-08-18-23:13: + // Planner queue entries are browser-local and keyed by the resolved session. Persist any + // follow-up typed before session creation completes only after that session becomes known. + replacePendingMessages(pendingMessagesRef.current, resolvedSessionId); setMessages((current) => [...current, makeOptimisticUserMessage(resolvedSessionId, content)]); if (!isCurrentStreamRequest()) return; startPlannerStream({ @@ -727,7 +829,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan setComposerState("idle"); setStreamingThinking(""); } - }, [addToast, modelPayload, projectId, sessionId, startPlannerStream, task.id, t]); + }, [addToast, enqueuePendingMessage, modelPayload, projectId, replacePendingMessages, startPlannerStream, task.id, t]); const refreshTaskAfterEdit = useCallback(async (hadDiscardedSideEffect: boolean) => { try { @@ -869,11 +971,9 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan } }, []); - const stopPlannerStreaming = useCallback(() => { + const cancelPlannerGeneration = useCallback((snapshot: NonNullable, selectedIndex?: number) => { if (cancellationInProgressRef.current) return; - const snapshot = streamSnapshotRef.current; - if (!snapshot) return; - + setQueueActionPending(true); streamRequestRef.current += 1; streamRef.current?.close(); streamRef.current = null; @@ -907,39 +1007,116 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan .then(async (result) => { const cancellationResult = result ?? { success: true, interrupted: false }; if (!cancellationResult.success) { - throw new Error("Planner chat cancellation did not complete"); + throw new Error(t("taskDetail.plannerChat.cancelFailed", "Failed to save the interrupted planner response")); } - let refreshed: ChatMessage[] | null = null; - try { - refreshed = (await fetchChatMessages(snapshot.sessionId, { order: "asc" }, projectId)).messages; - } catch { - // Keep the local interrupted bubble if the history read is temporarily unavailable. - } + // Reconciliation is part of the cancellation barrier: queued text is not released + // until the durable interrupted assistant row can be read back from chat history. + const refreshed = (await fetchChatMessages(snapshot.sessionId, { order: "asc" }, projectId)).messages; + if (sessionIdRef.current !== snapshot.sessionId) return; const persisted = cancellationResult.message ? [cancellationResult.message] : []; - if (refreshed || persisted.length > 0) { - const reconciled = [ - ...(refreshed ?? []), - ...persisted.filter((message) => !(refreshed ?? []).some((candidate) => candidate.id === message.id)), - ]; - setMessages((current) => mergePlannerTranscriptWithOptimistic( - current.filter((message) => message.id !== interruptedLocalId), - reconciled, - )); + const reconciled = [ + ...refreshed, + ...persisted.filter((message) => !refreshed.some((candidate) => candidate.id === message.id)), + ]; + setMessages((current) => mergePlannerTranscriptWithOptimistic( + current.filter((message) => message.id !== interruptedLocalId), + reconciled, + )); + + if (cancellationInProgressRef.current === cancellation) { + cancellationInProgressRef.current = null; } + queueDispatchRef.current?.(snapshot.sessionId, selectedIndex); }) .catch((cancelError) => { - addToastRef.current(getErrorMessage(cancelError) || t("taskDetail.plannerChat.cancelFailed", "Failed to save the interrupted planner response"), "error"); + if (sessionIdRef.current === snapshot.sessionId) { + const message = getErrorMessage(cancelError) || t("taskDetail.plannerChat.cancelFailed", "Failed to save the interrupted planner response"); + setError(message); + addToastRef.current(message, "error"); + } }) .finally(() => { streamSnapshotRef.current = null; if (cancellationInProgressRef.current === cancellation) { cancellationInProgressRef.current = null; } + setQueueActionPending(false); }); cancellationInProgressRef.current = cancellation; }, [projectId, t]); + const stopPlannerStreaming = useCallback(() => { + const snapshot = streamSnapshotRef.current; + if (!snapshot) return; + cancelPlannerGeneration(snapshot); + }, [cancelPlannerGeneration]); + + const beginPendingEdit = useCallback((index: number) => { + if (queueActionPending || !pendingMessagesRef.current[index]) return; + setEditingPendingIndex(index); + setEditingPendingText(pendingMessagesRef.current[index] ?? ""); + }, [queueActionPending]); + + const savePendingEdit = useCallback((index: number) => { + const content = editingPendingText.trim(); + if (!content) { + const message = t("taskDetail.plannerChat.pendingEditEmpty", "Queued messages cannot be empty"); + setError(message); + addToastRef.current(message, "warning"); + return; + } + const current = pendingMessagesRef.current; + if (index < 0 || index >= current.length) return; + const next = current.map((pendingMessage, pendingIndex) => pendingIndex === index ? content : pendingMessage); + replacePendingMessages(next, sessionIdRef.current); + setEditingPendingIndex(null); + setEditingPendingText(""); + setError(null); + }, [editingPendingText, replacePendingMessages, t]); + + const cancelPendingEdit = useCallback(() => { + setEditingPendingIndex(null); + setEditingPendingText(""); + }, []); + + const movePendingMessage = useCallback((index: number, direction: -1 | 1) => { + if (queueActionPending) return; + const targetIndex = index + direction; + const current = pendingMessagesRef.current; + if (index < 0 || targetIndex < 0 || targetIndex >= current.length) return; + const next = [...current]; + [next[index], next[targetIndex]] = [next[targetIndex]!, next[index]!]; + replacePendingMessages(next, sessionIdRef.current); + if (editingPendingIndex === index) setEditingPendingIndex(targetIndex); + else if (editingPendingIndex === targetIndex) setEditingPendingIndex(index); + }, [editingPendingIndex, queueActionPending, replacePendingMessages]); + + const deletePendingMessage = useCallback((index: number) => { + if (queueActionPending) return; + const current = pendingMessagesRef.current; + if (index < 0 || index >= current.length) return; + replacePendingMessages(current.filter((_, pendingIndex) => pendingIndex !== index), sessionIdRef.current); + if (editingPendingIndex === index) { + setEditingPendingIndex(null); + setEditingPendingText(""); + } else if (editingPendingIndex !== null && editingPendingIndex > index) { + setEditingPendingIndex(editingPendingIndex - 1); + } + }, [editingPendingIndex, queueActionPending, replacePendingMessages]); + + const forceSendPendingMessage = useCallback((index: number) => { + if (queueActionPending) return; + const resolvedSessionId = sessionIdRef.current; + if (!resolvedSessionId || !pendingMessagesRef.current[index]) return; + const snapshot = streamSnapshotRef.current; + if (snapshot) { + cancelPlannerGeneration(snapshot, index); + return; + } + queueDispatchRef.current?.(resolvedSessionId, index); + }, [cancelPlannerGeneration, queueActionPending]); + const handleKeyDown = useCallback((event: React.KeyboardEvent) => { if (showCommandMenu && event.key === "ArrowDown") { event.preventDefault(); @@ -977,7 +1154,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan void sendMessage(); }, [showCommandMenu, filteredCommands, highlightedCommandIndex, handleCommandMenuSelect, sendMessage]); - const canSend = draft.trim().length > 0 && composerState !== "sending"; + const canSend = draft.trim().length > 0 && composerState !== "sending" && !queueActionPending; const showEmptyState = historyLoaded && !loading && !error && messages.length === 0; const questionRenderStates = useMemo(() => buildPlannerQuestionRenderStates(messages), [messages]); const starterPrompts = useMemo(() => { @@ -1230,6 +1407,62 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan )} + {pendingMessages.length > 0 && ( +
+
+ )} + {showCommandMenu && (
- + { expect(screen.queryByRole("listbox", { name: /skill suggestions/i })).not.toBeInTheDocument(); }); }); + + describe("queued planner messages", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("queues follow-ups during a live reply and releases exactly one FIFO entry per completion", async () => { + const user = userEvent.setup(); + const streamHandlers: any[] = []; + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + streamHandlers.push(handlers); + return { close: vi.fn(), isConnected: () => true }; + }); + + renderPlannerChat(); + await screen.findByTestId("task-planner-chat-empty"); + await user.click(screen.getByRole("button", { name: /Summarize recent activity/ })); + await waitFor(() => expect(streamHandlers).toHaveLength(1)); + + const input = screen.getByLabelText("Message planner chat"); + for (const message of ["Follow-up A", "Follow-up B", "Follow-up B"]) { + await user.type(input, message); + await user.keyboard("{Enter}"); + } + + await waitFor(() => expect(screen.getAllByTestId(/task-planner-chat-pending-message-/)).toHaveLength(3)); + expect(JSON.parse(localStorage.getItem("fusion:chat-pending:chat-planner") ?? "null")).toEqual(["Follow-up A", "Follow-up B", "Follow-up B"]); + + act(() => streamHandlers[0].onDone({ + messageId: "assistant-1", + message: { id: "assistant-1", sessionId: "chat-planner", role: "assistant", content: "First answer", thinkingOutput: null, metadata: null, createdAt: "2026-08-18T22:00:00.000Z" }, + })); + await waitFor(() => expect(mockStreamChatResponse).toHaveBeenCalledTimes(2)); + expect(mockStreamChatResponse.mock.calls[1][1]).toBe("Follow-up A"); + expect(JSON.parse(localStorage.getItem("fusion:chat-pending:chat-planner") ?? "null")).toEqual(["Follow-up B", "Follow-up B"]); + + act(() => streamHandlers[1].onDone({ + messageId: "assistant-2", + message: { id: "assistant-2", sessionId: "chat-planner", role: "assistant", content: "Second answer", thinkingOutput: null, metadata: null, createdAt: "2026-08-18T22:01:00.000Z" }, + })); + await waitFor(() => expect(mockStreamChatResponse).toHaveBeenCalledTimes(3)); + expect(mockStreamChatResponse.mock.calls[2][1]).toBe("Follow-up B"); + expect(JSON.parse(localStorage.getItem("fusion:chat-pending:chat-planner") ?? "null")).toEqual(["Follow-up B"]); + + act(() => streamHandlers[2].onDone({ + messageId: "assistant-3", + message: { id: "assistant-3", sessionId: "chat-planner", role: "assistant", content: "Third answer", thinkingOutput: null, metadata: null, createdAt: "2026-08-18T22:02:00.000Z" }, + })); + await waitFor(() => expect(localStorage.getItem("fusion:chat-pending:chat-planner")).toBeNull()); + expect(screen.queryByTestId("task-planner-chat-pending-list")).not.toBeInTheDocument(); + }); + + it("hydrates a session queue without dispatching until an attached generation completes, then fences a task switch", async () => { + const attachedHandlers: any[] = []; + localStorage.setItem("fusion:chat-pending:chat-planner", JSON.stringify(["Restored follow-up"])); + mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: makePlannerSession({ inFlightGeneration: { generationId: "generation-1", streamingText: "Partial", streamingThinking: "", toolCalls: [] }, isGenerating: true }) }); + mockFetchChatSession.mockResolvedValueOnce({ session: makePlannerSession({ inFlightGeneration: { generationId: "generation-1", streamingText: "Partial", streamingThinking: "", toolCalls: [] }, isGenerating: true }) }); + mockAttachChatStream.mockImplementation((_sessionId, handlers) => { + attachedHandlers.push(handlers); + return { close: vi.fn(), isConnected: () => true }; + }); + + const { rerender } = renderPlannerChat(); + expect(await screen.findByText("Restored follow-up")).toBeInTheDocument(); + expect(mockStreamChatResponse).not.toHaveBeenCalled(); + expect(attachedHandlers).toHaveLength(1); + + mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: makePlannerSession({ id: "chat-other" }) }); + mockFetchChatSession.mockResolvedValueOnce({ session: makePlannerSession({ id: "chat-other" }) }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + rerender( + , + ); + await screen.findByTestId("task-planner-chat-empty"); + act(() => attachedHandlers[0].onDone({ messageId: "stale-attached" })); + expect(mockStreamChatResponse).not.toHaveBeenCalled(); + expect(screen.queryByText("Restored follow-up")).not.toBeInTheDocument(); + }); + + it("normalizes malformed persisted entries and never sends a blank request", async () => { + localStorage.setItem("fusion:chat-pending:chat-planner", JSON.stringify([" ", 42, " Valid restored text "])); + renderPlannerChat(); + await waitFor(() => expect(mockStreamChatResponse).toHaveBeenCalledTimes(1)); + expect(mockStreamChatResponse.mock.calls[0][1]).toBe("Valid restored text"); + expect(mockStreamChatResponse.mock.calls[0][1]).not.toBe(" "); + }); + + it("edits, reorders, deletes, and force-sends the selected duplicate occurrence", async () => { + const user = userEvent.setup(); + const streamHandlers: any[] = []; + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + streamHandlers.push(handlers); + return { close: vi.fn(), isConnected: () => true }; + }); + + renderPlannerChat(); + await screen.findByTestId("task-planner-chat-empty"); + await user.click(screen.getByRole("button", { name: /Summarize recent activity/ })); + const input = screen.getByLabelText("Message planner chat"); + for (const message of ["First", "Duplicate", "Duplicate"]) { + await user.type(input, message); + await user.keyboard("{Enter}"); + } + await waitFor(() => expect(screen.getAllByTestId(/task-planner-chat-pending-message-\d$/)).toHaveLength(3)); + + await user.click(screen.getByTestId("task-planner-chat-pending-edit-1")); + const editInput = screen.getByTestId("task-planner-chat-pending-message-1").querySelector("input"); + expect(editInput).not.toBeNull(); + await user.clear(editInput!); + await user.type(editInput!, "Edited duplicate"); + await user.click(screen.getByTestId("task-planner-chat-pending-save-1")); + expect(JSON.parse(localStorage.getItem("fusion:chat-pending:chat-planner") ?? "null")).toEqual(["First", "Edited duplicate", "Duplicate"]); + + expect(screen.getByTestId("task-planner-chat-pending-up-0")).toBeDisabled(); + expect(screen.getByTestId("task-planner-chat-pending-down-2")).toBeDisabled(); + await user.click(screen.getByTestId("task-planner-chat-pending-up-2")); + expect(JSON.parse(localStorage.getItem("fusion:chat-pending:chat-planner") ?? "null")).toEqual(["First", "Duplicate", "Edited duplicate"]); + + await user.click(screen.getByTestId("task-planner-chat-pending-delete-1")); + expect(JSON.parse(localStorage.getItem("fusion:chat-pending:chat-planner") ?? "null")).toEqual(["First", "Edited duplicate"]); + expect(screen.getByTestId("task-planner-chat-pending-force-1")).toHaveAccessibleName(/Force send queued message 2/); + void streamHandlers; + }); + + it("waits for durable cancellation and history reconciliation before force dispatching a non-front entry", async () => { + const user = userEvent.setup(); + const streamHandlers: any[] = []; + const cancelDeferred = createDeferred<{ success: boolean; interrupted: boolean }>(); + const historyDeferred = createDeferred<{ messages: any[] }>(); + const firstStream = { close: vi.fn(), isConnected: () => true }; + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + streamHandlers.push(handlers); + return streamHandlers.length === 1 ? firstStream : { close: vi.fn(), isConnected: () => true }; + }); + mockCancelChatResponse.mockReturnValueOnce(cancelDeferred.promise); + mockFetchChatMessages + .mockImplementationOnce(async () => ({ messages: [] })) + .mockImplementationOnce(() => historyDeferred.promise); + + renderPlannerChat(); + await screen.findByTestId("task-planner-chat-empty"); + await user.click(screen.getByRole("button", { name: /Summarize recent activity/ })); + const input = screen.getByLabelText("Message planner chat"); + for (const message of ["Keep this first", "Force this second"]) { + await user.type(input, message); + await user.keyboard("{Enter}"); + } + await waitFor(() => expect(streamHandlers).toHaveLength(1)); + + await user.click(screen.getByTestId("task-planner-chat-pending-force-1")); + expect(firstStream.close).toHaveBeenCalledTimes(1); + expect(mockCancelChatResponse).toHaveBeenCalledWith("chat-planner", undefined); + expect(mockStreamChatResponse).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("task-planner-chat-pending-force-1")).toBeDisabled(); + + act(() => streamHandlers[0].onText(" stale callback")); + expect(screen.queryByText("stale callback")).not.toBeInTheDocument(); + cancelDeferred.resolve({ success: true, interrupted: true }); + await waitFor(() => expect(mockFetchChatMessages).toHaveBeenCalledTimes(2)); + expect(mockStreamChatResponse).toHaveBeenCalledTimes(1); + historyDeferred.resolve({ messages: [] }); + await waitFor(() => expect(mockStreamChatResponse).toHaveBeenCalledTimes(2)); + expect(mockStreamChatResponse.mock.calls[1][1]).toBe("Force this second"); + expect(JSON.parse(localStorage.getItem("fusion:chat-pending:chat-planner") ?? "null")).toEqual(["Keep this first"]); + }); + + it("releases the queued FIFO front only after ordinary Stop reconciliation", async () => { + const user = userEvent.setup(); + const streamHandlers: any[] = []; + const cancelDeferred = createDeferred<{ success: boolean; interrupted: boolean }>(); + mockCancelChatResponse.mockReturnValueOnce(cancelDeferred.promise); + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + streamHandlers.push(handlers); + return { close: vi.fn(), isConnected: () => true }; + }); + + renderPlannerChat(); + await screen.findByTestId("task-planner-chat-empty"); + await user.click(screen.getByRole("button", { name: /Summarize recent activity/ })); + const input = screen.getByLabelText("Message planner chat"); + await user.type(input, "Queued after stop"); + await user.keyboard("{Enter}"); + await waitFor(() => expect(streamHandlers).toHaveLength(1)); + + await user.click(screen.getByTestId("chat-stop-btn")); + expect(mockStreamChatResponse).toHaveBeenCalledTimes(1); + cancelDeferred.resolve({ success: true, interrupted: true }); + await waitFor(() => expect(mockStreamChatResponse).toHaveBeenCalledTimes(2)); + expect(mockStreamChatResponse.mock.calls[1][1]).toBe("Queued after stop"); + }); + + it("retains the selected entry when cancellation fails", async () => { + const user = userEvent.setup(); + const cancelDeferred = createDeferred<{ success: boolean; interrupted: boolean }>(); + mockCancelChatResponse.mockReturnValueOnce(cancelDeferred.promise); + mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true }); + + renderPlannerChat(); + await screen.findByTestId("task-planner-chat-empty"); + await user.click(screen.getByRole("button", { name: /Summarize recent activity/ })); + const input = screen.getByLabelText("Message planner chat"); + await user.type(input, "Retain me"); + await user.keyboard("{Enter}"); + await waitFor(() => expect(screen.getByTestId("task-planner-chat-pending-force-0")).toBeInTheDocument()); + await user.click(screen.getByTestId("task-planner-chat-pending-force-0")); + cancelDeferred.resolve({ success: false, interrupted: false }); + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Failed to save the interrupted planner response")); + expect(screen.getByText("Retain me")).toBeInTheDocument(); + expect(mockStreamChatResponse).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 8625bf1f5f..febcb8e75a 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -8320,6 +8320,19 @@ "create": "Create task", "error": "Could not create task. Try again." }, + "plannerChat": { + "pendingLabel": "Pending planner messages", + "pendingHeading": "Pending messages", + "editPending": "Edit queued message", + "savePendingEdit": "Save queued message", + "cancelPendingEdit": "Cancel queued message edit", + "movePendingEarlier": "Move queued message earlier", + "movePendingLater": "Move queued message later", + "deletePending": "Delete queued message", + "forcePending": "Force send queued message", + "forcePendingShort": "Force send", + "pendingEditEmpty": "Queued messages cannot be empty" + }, "reverted": { "deleteAria": "Delete reverted task" }, diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 95c8bdd86e..d8f23b191b 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -8315,6 +8315,19 @@ "create": "", "error": "" }, + "plannerChat": { + "pendingLabel": "Mensajes del planificador pendientes", + "pendingHeading": "Mensajes pendientes", + "editPending": "Editar mensaje en cola", + "savePendingEdit": "Guardar mensaje en cola", + "cancelPendingEdit": "Cancelar edición del mensaje en cola", + "movePendingEarlier": "Mover mensaje en cola antes", + "movePendingLater": "Mover mensaje en cola después", + "deletePending": "Eliminar mensaje en cola", + "forcePending": "Forzar envío del mensaje en cola", + "forcePendingShort": "Forzar envío", + "pendingEditEmpty": "Los mensajes en cola no pueden estar vacíos" + }, "reverted": { "deleteAria": "" }, diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index ba2fc9fbb1..da844fa142 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -8315,6 +8315,19 @@ "create": "", "error": "" }, + "plannerChat": { + "pendingLabel": "Messages du planificateur en attente", + "pendingHeading": "Messages en attente", + "editPending": "Modifier le message en attente", + "savePendingEdit": "Enregistrer le message en attente", + "cancelPendingEdit": "Annuler la modification du message en attente", + "movePendingEarlier": "Avancer le message en attente", + "movePendingLater": "Reculer le message en attente", + "deletePending": "Supprimer le message en attente", + "forcePending": "Forcer l’envoi du message en attente", + "forcePendingShort": "Forcer l’envoi", + "pendingEditEmpty": "Les messages en attente ne peuvent pas être vides" + }, "reverted": { "deleteAria": "" }, diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 593f9d0ec5..b262f5b7ff 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -8315,6 +8315,19 @@ "create": "", "error": "" }, + "plannerChat": { + "pendingLabel": "대기 중인 플래너 메시지", + "pendingHeading": "대기 중인 메시지", + "editPending": "대기 메시지 편집", + "savePendingEdit": "대기 메시지 저장", + "cancelPendingEdit": "대기 메시지 편집 취소", + "movePendingEarlier": "대기 메시지 앞으로 이동", + "movePendingLater": "대기 메시지 뒤로 이동", + "deletePending": "대기 메시지 삭제", + "forcePending": "대기 메시지 강제 전송", + "forcePendingShort": "강제 전송", + "pendingEditEmpty": "대기 메시지는 비워 둘 수 없습니다" + }, "reverted": { "deleteAria": "" }, diff --git a/packages/i18n/locales/pt-BR/app.json b/packages/i18n/locales/pt-BR/app.json index f3ddc8efc2..7b7ae4f9db 100644 --- a/packages/i18n/locales/pt-BR/app.json +++ b/packages/i18n/locales/pt-BR/app.json @@ -8764,6 +8764,19 @@ "create": "Criar tarefa", "error": "Não foi possível criar a tarefa. Tente novamente." }, + "plannerChat": { + "pendingLabel": "Mensagens pendentes do planejador", + "pendingHeading": "Mensagens pendentes", + "editPending": "Editar mensagem na fila", + "savePendingEdit": "Salvar mensagem na fila", + "cancelPendingEdit": "Cancelar edição da mensagem na fila", + "movePendingEarlier": "Mover mensagem na fila para antes", + "movePendingLater": "Mover mensagem na fila para depois", + "deletePending": "Excluir mensagem na fila", + "forcePending": "Forçar envio da mensagem na fila", + "forcePendingShort": "Forçar envio", + "pendingEditEmpty": "Mensagens na fila não podem ficar vazias" + }, "reverted": { "deleteAria": "" }, diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 45a26d10b4..05fcf0f024 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -8315,6 +8315,19 @@ "create": "", "error": "" }, + "plannerChat": { + "pendingLabel": "待发送的规划器消息", + "pendingHeading": "待发送消息", + "editPending": "编辑待发送消息", + "savePendingEdit": "保存待发送消息", + "cancelPendingEdit": "取消编辑待发送消息", + "movePendingEarlier": "将待发送消息上移", + "movePendingLater": "将待发送消息下移", + "deletePending": "删除待发送消息", + "forcePending": "强制发送待发送消息", + "forcePendingShort": "强制发送", + "pendingEditEmpty": "待发送消息不能为空" + }, "reverted": { "deleteAria": "" }, diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 7b501b0931..8fec470320 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -8315,6 +8315,19 @@ "create": "", "error": "" }, + "plannerChat": { + "pendingLabel": "待傳送的規劃器訊息", + "pendingHeading": "待傳送訊息", + "editPending": "編輯待傳送訊息", + "savePendingEdit": "儲存待傳送訊息", + "cancelPendingEdit": "取消編輯待傳送訊息", + "movePendingEarlier": "將待傳送訊息上移", + "movePendingLater": "將待傳送訊息下移", + "deletePending": "刪除待傳送訊息", + "forcePending": "強制傳送待傳送訊息", + "forcePendingShort": "強制傳送", + "pendingEditEmpty": "待傳送訊息不能為空白" + }, "reverted": { "deleteAria": "" }, diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index 8fbed59370..fb80242ead 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -8274,6 +8274,19 @@ export default interface Resources { "replanCapHeadline": "Approval needed: Plan Review did not converge", "replanning": "Replanning {{id}}…" }, + "plannerChat": { + "cancelPendingEdit": "Cancel queued message edit", + "deletePending": "Delete queued message", + "editPending": "Edit queued message", + "forcePending": "Force send queued message", + "forcePendingShort": "Force send", + "movePendingEarlier": "Move queued message earlier", + "movePendingLater": "Move queued message later", + "pendingEditEmpty": "Queued messages cannot be empty", + "pendingHeading": "Pending messages", + "pendingLabel": "Pending planner messages", + "savePendingEdit": "Save queued message" + }, "pr": { "awaitingChecks": "Awaiting PR checks", "checkPrStatus": "Check PR Status",