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 <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-18 23:43:16 +00:00
parent b17c6de32b
commit 2fe3382279
12 changed files with 673 additions and 34 deletions

View File

@@ -743,6 +743,8 @@ Mailbox Inbox, Outbox, and agent lists exclude archived correspondence and unrea
<!-- FNXC:ChatViewDocs 2026-07-01-00:00: Task-detail planner chats are intentionally hidden from the common Direct feed by default after issue #1850; Settings keeps an opt-in for operators who want populated task-planner sessions restored without adding a mandatory Tasks tab. -->
<!-- FNXC:TaskDetailPlannerChat 2026-07-01-22:02: Done-task planner Chat remains available for retrospective Q&A and can create a task-scoped refinement through the planner tool, while common Chat feed visibility remains opt-in. -->
- 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.
<!-- FNXC:TaskPlannerChatQueue 2026-08-18-23:13: Planner Chat follow-ups use the existing browser-local, session-keyed queue rather than server-side storage, so operators can manage pending work without changing chat persistence boundaries. -->
- 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.
<!-- FNXC:ChatContextWindow 2026-06-27-00:00: Direct-chat docs must describe the desktop/tablet-only estimated token budget indicator and its intentional absence from mobile, narrow floating chat, and room headers. -->
- 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.
<!-- FNXC:ChatViewDocs 2026-06-28-14:52: Chat responsive docs must reflect that narrow chat hosts now key bubble width off the ChatView container, not just viewport media, so Quick Chat popups and the right dock on desktop viewports get the same full-width bubbles as phone Chat. -->

View File

@@ -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);

View File

@@ -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<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [pendingMessages, setPendingMessages] = useState<string[]>([]);
const [editingPendingIndex, setEditingPendingIndex] = useState<number | null>(null);
const [editingPendingText, setEditingPendingText] = useState("");
const [queueActionPending, setQueueActionPending] = useState(false);
const composerTextareaRef = useRef<HTMLTextAreaElement>(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<string | null>(null);
const streamRef = useRef<{ close: () => void } | null>(null);
const pendingMessagesRef = useRef<string[]>([]);
const sessionIdRef = useRef<string | null>(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<typeof streamSnapshotRef.current>, 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<HTMLTextAreaElement>) => {
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
)}
</div>
{pendingMessages.length > 0 && (
<section className="task-planner-chat-pending" data-testid="task-planner-chat-pending-list" aria-label={t("taskDetail.plannerChat.pendingLabel", "Pending planner messages")}>
<div className="task-planner-chat-pending-divider" aria-hidden="true" />
<h6 className="task-planner-chat-pending-heading">{t("taskDetail.plannerChat.pendingHeading", "Pending messages")}</h6>
<ol className="task-planner-chat-pending-items">
{pendingMessages.map((pendingMessage, index) => {
const isEditing = editingPendingIndex === index;
return (
<li className="task-planner-chat-pending-item" data-testid={`task-planner-chat-pending-message-${index}`} key={`${index}-${pendingMessage}`}>
{isEditing ? (
<input
className="input task-planner-chat-pending-edit-input"
aria-label={`${t("taskDetail.plannerChat.editPending", "Edit queued message")} ${index + 1}`}
value={editingPendingText}
onChange={(event) => setEditingPendingText(event.target.value)}
disabled={queueActionPending}
/>
) : (
<span className="task-planner-chat-pending-text">{pendingMessage}</span>
)}
<div className="task-planner-chat-pending-actions">
{isEditing ? (
<>
<button type="button" className="btn btn-icon btn-sm" onClick={() => savePendingEdit(index)} disabled={queueActionPending} aria-label={t("taskDetail.plannerChat.savePendingEdit", "Save queued message")} data-testid={`task-planner-chat-pending-save-${index}`}>
<Check aria-hidden="true" />
</button>
<button type="button" className="btn btn-icon btn-sm" onClick={cancelPendingEdit} disabled={queueActionPending} aria-label={t("taskDetail.plannerChat.cancelPendingEdit", "Cancel queued message edit")} data-testid={`task-planner-chat-pending-cancel-${index}`}>
<X aria-hidden="true" />
</button>
</>
) : (
<button type="button" className="btn btn-icon btn-sm" onClick={() => beginPendingEdit(index)} disabled={queueActionPending} aria-label={`${t("taskDetail.plannerChat.editPending", "Edit queued message")} ${index + 1}`} data-testid={`task-planner-chat-pending-edit-${index}`}>
<Pencil aria-hidden="true" />
</button>
)}
<button type="button" className="btn btn-icon btn-sm" onClick={() => movePendingMessage(index, -1)} disabled={queueActionPending || index === 0} aria-label={`${t("taskDetail.plannerChat.movePendingEarlier", "Move queued message earlier")} ${index + 1}`} data-testid={`task-planner-chat-pending-up-${index}`}>
<ArrowUp aria-hidden="true" />
</button>
<button type="button" className="btn btn-icon btn-sm" onClick={() => movePendingMessage(index, 1)} disabled={queueActionPending || index === pendingMessages.length - 1} aria-label={`${t("taskDetail.plannerChat.movePendingLater", "Move queued message later")} ${index + 1}`} data-testid={`task-planner-chat-pending-down-${index}`}>
<ArrowDown aria-hidden="true" />
</button>
<button type="button" className="btn btn-icon btn-sm" onClick={() => deletePendingMessage(index)} disabled={queueActionPending} aria-label={`${t("taskDetail.plannerChat.deletePending", "Delete queued message")} ${index + 1}`} data-testid={`task-planner-chat-pending-delete-${index}`}>
<Trash2 aria-hidden="true" />
</button>
<button type="button" className="btn btn-sm task-planner-chat-pending-force" onClick={() => forceSendPendingMessage(index)} disabled={queueActionPending} aria-label={`${t("taskDetail.plannerChat.forcePending", "Force send queued message")} ${index + 1}`} data-testid={`task-planner-chat-pending-force-${index}`}>
<Send aria-hidden="true" />
<span>{t("taskDetail.plannerChat.forcePendingShort", "Force send")}</span>
</button>
</div>
</li>
);
})}
</ol>
</section>
)}
{showCommandMenu && (
<div
className="chat-skill-menu task-planner-chat-command-menu"
@@ -1272,10 +1505,10 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
value={draft}
onChange={handleDraftChange}
onKeyDown={handleKeyDown}
disabled={composerState === "sending"}
disabled={queueActionPending}
rows={1}
/>
<MicButton {...dictation.micProps} disabled={composerState === "sending"} />
<MicButton {...dictation.micProps} disabled={queueActionPending} />
<StandardChatActionButton
isStreaming={composerState === "sending"}
canSend={canSend}

View File

@@ -2033,4 +2033,220 @@ describe("TaskPlannerChatTab", () => {
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(
<TaskPlannerChatTab
task={makeTask("FN-7311")}
active
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
addToast={vi.fn()}
/>,
);
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);
});
});
});

