Files
fusion/packages/dashboard/app/components/PendingChatMessageQueue.tsx
Fusion Agent 21b3baa62d FN-029: unify direct and planner chat queues
Standardize queued-message controls across Direct Chat and planner conversations while preserving force-send ordering during cancellation reconciliation.

- Add shared pending-message queue UI with edit, reorder, delete, and force-send actions.
- Coordinate durable cancellation reconciliation before dispatching selected queued messages.
- Disable the Direct composer and related controls until force-send reconciliation completes.
- Add localized queue copy, documentation, styling, and regression coverage.

Files changed:
 docs/dashboard-guide.md                            |   8 +
 packages/dashboard/app/components/ChatView.css     |  63 ------
 packages/dashboard/app/components/ChatView.tsx     |  55 +++--
 packages/dashboard/app/components/PendingChatMessageQueue.css     |  94 +++++++++
 packages/dashboard/app/components/PendingChatMessageQueue.tsx     | 200 ++++++++++++++++++
 packages/dashboard/app/components/TaskPlannerChatTab.css          |  84 --------
 packages/dashboard/app/components/TaskPlannerChatTab.tsx          | 113 ++---------
 packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx |  24 ++-
 packages/dashboard/app/components/__tests__/PendingChatMessageQueue.test.tsx   |  76 +++++++
 packages/dashboard/app/hooks/__tests__/useChat.test.ts            |  39 +++
 packages/dashboard/app/hooks/useChat.ts                           | 224 ++++++++++++++++-----
 packages/i18n/locales/en/app.json                                 |  25 +--
 packages/i18n/locales/es/app.json                                 |  25 +--
 packages/i18n/locales/fr/app.json                                 |  25 +--
 packages/i18n/locales/ko/app.json                                 |  25 +--
 packages/i18n/locales/pt-BR/app.json                              |  25 +--
 packages/i18n/locales/zh-CN/app.json                              |  25 +--
 packages/i18n/locales/zh-TW/app.json                              |  25 +--
 packages/i18n/src/resources.d.ts                                  |  25 +--
 19 files changed, 746 insertions(+), 434 deletions(-)

Fusion-Task-Id: FN-029

Fusion-Task-Lineage: 3330d0e7-ba77-4392-98f7-69425070cfd1

Co-authored-by: Fusion <noreply@runfusion.ai>
2026-08-20 03:55:39 +00:00

201 lines
8.1 KiB
TypeScript

