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>
This commit is contained in:
@@ -246,6 +246,14 @@ The server accepts the replacement only after it has discarded the target and ev
|
||||
|
||||
Planner Chat refreshes task detail after an accepted replacement. Steering comments and refinement tasks created by discarded planner turns are durable side effects and are not rolled back; when applicable, Planner Chat shows the existing informational notice. The edit behavior is shared across desktop, popup/dock/host, mobile/touch, and task-detail surfaces.
|
||||
|
||||
## Pending messages in Direct and Planner Chat
|
||||
|
||||
Direct Chat and task-detail Planner Chat share one browser-local, text-only pending queue per chat session. While a model reply is active, send additional text to add it to the queue; the queue survives reloads under the session-scoped `fusion:chat-pending:<sessionId>` storage key and does not cross projects, tasks, or sessions.
|
||||
|
||||
Both model-loop surfaces expose the same queue controls: edit an entry, move it earlier or later, delete it, or **Force send** a selected entry. Duplicate text is selected by its position in the list, not by its content. A blank edit is rejected without deleting the queued entry, and queue controls remain named and touch-reachable on narrow screens.
|
||||
|
||||
Normal completion and **Stop** release only the FIFO front after cancellation and authoritative history reconciliation. **Force send** first fences and cancels the active stream, waits for cancellation plus reconciliation, then dispatches only the selected entry; failures keep the entry in its original queue position. Attachments are never queued. Activity task chat, Chat Rooms, and CLI-backed chat intentionally keep their separate interaction and transport contracts and do not inherit these model-loop queue controls.
|
||||
|
||||
## Automations
|
||||
|
||||
<!-- FNXC:AutomationTools 2026-06-26-00:00: Automation AI-prompt steps now default to the full coding tool set and expose per-step restrictions so operators can intentionally narrow tool access without breaking legacy schedules. -->
|
||||
|
||||
@@ -2377,61 +2377,6 @@ Thinking-section text uses the defined muted text token across all themes. The m
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
/* === Chat Pending Message === */
|
||||
/*
|
||||
FNXC:ChatComposer 2026-06-27-00:00:
|
||||
Queued-message banners stack above the composer input with a capped scroll area, and the shared divider below them makes the queued state visually distinct without rendering an empty rule when the FIFO queue is empty.
|
||||
*/
|
||||
.chat-pending-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
width: 100%;
|
||||
max-height: calc(var(--space-xl) * 6);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-pending-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--todo) 10%, transparent);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-pending-divider {
|
||||
width: 100%;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-pending-message span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-pending-message-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
padding: var(--space-xs);
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.chat-pending-message-dismiss:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Sidebar footer contains the primary chat-scope action on desktop and mobile. */
|
||||
.chat-sidebar-footer {
|
||||
display: block;
|
||||
@@ -2454,14 +2399,6 @@ Queued-message banners stack above the composer input with a capped scroll area,
|
||||
thread takes the full viewport. The thread already renders a back
|
||||
button (ChevronLeft) on mobile to flip back to the session list. */
|
||||
@media (max-width: 768px) {
|
||||
.chat-pending-stack {
|
||||
max-height: calc(var(--space-xl) * 5);
|
||||
}
|
||||
|
||||
.chat-pending-message {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.chat-view__body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { type Agent, type ChatTag, type Settings } from "@fusion/core";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { MicButton } from "./MicButton";
|
||||
import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl";
|
||||
import { PendingChatMessageQueue } from "./PendingChatMessageQueue";
|
||||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
@@ -652,7 +653,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
editMessageAndResend,
|
||||
stopStreaming,
|
||||
pendingMessages,
|
||||
pendingQueueAction,
|
||||
clearPendingMessage,
|
||||
updatePendingMessage,
|
||||
movePendingMessage,
|
||||
forceSendPendingMessage,
|
||||
loadMoreMessages,
|
||||
hasMoreMessages,
|
||||
searchQuery,
|
||||
@@ -2770,10 +2775,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
// assistant bubble is noise. Hide the per-message identity row entirely.
|
||||
const hideAssistantIdentity = activeSession?.agentId === FN_AGENT_ID;
|
||||
|
||||
const getPendingPreview = (message: string) => message.length > 50
|
||||
? `${message.slice(0, 50)}…`
|
||||
: message;
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileSessionMenuOpen) {
|
||||
return;
|
||||
@@ -3086,31 +3087,15 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pendingMessages.length > 0 && (
|
||||
<>
|
||||
{/*
|
||||
FNXC:ChatComposer 2026-06-27-00:00:
|
||||
Queued direct-chat messages stack above the input in FIFO order with one shared divider, so multiple sends remain visible without changing the above-composer placement established by FN-7121.
|
||||
*/}
|
||||
<div className="chat-pending-stack" data-testid="chat-pending-stack">
|
||||
{pendingMessages.map((pendingMessage, index) => (
|
||||
<div className="chat-pending-message" data-testid="chat-pending-indicator" key={`${index}-${pendingMessage}`}>
|
||||
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: getPendingPreview(pendingMessage) })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-pending-message-dismiss"
|
||||
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
|
||||
data-testid={`chat-pending-dismiss-${index}`}
|
||||
onClick={() => clearPendingMessage(index)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="chat-pending-divider" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
<PendingChatMessageQueue
|
||||
messages={pendingMessages}
|
||||
disabled={pendingQueueAction}
|
||||
onEdit={updatePendingMessage ?? (() => undefined)}
|
||||
onMove={movePendingMessage ?? (() => undefined)}
|
||||
onDelete={clearPendingMessage}
|
||||
onForceSend={forceSendPendingMessage ?? (() => undefined)}
|
||||
testIdPrefix="chat-pending"
|
||||
/>
|
||||
<div className="chat-input-row">
|
||||
<button
|
||||
type="button"
|
||||
@@ -3118,6 +3103,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
data-testid="chat-attach-btn"
|
||||
aria-label={t("chat.attachFiles", "Attach files")}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={pendingQueueAction}
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
@@ -3148,7 +3134,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
void setSessionModel(activeSession.id, selection);
|
||||
}
|
||||
}}
|
||||
disabled={!activeSession}
|
||||
disabled={!activeSession || pendingQueueAction}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
@@ -3186,6 +3172,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
// the visualViewport/input-focus effects own scroll compensation.
|
||||
}}
|
||||
rows={1}
|
||||
disabled={pendingQueueAction}
|
||||
data-testid="chat-input"
|
||||
/>
|
||||
<AgentMentionPopup
|
||||
@@ -3213,10 +3200,14 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
loading={fileMention.loading}
|
||||
/>
|
||||
</div>
|
||||
<MicButton {...composerDictation.micProps} />
|
||||
<MicButton {...composerDictation.micProps} disabled={pendingQueueAction} />
|
||||
{/*
|
||||
FNXC:ChatPendingQueue 2026-08-19-06:25:
|
||||
Force-send cancellation must own the Direct composer until server reconciliation completes; otherwise a new send is queued while the selected entry is being dispatched and loses its priority.
|
||||
*/}
|
||||
<StandardChatActionButton
|
||||
isStreaming={isStreaming}
|
||||
canSend={Boolean(messageInput.trim() || pendingAttachments.length > 0)}
|
||||
canSend={!pendingQueueAction && Boolean(messageInput.trim() || pendingAttachments.length > 0)}
|
||||
onSend={handleSend}
|
||||
onStop={stopStreaming}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
FNXC:ChatPendingQueue 2026-08-19-05:47:
|
||||
The shared queue keeps the existing above-composer hierarchy while using dashboard tokens so Direct
|
||||
and Planner hosts remain touch-safe at narrow widths without retaining two queue style systems.
|
||||
*/
|
||||
.pending-chat-message-queue {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pending-chat-message-queue-divider {
|
||||
width: 100%;
|
||||
border-top: var(--btn-border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.pending-chat-message-queue-heading {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pending-chat-message-queue-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;
|
||||
}
|
||||
|
||||
.pending-chat-message-queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pending-chat-message-queue-text {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pending-chat-message-queue-edit-input {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.pending-chat-message-queue-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.pending-chat-message-queue-actions .btn-icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pending-chat-message-queue-force {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.pending-chat-message-queue-error {
|
||||
flex-basis: 100%;
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pending-chat-message-queue-items {
|
||||
max-height: calc(var(--space-xl) * 5);
|
||||
}
|
||||
|
||||
.pending-chat-message-queue-item {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pending-chat-message-queue-actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
200
packages/dashboard/app/components/PendingChatMessageQueue.tsx
Normal file
200
packages/dashboard/app/components/PendingChatMessageQueue.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -210,77 +210,6 @@ 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);
|
||||
@@ -329,19 +258,6 @@ 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);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ChatInFlightGenerationState, ChatMessage, ResolvedModelSelection,
|
||||
import { isWipColumnRole } from "../utils/columnRoles";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, Check, Loader2, Maximize2, Minimize2, Pencil, Send, Trash2, X } from "lucide-react";
|
||||
import { Loader2, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useComposerDictation } from "../hooks/useComposerDictation";
|
||||
@@ -12,6 +12,7 @@ import type { ChatMessageInfo, ToolCallInfo } from "../hooks/chatTypes";
|
||||
import { attachChatStream, cancelChatResponse, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, type ChatFailureInfo, type ChatStreamErrorMeta } from "../api";
|
||||
import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall";
|
||||
import { ChatQuestionResponse } from "./ChatQuestionResponse";
|
||||
import { PendingChatMessageQueue } from "./PendingChatMessageQueue";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { StandardChatActionButton, StandardChatMessageItem, StandardStreamingMessage, formatModelTag } from "./StandardChatSurface";
|
||||
import { CHAT_COMMANDS, filterChatCommands, getSlashTriggerMatch, matchChatCommand, type ChatCommand } from "./chat-commands";
|
||||
@@ -332,8 +333,6 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
|
||||
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 autosizeRef = useRef<ChatInputAutosizeController | null>(null);
|
||||
@@ -714,8 +713,6 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
|
||||
setSessionId(null);
|
||||
pendingMessagesRef.current = [];
|
||||
setPendingMessages([]);
|
||||
setEditingPendingIndex(null);
|
||||
setEditingPendingText("");
|
||||
setQueueActionPending(false);
|
||||
setDraft("");
|
||||
composerStateRef.current = "idle";
|
||||
@@ -1113,33 +1110,15 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
|
||||
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 updatePendingMessage = useCallback((index: number, content: string) => {
|
||||
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("");
|
||||
replacePendingMessages(
|
||||
current.map((pendingMessage, pendingIndex) => pendingIndex === index ? content : pendingMessage),
|
||||
sessionIdRef.current,
|
||||
);
|
||||
setError(null);
|
||||
}, [editingPendingText, replacePendingMessages, t]);
|
||||
|
||||
const cancelPendingEdit = useCallback(() => {
|
||||
setEditingPendingIndex(null);
|
||||
setEditingPendingText("");
|
||||
}, []);
|
||||
}, [replacePendingMessages]);
|
||||
|
||||
const movePendingMessage = useCallback((index: number, direction: -1 | 1) => {
|
||||
if (queueActionPending) return;
|
||||
@@ -1149,22 +1128,14 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
|
||||
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]);
|
||||
}, [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]);
|
||||
}, [queueActionPending, replacePendingMessages]);
|
||||
|
||||
const forceSendPendingMessage = useCallback((index: number) => {
|
||||
if (queueActionPending) return;
|
||||
@@ -1468,61 +1439,15 @@ 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>
|
||||
)}
|
||||
<PendingChatMessageQueue
|
||||
messages={pendingMessages}
|
||||
disabled={queueActionPending}
|
||||
onEdit={updatePendingMessage}
|
||||
onMove={movePendingMessage}
|
||||
onDelete={deletePendingMessage}
|
||||
onForceSend={forceSendPendingMessage}
|
||||
testIdPrefix="task-planner-chat-pending"
|
||||
/>
|
||||
|
||||
{showCommandMenu && (
|
||||
<div
|
||||
|
||||
@@ -909,14 +909,20 @@ describe("ChatView core interactions", () => {
|
||||
expect(screen.getByTestId("chat-send-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders stacked pending message indicators above the input row and dismisses one entry", async () => {
|
||||
it("renders the shared pending queue controls above the input row", async () => {
|
||||
const clearPendingMessage = vi.fn();
|
||||
const updatePendingMessage = vi.fn();
|
||||
const movePendingMessage = vi.fn();
|
||||
const forceSendPendingMessage = vi.fn();
|
||||
const activeSession = activeSessionFixture;
|
||||
setupMockChat({
|
||||
activeSession,
|
||||
messages: [],
|
||||
pendingMessages: ["Queued A", "Queued B", "Queued C with a very long body that should truncate in the preview"],
|
||||
clearPendingMessage,
|
||||
updatePendingMessage,
|
||||
movePendingMessage,
|
||||
forceSendPendingMessage,
|
||||
});
|
||||
|
||||
const { rerender } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
@@ -941,14 +947,30 @@ describe("ChatView core interactions", () => {
|
||||
});
|
||||
expect(inputArea!.querySelectorAll(".chat-pending-divider")).toHaveLength(1);
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-pending-edit-1"));
|
||||
const editInput = screen.getByRole("textbox", { name: "Edit queued message 2" });
|
||||
await userEvent.clear(editInput);
|
||||
await userEvent.type(editInput, "Edited queued B");
|
||||
await userEvent.click(screen.getByTestId("chat-pending-save-1"));
|
||||
expect(updatePendingMessage).toHaveBeenCalledWith(1, "Edited queued B");
|
||||
await userEvent.click(screen.getByTestId("chat-pending-up-2"));
|
||||
await userEvent.click(screen.getByTestId("chat-pending-down-0"));
|
||||
expect(movePendingMessage).toHaveBeenNthCalledWith(1, 2, -1);
|
||||
expect(movePendingMessage).toHaveBeenNthCalledWith(2, 0, 1);
|
||||
await userEvent.click(screen.getByTestId("chat-pending-dismiss-1"));
|
||||
expect(clearPendingMessage).toHaveBeenCalledWith(1);
|
||||
await userEvent.click(screen.getByTestId("chat-pending-force-0"));
|
||||
expect(forceSendPendingMessage).toHaveBeenCalledWith(0);
|
||||
expect(screen.queryByTestId("chat-pending-message-dismiss")).not.toBeInTheDocument();
|
||||
|
||||
setupMockChat({
|
||||
activeSession,
|
||||
messages: [],
|
||||
pendingMessages: [],
|
||||
clearPendingMessage,
|
||||
updatePendingMessage,
|
||||
movePendingMessage,
|
||||
forceSendPendingMessage,
|
||||
});
|
||||
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { PendingChatMessageQueue } from "../PendingChatMessageQueue";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback: string, options?: { preview?: string }) =>
|
||||
fallback.replace("{{preview}}", options?.preview ?? ""),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("PendingChatMessageQueue", () => {
|
||||
const callbacks = () => ({
|
||||
onEdit: vi.fn(),
|
||||
onMove: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onForceSend: vi.fn(),
|
||||
});
|
||||
|
||||
it("renders no queue shell for empty or undefined messages", () => {
|
||||
const props = callbacks();
|
||||
const { rerender } = render(<PendingChatMessageQueue {...props} />);
|
||||
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
||||
expect(document.body.querySelector("button")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<PendingChatMessageQueue {...props} messages={[]} />);
|
||||
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("manages duplicate entries by index and rejects blank edits without destruction", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = callbacks();
|
||||
render(<PendingChatMessageQueue {...props} messages={["Duplicate", "Duplicate", "Last"]} testIdPrefix="queue" />);
|
||||
|
||||
await user.click(screen.getByTestId("queue-edit-1"));
|
||||
const input = screen.getByRole("textbox", { name: "Edit queued message 2" });
|
||||
await user.clear(input);
|
||||
await user.click(screen.getByTestId("queue-save-1"));
|
||||
expect(screen.getByText("Queued messages cannot be empty")).toBeInTheDocument();
|
||||
expect(props.onEdit).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByTestId("queue-cancel-1"));
|
||||
expect(screen.getByTestId("queue-message-1")).toHaveTextContent("Duplicate");
|
||||
await user.click(screen.getByTestId("queue-edit-1"));
|
||||
const reopenedInput = screen.getByRole("textbox", { name: "Edit queued message 2" });
|
||||
await user.clear(reopenedInput);
|
||||
await user.type(reopenedInput, "Edited duplicate");
|
||||
await user.click(screen.getByTestId("queue-save-1"));
|
||||
expect(props.onEdit).toHaveBeenCalledWith(1, "Edited duplicate");
|
||||
|
||||
await user.click(screen.getByTestId("queue-up-2"));
|
||||
await user.click(screen.getByTestId("queue-down-0"));
|
||||
expect(props.onMove).toHaveBeenNthCalledWith(1, 2, -1);
|
||||
expect(props.onMove).toHaveBeenNthCalledWith(2, 0, 1);
|
||||
expect(screen.getByTestId("queue-up-0")).toBeDisabled();
|
||||
expect(screen.getByTestId("queue-down-2")).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByTestId("queue-delete-1"));
|
||||
expect(props.onDelete).toHaveBeenCalledWith(1);
|
||||
await user.click(screen.getByTestId("queue-force-1"));
|
||||
expect(props.onForceSend).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("disables every mutation while the caller owns a queue action", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = callbacks();
|
||||
render(<PendingChatMessageQueue {...props} messages={["One", "Two"]} disabled testIdPrefix="queue" />);
|
||||
|
||||
for (const id of ["edit-0", "up-1", "down-0", "delete-0", "force-0"]) {
|
||||
expect(screen.getByTestId(`queue-${id}`)).toBeDisabled();
|
||||
}
|
||||
await user.click(screen.getByTestId("queue-force-0"));
|
||||
expect(props.onForceSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2589,6 +2589,45 @@ describe("useChat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("force-sends a selected direct queue entry only after cancellation reconciliation", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
const cancelDeferred = createDeferredPromise<{ success: boolean; interrupted: boolean }>();
|
||||
const streamHandlers: StreamAppendHandlers[] = [];
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||
streamHandlers.push(handlers as StreamAppendHandlers);
|
||||
return { close: vi.fn(), isConnected: () => true };
|
||||
});
|
||||
mockCancelChatResponse.mockReturnValueOnce(cancelDeferred.promise);
|
||||
|
||||
const { result } = renderHook(() => useChat("proj-123"));
|
||||
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
|
||||
act(() => result.current.selectSession("session-001"));
|
||||
await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001"));
|
||||
|
||||
act(() => {
|
||||
result.current.sendMessage("First");
|
||||
result.current.sendMessage("Keep first");
|
||||
result.current.sendMessage("Force second");
|
||||
});
|
||||
await waitFor(() => expect(result.current.pendingMessages).toEqual(["Keep first", "Force second"]));
|
||||
|
||||
act(() => result.current.forceSendPendingMessage?.(1));
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
||||
expect(mockCancelChatResponse).toHaveBeenCalledWith("session-001", "proj-123");
|
||||
expect(result.current.pendingMessages).toEqual(["Keep first", "Force second"]);
|
||||
|
||||
act(() => streamHandlers[0]?.onText(" stale callback"));
|
||||
cancelDeferred.resolve({ success: true, interrupted: true });
|
||||
await waitFor(() => {
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
|
||||
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Force second");
|
||||
expect(result.current.pendingMessages).toEqual(["Keep first"]);
|
||||
});
|
||||
expect(result.current.streamingText).toBe("");
|
||||
});
|
||||
|
||||
it("sending during streaming queues pendingMessages without warning toast", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
|
||||
@@ -129,6 +129,8 @@ export interface UseChatReturn {
|
||||
streamingThinking: string;
|
||||
streamingToolCalls: ToolCallInfo[];
|
||||
pendingMessages: string[];
|
||||
/** Optional for legacy lightweight ChatView test doubles; the real hook always provides it. */
|
||||
pendingQueueAction?: boolean;
|
||||
|
||||
// Session operations
|
||||
selectSession: (id: string, sessionOverride?: ChatSessionInfo) => void;
|
||||
@@ -178,6 +180,9 @@ export interface UseChatReturn {
|
||||
editMessageAndResend: (messageId: string, newContent: string) => Promise<void>;
|
||||
stopStreaming: () => void;
|
||||
clearPendingMessage: (index?: number) => void;
|
||||
updatePendingMessage?: (index: number, content: string) => void;
|
||||
movePendingMessage?: (index: number, direction: -1 | 1) => void;
|
||||
forceSendPendingMessage?: (index: number) => void;
|
||||
loadMoreMessages: () => Promise<void>;
|
||||
hasMoreMessages: boolean;
|
||||
|
||||
@@ -461,6 +466,7 @@ export function useChat(
|
||||
const [streamingThinking, setStreamingThinking] = useState("");
|
||||
const [streamingToolCalls, setStreamingToolCalls] = useState<ToolCallInfo[]>([]);
|
||||
const [pendingMessages, setPendingMessages] = useState<string[]>([]);
|
||||
const [pendingQueueAction, setPendingQueueAction] = useState(false);
|
||||
|
||||
// Search/filter
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -489,6 +495,8 @@ export function useChat(
|
||||
const streamingThinkingRef = useRef("");
|
||||
const streamingToolCallsRef = useRef<ToolCallInfo[]>([]);
|
||||
const pendingMessagesRef = useRef<string[]>([]);
|
||||
const pendingQueueActionRef = useRef(false);
|
||||
const streamRequestRef = useRef(0);
|
||||
const attachIfGeneratingRef = useRef<(
|
||||
sessionId: string,
|
||||
inFlightGeneration?: ChatInFlightGenerationState | null,
|
||||
@@ -760,6 +768,8 @@ export function useChat(
|
||||
cancelStreamingFlushesRef.current = null;
|
||||
pendingMessagesRef.current = [];
|
||||
setPendingMessages([]);
|
||||
pendingQueueActionRef.current = false;
|
||||
setPendingQueueAction(false);
|
||||
streamingTextRef.current = "";
|
||||
streamingThinkingRef.current = "";
|
||||
streamingToolCallsRef.current = [];
|
||||
@@ -769,20 +779,46 @@ export function useChat(
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
const replacePendingMessages = useCallback((nextMessages: readonly string[], sessionId = activeSessionRef.current?.id) => {
|
||||
const normalizedMessages = nextMessages.map((message) => message.trim()).filter(Boolean);
|
||||
pendingMessagesRef.current = normalizedMessages;
|
||||
setPendingMessages(normalizedMessages);
|
||||
setPersistedPendingChatMessages(sessionId, normalizedMessages);
|
||||
}, []);
|
||||
|
||||
const clearPendingMessage = useCallback((index?: number) => {
|
||||
const sessionId = activeSessionRef.current?.id;
|
||||
if (typeof index === "number") {
|
||||
const nextMessages = pendingMessagesRef.current.filter((_, messageIndex) => messageIndex !== index);
|
||||
pendingMessagesRef.current = nextMessages;
|
||||
setPendingMessages(nextMessages);
|
||||
setPersistedPendingChatMessages(sessionId, nextMessages);
|
||||
replacePendingMessages(
|
||||
pendingMessagesRef.current.filter((_, messageIndex) => messageIndex !== index),
|
||||
sessionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
removePersistedPendingChatMessages(sessionId);
|
||||
pendingMessagesRef.current = [];
|
||||
setPendingMessages([]);
|
||||
}, []);
|
||||
}, [replacePendingMessages]);
|
||||
|
||||
const updatePendingMessage = useCallback((index: number, content: string) => {
|
||||
const trimmedContent = content.trim();
|
||||
if (!trimmedContent || index < 0 || index >= pendingMessagesRef.current.length) return;
|
||||
const nextMessages = pendingMessagesRef.current.map((message, messageIndex) =>
|
||||
messageIndex === index ? trimmedContent : message,
|
||||
);
|
||||
replacePendingMessages(nextMessages);
|
||||
}, [replacePendingMessages]);
|
||||
|
||||
const movePendingMessage = useCallback((index: number, direction: -1 | 1) => {
|
||||
if (pendingQueueActionRef.current) return;
|
||||
const targetIndex = index + direction;
|
||||
const current = pendingMessagesRef.current;
|
||||
if (index < 0 || index >= current.length || targetIndex < 0 || targetIndex >= current.length) return;
|
||||
const nextMessages = [...current];
|
||||
[nextMessages[index], nextMessages[targetIndex]] = [nextMessages[targetIndex]!, nextMessages[index]!];
|
||||
replacePendingMessages(nextMessages);
|
||||
}, [replacePendingMessages]);
|
||||
|
||||
const flushPendingMessage = useCallback(() => {
|
||||
const [queuedMessage, ...remainingMessages] = pendingMessagesRef.current;
|
||||
@@ -857,8 +893,20 @@ export function useChat(
|
||||
bubble, Stop control, or transcript of a thread re-entered afterward.
|
||||
*/
|
||||
const attachmentSelectionVersion = activeSessionSelectionRef.current;
|
||||
const attachmentRequestId = ++streamRequestRef.current;
|
||||
const ownsAttachedSession = () =>
|
||||
activeSessionSelectionRef.current === attachmentSelectionVersion && activeSessionRef.current?.id === sessionId;
|
||||
streamRequestRef.current === attachmentRequestId
|
||||
&& activeSessionSelectionRef.current === attachmentSelectionVersion
|
||||
&& activeSessionRef.current?.id === sessionId;
|
||||
const updateAttachedStreamingText = (next: SetStateAction<string>) => {
|
||||
if (ownsAttachedSession()) updateStreamingText(next);
|
||||
};
|
||||
const updateAttachedStreamingThinking = (next: SetStateAction<string>) => {
|
||||
if (ownsAttachedSession()) updateStreamingThinking(next);
|
||||
};
|
||||
const updateAttachedStreamingToolCalls = (next: SetStateAction<ToolCallInfo[]>) => {
|
||||
if (ownsAttachedSession()) updateStreamingToolCalls(next);
|
||||
};
|
||||
const currentMessages = messagesRef.current;
|
||||
const needsPriorThreadLoad = currentMessages.length === 0 || currentMessages[0]?.sessionId !== sessionId;
|
||||
lastAttachedGenerationRef.current = {
|
||||
@@ -881,9 +929,9 @@ export function useChat(
|
||||
FNXC:ChatStreaming 2026-06-18-06:00:
|
||||
Main chat paints the durable in-flight snapshot immediately for reattach UX, and passes the same snapshot into createChatStreamHandlers so the first replayed delta appends to accumulated text/thinking/tool calls instead of replacing the visible prefix.
|
||||
*/
|
||||
setStreamingText(inFlightGeneration.streamingText);
|
||||
setStreamingThinking(inFlightGeneration.streamingThinking);
|
||||
setStreamingToolCalls(inFlightGeneration.toolCalls);
|
||||
updateAttachedStreamingText(inFlightGeneration.streamingText);
|
||||
updateAttachedStreamingThinking(inFlightGeneration.streamingThinking);
|
||||
updateAttachedStreamingToolCalls(inFlightGeneration.toolCalls);
|
||||
}
|
||||
/*
|
||||
FNXC:ChatStreaming 2026-07-22-19:20:
|
||||
@@ -900,12 +948,13 @@ export function useChat(
|
||||
initialText: inFlightGeneration?.streamingText,
|
||||
initialThinking: inFlightGeneration?.streamingThinking,
|
||||
initialToolCalls: inFlightGeneration?.toolCalls,
|
||||
setStreamingText: updateStreamingText,
|
||||
setStreamingThinking: updateStreamingThinking,
|
||||
setStreamingToolCalls: updateStreamingToolCalls,
|
||||
setStreamingText: updateAttachedStreamingText,
|
||||
setStreamingThinking: updateAttachedStreamingThinking,
|
||||
setStreamingToolCalls: updateAttachedStreamingToolCalls,
|
||||
cancelStreamingFlushesRef,
|
||||
addToast: options?.silent ? undefined : addToast,
|
||||
onFallbackSession: (data, fallbackSessionId) => {
|
||||
if (!ownsAttachedSession()) return;
|
||||
const nextModel = parseModelDescriptor(data.fallbackModel);
|
||||
setSessions((prev) => prev.map((session) =>
|
||||
session.id === fallbackSessionId ? { ...session, ...nextModel } : session,
|
||||
@@ -968,6 +1017,7 @@ export function useChat(
|
||||
return;
|
||||
}
|
||||
const selectionVersion = ++activeSessionSelectionRef.current;
|
||||
streamRequestRef.current += 1;
|
||||
authoritativeSelectionRefreshRef.current = id ? { sessionId: id, version: selectionVersion } : null;
|
||||
// Close any existing stream before its transient state is reset.
|
||||
if (streamRef.current) {
|
||||
@@ -1420,11 +1470,20 @@ export function useChat(
|
||||
await loadMessages(activeSession.id, { before: cursor });
|
||||
}, [activeSession, hasMoreMessages, loadMessages]);
|
||||
|
||||
const stopStreaming = useCallback(() => {
|
||||
/*
|
||||
FNXC:ChatPendingQueue 2026-08-19-05:47:
|
||||
Direct Stop and selected Force share one durable cancellation/history barrier. The queue is not
|
||||
released until that barrier succeeds, so a closed transport or failed reconciliation cannot lose
|
||||
a pending turn or let a stale callback dispatch it into a different session incarnation.
|
||||
*/
|
||||
const cancelAndReconcile = useCallback((onReconciled: () => void) => {
|
||||
const session = activeSessionRef.current;
|
||||
if (!session || cancellationInProgressRef.current) return;
|
||||
if (!session || cancellationInProgressRef.current) return false;
|
||||
|
||||
pendingQueueActionRef.current = true;
|
||||
setPendingQueueAction(true);
|
||||
cancelledByUserRef.current = true;
|
||||
streamRequestRef.current += 1;
|
||||
cancelStreamingFlushesRef.current?.();
|
||||
cancelStreamingFlushesRef.current = null;
|
||||
streamRef.current?.close();
|
||||
@@ -1438,9 +1497,6 @@ export function useChat(
|
||||
const interruptedLocalId = `interrupted-${Date.now()}`;
|
||||
const hasInterruptedOutput = Boolean(stoppedText || stoppedThinking || stoppedToolCalls.length > 0);
|
||||
if (hasInterruptedOutput) {
|
||||
// FNXC:ChatCancellation 2026-08-18-21:55:
|
||||
// Keep the displayed prefix in the transcript while the awaited cancel request
|
||||
// reconciles its durable PostgreSQL message; a failed request must not erase it.
|
||||
setMessages((previous) => appendChatMessageChronologically(previous, {
|
||||
id: interruptedLocalId,
|
||||
sessionId: session.id,
|
||||
@@ -1473,7 +1529,7 @@ export function useChat(
|
||||
const data = await fetchChatMessages(session.id, { limit: 50, order: "asc" }, projectId);
|
||||
refreshedMessages = data.messages.map(mapChatMessageToInfo);
|
||||
} catch {
|
||||
// The local interrupted row remains recoverable when reconciliation cannot read history.
|
||||
// The queue remains durable unless the cancel response itself proves the interrupted row.
|
||||
}
|
||||
if (activeSessionRef.current?.id !== session.id || activeSessionSelectionRef.current !== sessionSelectionVersion) {
|
||||
return;
|
||||
@@ -1482,33 +1538,33 @@ export function useChat(
|
||||
const persistedInterruptedMessage = cancellationResult.message
|
||||
? mapChatMessageToInfo(cancellationResult.message)
|
||||
: undefined;
|
||||
if (refreshedMessages || persistedInterruptedMessage) {
|
||||
const reconciled = [
|
||||
...(refreshedMessages ?? []),
|
||||
...(persistedInterruptedMessage && !(refreshedMessages ?? []).some((message) => message.id === persistedInterruptedMessage.id)
|
||||
? [persistedInterruptedMessage]
|
||||
: []),
|
||||
];
|
||||
const hasDurableInterruptedMessage = Boolean(persistedInterruptedMessage)
|
||||
|| reconciled.some((message) =>
|
||||
message.role === "assistant"
|
||||
&& message.content === stoppedText
|
||||
&& message.metadata?.interrupted === true,
|
||||
);
|
||||
setMessages((current) => {
|
||||
// FNXC:ChatCancellation 2026-08-19-05:20:
|
||||
// A successful but incomplete history read must not erase the optimistic prefix. Remove it only after the cancel response or history proves the interrupted row exists; otherwise merge durable history around the still-visible local recovery bubble.
|
||||
let next = current.filter((message) =>
|
||||
message.id !== "streaming-assistant"
|
||||
&& (!hasDurableInterruptedMessage || message.id !== interruptedLocalId),
|
||||
);
|
||||
for (const persisted of reconciled) {
|
||||
next = reconcileOptimisticSentMessage(next, persisted);
|
||||
}
|
||||
return sortChatMessagesChronologically(next);
|
||||
});
|
||||
if (!refreshedMessages && !persistedInterruptedMessage) {
|
||||
throw new Error("Chat history reconciliation did not complete");
|
||||
}
|
||||
flushPendingMessage();
|
||||
|
||||
const reconciled = [
|
||||
...(refreshedMessages ?? []),
|
||||
...(persistedInterruptedMessage && !(refreshedMessages ?? []).some((message) => message.id === persistedInterruptedMessage.id)
|
||||
? [persistedInterruptedMessage]
|
||||
: []),
|
||||
];
|
||||
const hasDurableInterruptedMessage = Boolean(persistedInterruptedMessage)
|
||||
|| reconciled.some((message) =>
|
||||
message.role === "assistant"
|
||||
&& message.content === stoppedText
|
||||
&& message.metadata?.interrupted === true,
|
||||
);
|
||||
setMessages((current) => {
|
||||
let next = current.filter((message) =>
|
||||
message.id !== "streaming-assistant"
|
||||
&& (!hasDurableInterruptedMessage || message.id !== interruptedLocalId),
|
||||
);
|
||||
for (const persisted of reconciled) {
|
||||
next = reconcileOptimisticSentMessage(next, persisted);
|
||||
}
|
||||
return sortChatMessagesChronologically(next);
|
||||
});
|
||||
onReconciled();
|
||||
})
|
||||
.catch(() => {
|
||||
if (activeSessionRef.current?.id === session.id && activeSessionSelectionRef.current === sessionSelectionVersion) {
|
||||
@@ -1516,12 +1572,20 @@ export function useChat(
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
pendingQueueActionRef.current = false;
|
||||
setPendingQueueAction(false);
|
||||
if (cancellationInProgressRef.current === cancellation) {
|
||||
cancellationInProgressRef.current = null;
|
||||
}
|
||||
});
|
||||
cancellationInProgressRef.current = cancellation;
|
||||
}, [activeSession, addToast, flushPendingMessage, projectId]);
|
||||
return true;
|
||||
}, [addToast, projectId]);
|
||||
|
||||
const stopStreaming = useCallback(() => {
|
||||
if (!activeSessionRef.current || cancellationInProgressRef.current) return;
|
||||
cancelAndReconcile(flushPendingMessage);
|
||||
}, [cancelAndReconcile, flushPendingMessage]);
|
||||
|
||||
/**
|
||||
* Send a user message to the active chat session.
|
||||
@@ -1607,6 +1671,18 @@ export function useChat(
|
||||
streamRef.current = null;
|
||||
}
|
||||
lastAttachedGenerationRef.current = null;
|
||||
const requestId = ++streamRequestRef.current;
|
||||
const ownsStream = () => streamRequestRef.current === requestId
|
||||
&& activeSessionRef.current?.id === activeSession.id;
|
||||
const updateOwnedStreamingText = (next: SetStateAction<string>) => {
|
||||
if (ownsStream()) updateStreamingText(next);
|
||||
};
|
||||
const updateOwnedStreamingThinking = (next: SetStateAction<string>) => {
|
||||
if (ownsStream()) updateStreamingThinking(next);
|
||||
};
|
||||
const updateOwnedStreamingToolCalls = (next: SetStateAction<ToolCallInfo[]>) => {
|
||||
if (ownsStream()) updateStreamingToolCalls(next);
|
||||
};
|
||||
|
||||
// Optimistically add user message
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
@@ -1629,12 +1705,13 @@ export function useChat(
|
||||
const { handlers } = createChatStreamHandlers({
|
||||
sessionId: activeSession.id,
|
||||
tempUserMessageId: tempId,
|
||||
setStreamingText: updateStreamingText,
|
||||
setStreamingThinking: updateStreamingThinking,
|
||||
setStreamingToolCalls: updateStreamingToolCalls,
|
||||
setStreamingText: updateOwnedStreamingText,
|
||||
setStreamingThinking: updateOwnedStreamingThinking,
|
||||
setStreamingToolCalls: updateOwnedStreamingToolCalls,
|
||||
cancelStreamingFlushesRef,
|
||||
addToast,
|
||||
onFallbackSession: (data, sessionId) => {
|
||||
if (!ownsStream()) return;
|
||||
const nextModel = parseModelDescriptor(data.fallbackModel);
|
||||
setSessions((prev) => prev.map((session) =>
|
||||
session.id === sessionId ? { ...session, ...nextModel } : session,
|
||||
@@ -1642,6 +1719,7 @@ export function useChat(
|
||||
setActiveSession((prev) => prev && prev.id === sessionId ? { ...prev, ...nextModel } : prev);
|
||||
},
|
||||
onDone: ({ messageId, message: finalMessage, accumulated }) => {
|
||||
if (!ownsStream()) return;
|
||||
const assistantMessage: ChatMessageInfo = finalMessage
|
||||
? {
|
||||
...mapChatMessageToInfo(finalMessage),
|
||||
@@ -1686,6 +1764,7 @@ export function useChat(
|
||||
flushPendingMessage();
|
||||
},
|
||||
onError: (data, tempUserMessageId, meta?: ChatStreamErrorMeta) => {
|
||||
if (!ownsStream()) return;
|
||||
const failureInfo = normalizeFailureInfo(data);
|
||||
const suspensionMessage = typeof data === "string" ? data : failureInfo.summary;
|
||||
const shouldSuppressSuspensionError = isLikelyTabSuspensionError(suspensionMessage);
|
||||
@@ -1790,6 +1869,51 @@ export function useChat(
|
||||
|
||||
sendMessageRef.current = sendMessage;
|
||||
|
||||
const dispatchPendingMessage = useCallback((sessionId: string, selectionVersion: number, index: number, content: string) => {
|
||||
if (
|
||||
activeSessionRef.current?.id !== sessionId
|
||||
|| activeSessionSelectionRef.current !== selectionVersion
|
||||
|| pendingMessagesRef.current[index]?.trim() !== content
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
replacePendingMessages(
|
||||
pendingMessagesRef.current.filter((_, messageIndex) => messageIndex !== index),
|
||||
sessionId,
|
||||
);
|
||||
sendMessageRef.current(content, undefined, {
|
||||
onFailed: () => {
|
||||
const isCurrentSelection = activeSessionRef.current?.id === sessionId
|
||||
&& activeSessionSelectionRef.current === selectionVersion;
|
||||
const current = isCurrentSelection
|
||||
? pendingMessagesRef.current
|
||||
: getPersistedPendingChatMessages(sessionId);
|
||||
const insertionIndex = Math.min(Math.max(index, 0), current.length);
|
||||
const restored = [...current.slice(0, insertionIndex), content, ...current.slice(insertionIndex)];
|
||||
setPersistedPendingChatMessages(sessionId, restored);
|
||||
if (isCurrentSelection) {
|
||||
pendingMessagesRef.current = restored;
|
||||
setPendingMessages(restored);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [replacePendingMessages]);
|
||||
|
||||
const forceSendPendingMessage = useCallback((index: number) => {
|
||||
const session = activeSessionRef.current;
|
||||
const content = pendingMessagesRef.current[index]?.trim();
|
||||
if (!session || !content || pendingQueueActionRef.current) return;
|
||||
|
||||
const selectionVersion = activeSessionSelectionRef.current;
|
||||
const dispatch = () => dispatchPendingMessage(session.id, selectionVersion, index, content);
|
||||
if (isStreamingRef.current || streamRef.current) {
|
||||
cancelAndReconcile(dispatch);
|
||||
return;
|
||||
}
|
||||
dispatch();
|
||||
}, [cancelAndReconcile, dispatchPendingMessage]);
|
||||
|
||||
/*
|
||||
* FNXC:ChatMessageEdit 2026-08-19-03:34:
|
||||
* Editing is one replacement-aware SSE request. Keep the original transcript mounted
|
||||
@@ -2283,6 +2407,7 @@ export function useChat(
|
||||
streamingThinking,
|
||||
streamingToolCalls,
|
||||
pendingMessages,
|
||||
pendingQueueAction,
|
||||
selectSession,
|
||||
createSession,
|
||||
archiveSession,
|
||||
@@ -2303,6 +2428,9 @@ export function useChat(
|
||||
editMessageAndResend,
|
||||
stopStreaming,
|
||||
clearPendingMessage,
|
||||
updatePendingMessage,
|
||||
movePendingMessage,
|
||||
forceSendPendingMessage,
|
||||
loadMoreMessages,
|
||||
hasMoreMessages,
|
||||
searchQuery,
|
||||
|
||||
@@ -1277,7 +1277,6 @@
|
||||
"deleteRoom": "Delete room {{name}}",
|
||||
"deleteRoomBody": "This action cannot be undone. This room and all its messages will be permanently deleted.",
|
||||
"deleteRoomTitle": "Delete Room?",
|
||||
"dismissQueuedMessage": "Dismiss queued message",
|
||||
"failedToArchiveConversation": "Failed to archive conversation",
|
||||
"failedToClearConversation": "Failed to clear conversation",
|
||||
"failedToClearRoomConversation": "Failed to clear room conversation",
|
||||
@@ -1332,6 +1331,17 @@
|
||||
"questionSubmittedAnswerLabel": "Submitted answer",
|
||||
"questionTextPlaceholder": "Type your answer here…",
|
||||
"queuedMessage": "Queued: {{preview}}",
|
||||
"pendingLabel": "Pending 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",
|
||||
"quickChatTitle": "Quick Chat",
|
||||
"relativeTimeDays_one": "{{count}}d ago",
|
||||
"relativeTimeDays_other": "{{count}}d ago",
|
||||
@@ -8321,19 +8331,6 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -1266,7 +1266,6 @@
|
||||
"deleteRoom": "Eliminar canal {{name}}",
|
||||
"deleteRoomBody": "Esta acción no se puede deshacer. Este canal y todos sus mensajes se eliminarán de forma permanente.",
|
||||
"deleteRoomTitle": "¿Eliminar canal?",
|
||||
"dismissQueuedMessage": "Cerrar mensaje en cola",
|
||||
"failedToArchiveConversation": "Error al archivar la conversación",
|
||||
"failedToClearConversation": "Error al limpiar la conversación",
|
||||
"failedToClearRoomConversation": "Error al limpiar la conversación del canal",
|
||||
@@ -1319,6 +1318,17 @@
|
||||
"questionSubmittedAnswerLabel": "",
|
||||
"questionTextPlaceholder": "",
|
||||
"queuedMessage": "En cola: {{preview}}",
|
||||
"pendingLabel": "Mensajes 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",
|
||||
"quickChatTitle": "Chat rápido",
|
||||
"relativeTimeDays_one": "",
|
||||
"relativeTimeDays_other": "",
|
||||
@@ -8316,19 +8326,6 @@
|
||||
"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": ""
|
||||
},
|
||||
|
||||
@@ -1266,7 +1266,6 @@
|
||||
"deleteRoom": "Supprimer le salon {{name}}",
|
||||
"deleteRoomBody": "Cette action est irréversible. Ce salon et tous ses messages seront définitivement supprimés.",
|
||||
"deleteRoomTitle": "Supprimer le salon ?",
|
||||
"dismissQueuedMessage": "Ignorer le message en file d'attente",
|
||||
"failedToArchiveConversation": "Impossible d'archiver la conversation",
|
||||
"failedToClearConversation": "Impossible d'effacer la conversation",
|
||||
"failedToClearRoomConversation": "Impossible d'effacer la conversation du salon",
|
||||
@@ -1319,6 +1318,17 @@
|
||||
"questionSubmittedAnswerLabel": "",
|
||||
"questionTextPlaceholder": "",
|
||||
"queuedMessage": "En file d'attente : {{preview}}",
|
||||
"pendingLabel": "Messages 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",
|
||||
"quickChatTitle": "Chat rapide",
|
||||
"relativeTimeDays_one": "",
|
||||
"relativeTimeDays_other": "",
|
||||
@@ -8316,19 +8326,6 @@
|
||||
"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": ""
|
||||
},
|
||||
|
||||
@@ -1266,7 +1266,6 @@
|
||||
"deleteRoom": "방 {{name}} 삭제",
|
||||
"deleteRoomBody": "이 작업은 되돌릴 수 없습니다. 이 방과 모든 메시지가 영구적으로 삭제됩니다.",
|
||||
"deleteRoomTitle": "방을 삭제하시겠습니까?",
|
||||
"dismissQueuedMessage": "대기 중인 메시지 닫기",
|
||||
"failedToArchiveConversation": "대화 보관 실패",
|
||||
"failedToClearConversation": "대화 초기화 실패",
|
||||
"failedToClearRoomConversation": "방 대화 초기화 실패",
|
||||
@@ -1319,6 +1318,17 @@
|
||||
"questionSubmittedAnswerLabel": "",
|
||||
"questionTextPlaceholder": "",
|
||||
"queuedMessage": "대기 중: {{preview}}",
|
||||
"pendingLabel": "대기 중인 메시지",
|
||||
"pendingHeading": "대기 중인 메시지",
|
||||
"editPending": "대기 메시지 편집",
|
||||
"savePendingEdit": "대기 메시지 저장",
|
||||
"cancelPendingEdit": "대기 메시지 편집 취소",
|
||||
"movePendingEarlier": "대기 메시지 앞으로 이동",
|
||||
"movePendingLater": "대기 메시지 뒤로 이동",
|
||||
"deletePending": "대기 메시지 삭제",
|
||||
"forcePending": "대기 메시지 강제 전송",
|
||||
"forcePendingShort": "강제 전송",
|
||||
"pendingEditEmpty": "대기 메시지는 비워 둘 수 없습니다",
|
||||
"quickChatTitle": "빠른 채팅",
|
||||
"relativeTimeDays_one": "",
|
||||
"relativeTimeDays_other": "",
|
||||
@@ -8316,19 +8326,6 @@
|
||||
"create": "",
|
||||
"error": ""
|
||||
},
|
||||
"plannerChat": {
|
||||
"pendingLabel": "대기 중인 플래너 메시지",
|
||||
"pendingHeading": "대기 중인 메시지",
|
||||
"editPending": "대기 메시지 편집",
|
||||
"savePendingEdit": "대기 메시지 저장",
|
||||
"cancelPendingEdit": "대기 메시지 편집 취소",
|
||||
"movePendingEarlier": "대기 메시지 앞으로 이동",
|
||||
"movePendingLater": "대기 메시지 뒤로 이동",
|
||||
"deletePending": "대기 메시지 삭제",
|
||||
"forcePending": "대기 메시지 강제 전송",
|
||||
"forcePendingShort": "강제 전송",
|
||||
"pendingEditEmpty": "대기 메시지는 비워 둘 수 없습니다"
|
||||
},
|
||||
"reverted": {
|
||||
"deleteAria": ""
|
||||
},
|
||||
|
||||
@@ -1277,7 +1277,6 @@
|
||||
"deleteRoom": "Excluir sala {{name}}",
|
||||
"deleteRoomBody": "Esta ação não pode ser desfeita. Esta sala e todas as suas mensagens serão excluídas permanentemente.",
|
||||
"deleteRoomTitle": "Excluir sala?",
|
||||
"dismissQueuedMessage": "Descartar mensagem na fila",
|
||||
"failedToArchiveConversation": "Falha ao arquivar a conversa",
|
||||
"failedToClearConversation": "Falha ao limpar a conversa",
|
||||
"failedToClearRoomConversation": "Falha ao limpar a conversa da sala",
|
||||
@@ -1332,6 +1331,17 @@
|
||||
"questionSubmittedAnswerLabel": "Resposta enviada",
|
||||
"questionTextPlaceholder": "Digite sua resposta aqui…",
|
||||
"queuedMessage": "Na fila: {{preview}}",
|
||||
"pendingLabel": "Mensagens pendentes",
|
||||
"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",
|
||||
"quickChatTitle": "Chat rápido",
|
||||
"relativeTimeDays_one": "há {{count}}d",
|
||||
"relativeTimeDays_other": "há {{count}}d",
|
||||
@@ -8765,19 +8775,6 @@
|
||||
"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": ""
|
||||
},
|
||||
|
||||
@@ -1266,7 +1266,6 @@
|
||||
"deleteRoom": "删除频道 {{name}}",
|
||||
"deleteRoomBody": "此操作无法撤销。该频道及其所有消息将被永久删除。",
|
||||
"deleteRoomTitle": "删除频道?",
|
||||
"dismissQueuedMessage": "关闭排队消息",
|
||||
"failedToArchiveConversation": "归档对话失败",
|
||||
"failedToClearConversation": "清除对话失败",
|
||||
"failedToClearRoomConversation": "清除房间对话失败",
|
||||
@@ -1319,6 +1318,17 @@
|
||||
"questionSubmittedAnswerLabel": "",
|
||||
"questionTextPlaceholder": "",
|
||||
"queuedMessage": "已排队:{{preview}}",
|
||||
"pendingLabel": "待发送消息",
|
||||
"pendingHeading": "待发送消息",
|
||||
"editPending": "编辑待发送消息",
|
||||
"savePendingEdit": "保存待发送消息",
|
||||
"cancelPendingEdit": "取消编辑待发送消息",
|
||||
"movePendingEarlier": "将待发送消息上移",
|
||||
"movePendingLater": "将待发送消息下移",
|
||||
"deletePending": "删除待发送消息",
|
||||
"forcePending": "强制发送待发送消息",
|
||||
"forcePendingShort": "强制发送",
|
||||
"pendingEditEmpty": "待发送消息不能为空",
|
||||
"quickChatTitle": "快速聊天",
|
||||
"relativeTimeDays_one": "",
|
||||
"relativeTimeDays_other": "",
|
||||
@@ -8316,19 +8326,6 @@
|
||||
"create": "",
|
||||
"error": ""
|
||||
},
|
||||
"plannerChat": {
|
||||
"pendingLabel": "待发送的规划器消息",
|
||||
"pendingHeading": "待发送消息",
|
||||
"editPending": "编辑待发送消息",
|
||||
"savePendingEdit": "保存待发送消息",
|
||||
"cancelPendingEdit": "取消编辑待发送消息",
|
||||
"movePendingEarlier": "将待发送消息上移",
|
||||
"movePendingLater": "将待发送消息下移",
|
||||
"deletePending": "删除待发送消息",
|
||||
"forcePending": "强制发送待发送消息",
|
||||
"forcePendingShort": "强制发送",
|
||||
"pendingEditEmpty": "待发送消息不能为空"
|
||||
},
|
||||
"reverted": {
|
||||
"deleteAria": ""
|
||||
},
|
||||
|
||||
@@ -1266,7 +1266,6 @@
|
||||
"deleteRoom": "刪除頻道 {{name}}",
|
||||
"deleteRoomBody": "此操作無法復原。此頻道及其所有訊息將被永久刪除。",
|
||||
"deleteRoomTitle": "刪除頻道?",
|
||||
"dismissQueuedMessage": "關閉排隊訊息",
|
||||
"failedToArchiveConversation": "封存對話失敗",
|
||||
"failedToClearConversation": "清除對話失敗",
|
||||
"failedToClearRoomConversation": "清除房間對話失敗",
|
||||
@@ -1319,6 +1318,17 @@
|
||||
"questionSubmittedAnswerLabel": "",
|
||||
"questionTextPlaceholder": "",
|
||||
"queuedMessage": "已排隊:{{preview}}",
|
||||
"pendingLabel": "待傳送訊息",
|
||||
"pendingHeading": "待傳送訊息",
|
||||
"editPending": "編輯待傳送訊息",
|
||||
"savePendingEdit": "儲存待傳送訊息",
|
||||
"cancelPendingEdit": "取消編輯待傳送訊息",
|
||||
"movePendingEarlier": "將待傳送訊息上移",
|
||||
"movePendingLater": "將待傳送訊息下移",
|
||||
"deletePending": "刪除待傳送訊息",
|
||||
"forcePending": "強制傳送待傳送訊息",
|
||||
"forcePendingShort": "強制傳送",
|
||||
"pendingEditEmpty": "待傳送訊息不能為空",
|
||||
"quickChatTitle": "快速聊天",
|
||||
"relativeTimeDays_one": "",
|
||||
"relativeTimeDays_other": "",
|
||||
@@ -8316,19 +8326,6 @@
|
||||
"create": "",
|
||||
"error": ""
|
||||
},
|
||||
"plannerChat": {
|
||||
"pendingLabel": "待傳送的規劃器訊息",
|
||||
"pendingHeading": "待傳送訊息",
|
||||
"editPending": "編輯待傳送訊息",
|
||||
"savePendingEdit": "儲存待傳送訊息",
|
||||
"cancelPendingEdit": "取消編輯待傳送訊息",
|
||||
"movePendingEarlier": "將待傳送訊息上移",
|
||||
"movePendingLater": "將待傳送訊息下移",
|
||||
"deletePending": "刪除待傳送訊息",
|
||||
"forcePending": "強制傳送待傳送訊息",
|
||||
"forcePendingShort": "強制傳送",
|
||||
"pendingEditEmpty": "待傳送訊息不能為空白"
|
||||
},
|
||||
"reverted": {
|
||||
"deleteAria": ""
|
||||
},
|
||||
|
||||
25
packages/i18n/src/resources.d.ts
vendored
25
packages/i18n/src/resources.d.ts
vendored
@@ -1250,6 +1250,7 @@ export default interface Resources {
|
||||
"attachFiles": "Attach files",
|
||||
"cancel": "Cancel",
|
||||
"cancelButton": "Cancel",
|
||||
"cancelPendingEdit": "Cancel queued message edit",
|
||||
"clearConversationFailed": "Failed to clear conversation",
|
||||
"closeQuickChat": "Close quick chat",
|
||||
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens",
|
||||
@@ -1269,10 +1270,11 @@ export default interface Resources {
|
||||
"deleteConversation": "Delete conversation",
|
||||
"deleteConversationBody": "This action cannot be undone. All messages in this conversation will be permanently deleted.",
|
||||
"deleteConversationTitle": "Delete Conversation?",
|
||||
"deletePending": "Delete queued message",
|
||||
"deleteRoom": "Delete room {{name}}",
|
||||
"deleteRoomBody": "This action cannot be undone. This room and all its messages will be permanently deleted.",
|
||||
"deleteRoomTitle": "Delete Room?",
|
||||
"dismissQueuedMessage": "Dismiss queued message",
|
||||
"editPending": "Edit queued message",
|
||||
"failedToArchiveConversation": "Failed to archive conversation",
|
||||
"failedToClearConversation": "Failed to clear conversation",
|
||||
"failedToClearRoomConversation": "Failed to clear room conversation",
|
||||
@@ -1282,6 +1284,8 @@ export default interface Resources {
|
||||
"failedToRenameConversation": "Failed to rename conversation",
|
||||
"failedToSendRoomMessage": "Failed to send room message",
|
||||
"failureDetails": "Failure details",
|
||||
"forcePending": "Force send queued message",
|
||||
"forcePendingShort": "Force send",
|
||||
"helpMessageContent": "Available commands:\n- `/new` or `/clear` — Clear conversation and start fresh\n- `/skill:{name}` — Use a specific skill\n- `/help` — Show this help",
|
||||
"jumpToLatest": "Latest",
|
||||
"latest": "Latest",
|
||||
@@ -1303,6 +1307,8 @@ export default interface Resources {
|
||||
"modeAgent": "Agent",
|
||||
"modeModel": "Model",
|
||||
"modelAgentSection": "Model / Agent",
|
||||
"movePendingEarlier": "Move queued message earlier",
|
||||
"movePendingLater": "Move queued message later",
|
||||
"newChat": "New Chat",
|
||||
"newChatModeAgent": "Agent",
|
||||
"newChatModeModel": "Model",
|
||||
@@ -1318,6 +1324,9 @@ export default interface Resources {
|
||||
"noSkillsAvailable": "No skills available",
|
||||
"noSkillsFound": "No skills found",
|
||||
"openQuickChat": "Open quick chat",
|
||||
"pendingEditEmpty": "Queued messages cannot be empty",
|
||||
"pendingHeading": "Pending messages",
|
||||
"pendingLabel": "Pending messages",
|
||||
"questionAnsweredLabel": "Answered",
|
||||
"questionAnsweredWithoutContent": "A later user reply answered this question.",
|
||||
"questionConfirmNo": "No",
|
||||
@@ -1360,6 +1369,7 @@ export default interface Resources {
|
||||
"roomMemberCount_other": "{{count}} members",
|
||||
"roomsGroupLabel": "Rooms",
|
||||
"save": "Save",
|
||||
"savePendingEdit": "Save queued message",
|
||||
"scopeDirect": "Direct",
|
||||
"scopeRooms": "Rooms",
|
||||
"scrollMessageToTop": "Scroll message to top",
|
||||
@@ -8275,19 +8285,6 @@ 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",
|
||||
|
||||
Reference in New Issue
Block a user