View File

@@ -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"
},

View File

@@ -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": ""
},

View File

@@ -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": ""
},

View File

@@ -8315,6 +8315,19 @@
"create": "",
"error": ""
},
"plannerChat": {
"pendingLabel": "대기 중인 플래너 메시지",
"pendingHeading": "대기 중인 메시지",
"editPending": "대기 메시지 편집",
"savePendingEdit": "대기 메시지 저장",
"cancelPendingEdit": "대기 메시지 편집 취소",
"movePendingEarlier": "대기 메시지 앞으로 이동",
"movePendingLater": "대기 메시지 뒤로 이동",
"deletePending": "대기 메시지 삭제",
"forcePending": "대기 메시지 강제 전송",
"forcePendingShort": "강제 전송",
"pendingEditEmpty": "대기 메시지는 비워 둘 수 없습니다"
},
"reverted": {
"deleteAria": ""
},

View File

@@ -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": ""
},

View File

@@ -8315,6 +8315,19 @@
"create": "",
"error": ""
},
"plannerChat": {
"pendingLabel": "待发送的规划器消息",
"pendingHeading": "待发送消息",
"editPending": "编辑待发送消息",
"savePendingEdit": "保存待发送消息",
"cancelPendingEdit": "取消编辑待发送消息",
"movePendingEarlier": "将待发送消息上移",
"movePendingLater": "将待发送消息下移",
"deletePending": "删除待发送消息",
"forcePending": "强制发送待发送消息",
"forcePendingShort": "强制发送",
"pendingEditEmpty": "待发送消息不能为空"
},
"reverted": {
"deleteAria": ""
},

View File

@@ -8315,6 +8315,19 @@
"create": "",
"error": ""
},
"plannerChat": {
"pendingLabel": "待傳送的規劃器訊息",
"pendingHeading": "待傳送訊息",
"editPending": "編輯待傳送訊息",
"savePendingEdit": "儲存待傳送訊息",
"cancelPendingEdit": "取消編輯待傳送訊息",
"movePendingEarlier": "將待傳送訊息上移",
"movePendingLater": "將待傳送訊息下移",
"deletePending": "刪除待傳送訊息",
"forcePending": "強制傳送待傳送訊息",
"forcePendingShort": "強制傳送",
"pendingEditEmpty": "待傳送訊息不能為空白"
},
"reverted": {
"deleteAria": ""
},

View File

@@ -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",