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:
Fusion Agent
2026-08-19 06:27:16 +00:00
parent a7afb020e9
commit 21b3baa62d
19 changed files with 746 additions and 434 deletions

View File

@@ -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. -->

View File

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

View File

@@ -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}
/>

View File

@@ -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%;
}
}

View 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>
);
}

View File

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

View File

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

View File

@@ -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()} />);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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