import React, { useEffect, useState } from "react";
import { ArrowDown, ArrowUp, Check, Pencil, Send, Trash2, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import "./PendingChatMessageQueue.css";
export interface PendingChatMessageQueueProps {
messages?: readonly string[];
disabled?: boolean;
onEdit: (index: number, content: string) => void;
onMove: (index: number, direction: -1 | 1) => void;
onDelete: (index: number) => void;
onForceSend: (index: number) => void;
/** Stable test ids for the two hosts without coupling this component to either host. */
testIdPrefix?: string;
}
function previewMessage(message: string): string {
return message.length > 50 ? `${message.slice(0, 50)}…` : message;
}
/**
* FNXC:ChatPendingQueue 2026-08-19-05:47:
* Direct and Planner model-loop chats share this presentation-only queue. Callers keep ownership
* of session fences, persistence, and cancellation; this component only manages indexed editing
* state so duplicate text remains addressable and an empty queue renders no interaction shell.
*/
export function PendingChatMessageQueue({
messages,
disabled = false,
onEdit,
onMove,
onDelete,
onForceSend,
testIdPrefix = "pending-chat-message-queue",
}: PendingChatMessageQueueProps) {
const { t } = useTranslation("app");
const visibleMessages = (messages ?? []).filter((message) => typeof message === "string" && message.trim().length > 0);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editingText, setEditingText] = useState("");
const [editError, setEditError] = useState<string | null>(null);
useEffect(() => {
if (editingIndex !== null && editingIndex >= visibleMessages.length) {
setEditingIndex(null);
setEditingText("");
setEditError(null);
}
}, [editingIndex, visibleMessages.length]);
if (visibleMessages.length === 0) return null;
const beginEdit = (index: number) => {
if (disabled) return;
setEditingIndex(index);
setEditingText(visibleMessages[index] ?? "");
setEditError(null);
};
const saveEdit = (index: number) => {
const content = editingText.trim();
if (!content) {
setEditError(t("chat.pendingEditEmpty", "Queued messages cannot be empty"));
return;
}
onEdit(index, content);
setEditingIndex(null);
setEditingText("");
setEditError(null);
};
const cancelEdit = () => {
setEditingIndex(null);
setEditingText("");
setEditError(null);
};
const rootTestId = testIdPrefix === "chat-pending" ? "chat-pending-stack" : `${testIdPrefix}-list`;
const itemTestId = testIdPrefix === "chat-pending"
? ("chat-pending-indicator" as const)
: (index: number) => `${testIdPrefix}-message-${index}`;
return (
<section
className="pending-chat-message-queue chat-pending-stack"
data-testid={rootTestId}
aria-label={t("chat.pendingLabel", "Pending messages")}
>
<div className="pending-chat-message-queue-divider chat-pending-divider" aria-hidden="true" />
<h6 className="pending-chat-message-queue-heading">{t("chat.pendingHeading", "Pending messages")}</h6>
<ol className="pending-chat-message-queue-items">
{visibleMessages.map((pendingMessage, index) => {
const isEditing = editingIndex === index;
const itemId = typeof itemTestId === "function" ? itemTestId(index) : itemTestId;
return (
<li
className="pending-chat-message-queue-item chat-pending-message"
data-testid={itemId}
key={`${index}-${pendingMessage}`}
>
{isEditing ? (
<input
className="input pending-chat-message-queue-edit-input"
aria-label={`${t("chat.editPending", "Edit queued message")} ${index + 1}`}
value={editingText}
onChange={(event) => setEditingText(event.target.value)}
disabled={disabled}
autoFocus
/>
) : (
<span className="pending-chat-message-queue-text chat-pending-message-text">
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: "" }).replace("{{preview}}", "")}</span>
<span>{previewMessage(pendingMessage)}</span>
</span>
)}
<div className="pending-chat-message-queue-actions">
{isEditing ? (
<>
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => saveEdit(index)}
disabled={disabled}
aria-label={t("chat.savePendingEdit", "Save queued message")}
data-testid={`${testIdPrefix}-save-${index}`}
>
<Check aria-hidden="true" />
</button>
<button
type="button"
className="btn btn-icon btn-sm"
onClick={cancelEdit}
disabled={disabled}
aria-label={t("chat.cancelPendingEdit", "Cancel queued message edit")}
data-testid={`${testIdPrefix}-cancel-${index}`}
>
<X aria-hidden="true" />
</button>
</>
) : (
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => beginEdit(index)}
disabled={disabled}
aria-label={`${t("chat.editPending", "Edit queued message")} ${index + 1}`}
data-testid={`${testIdPrefix}-edit-${index}`}
>
<Pencil aria-hidden="true" />
</button>
)}
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => onMove(index, -1)}
disabled={disabled || index === 0}
aria-label={`${t("chat.movePendingEarlier", "Move queued message earlier")} ${index + 1}`}
data-testid={`${testIdPrefix}-up-${index}`}
>
<ArrowUp aria-hidden="true" />
</button>
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => onMove(index, 1)}
disabled={disabled || index === visibleMessages.length - 1}
aria-label={`${t("chat.movePendingLater", "Move queued message later")} ${index + 1}`}
data-testid={`${testIdPrefix}-down-${index}`}
>
<ArrowDown aria-hidden="true" />
</button>
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => onDelete(index)}
disabled={disabled}
aria-label={`${t("chat.deletePending", "Delete queued message")} ${index + 1}`}
data-testid={testIdPrefix === "chat-pending" ? `chat-pending-dismiss-${index}` : `${testIdPrefix}-delete-${index}`}
>
<Trash2 aria-hidden="true" />
</button>
<button
type="button"
className="btn btn-sm pending-chat-message-queue-force"
onClick={() => onForceSend(index)}
disabled={disabled}
aria-label={`${t("chat.forcePending", "Force send queued message")} ${index + 1}`}
data-testid={`${testIdPrefix}-force-${index}`}
>
<Send aria-hidden="true" />
<span>{t("chat.forcePendingShort", "Force send")}</span>
</button>
</div>
{isEditing && editError && <div className="pending-chat-message-queue-error" role="alert">{editError}</div>}
</li>
);
})}
</ol>
</section>
);
}