Standardize expandable tool-call payloads across dashboard chat and log surfaces. - Add a shared formatter and detail renderer that preserves complete browser-available arguments and results. - Use compact previews with wrapped, scrollable expanded payloads in chat, activity, and agent logs. - Cover formatting and rendering behavior with dashboard tests and document the display contract. Files changed: .changeset/fn-8701-tool-call-display.md | 7 ++ docs/dashboard-guide.md | 4 +- .../dashboard/app/components/AgentLogViewer.tsx | 12 +++- packages/dashboard/app/components/ChatView.css | 37 ++--------- .../app/components/StandardChatSurface.tsx | 56 +++++++--------- packages/dashboard/app/components/TaskChatTab.css | 17 ----- packages/dashboard/app/components/TaskChatTab.tsx | 31 +++++---- .../dashboard/app/components/ToolCallDetails.css | 52 +++++++++++++++ .../dashboard/app/components/ToolCallDetails.tsx | 77 ++++++++++++++++++++++ .../__tests__/AgentLogViewer.rendering.test.tsx | 8 +++ .../components/__tests__/ChatView.core.test.tsx | 24 +++++++ .../app/components/__tests__/TaskChatTab.test.tsx | 33 +++++++--- .../__tests__/TaskPlannerChatTab.test.tsx | 19 ++++++ 13 files changed, 275 insertions(+), 102 deletions(-) Fusion-Task-Id: FN-8701 Fusion-Task-Lineage: 86513f9a-31d4-45fb-97fe-1f0c73d0e5b3 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
813 lines
44 KiB
TypeScript
813 lines
44 KiB
TypeScript
import type { Agent } from "@fusion/core";
|
|
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
|
|
import type { Components } from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import { ArrowUpToLine, Bot, File, Pencil, Send, TriangleAlert } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { ChatMessageInfo, FailureInfo, ToolCallInfo } from "../hooks/chatTypes";
|
|
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
|
import { parseQuestionToolCall } from "../utils/parseQuestionToolCall";
|
|
import { ChatQuestionResponse } from "./ChatQuestionResponse";
|
|
import { ProviderIcon } from "./ProviderIcon";
|
|
import { NativeStructurePreview } from "./NativeStructurePreview";
|
|
import { openNativeStructure } from "./nativeStructureNavigation";
|
|
import { nativeStructureChatRefMatcher, parseNativeStructureChatRef, splitNativeStructureChatRefMatch } from "./nativeStructureChatRef";
|
|
import { MicButton } from "./MicButton";
|
|
import { useComposerDictation } from "../hooks/useComposerDictation";
|
|
import { ToolCallDetails, formatToolArgsPreview, formatToolPreview, hasToolCallDetails } from "./ToolCallDetails";
|
|
|
|
export interface StandardRoomContext {
|
|
roomName: string;
|
|
memberIds: ReadonlySet<string>;
|
|
}
|
|
|
|
export interface StandardChatMessageItemProps {
|
|
message: ChatMessageInfo;
|
|
forcePlain: boolean;
|
|
agentName: string;
|
|
hideAssistantIdentity: boolean;
|
|
showAssistantModelTag: boolean;
|
|
activeModelTag: string | null;
|
|
activeModelProvider: string | null;
|
|
activeSessionId: string | null;
|
|
/** The owning dashboard project keeps voice availability isolated in multi-project views. */
|
|
projectId?: string;
|
|
mentionAgentsByName?: Map<string, Agent>;
|
|
roomContext?: StandardRoomContext | null;
|
|
copyAction?: ReactNode;
|
|
onScrollToTop?: (messageId: string) => void;
|
|
/**
|
|
* FNXC:ChatMessageScrollToTop 2026-07-12-23:09:
|
|
* ChatView owns scroll-container measurement and sets this when the message top is clipped above the visible container top. StandardChatSurface keeps eligible go-to-top controls mounted for tests/accessibility wiring but hides them until this state is true, and renders the control inline with the Thinking row instead of a standalone action line.
|
|
*/
|
|
isTopClipped?: boolean;
|
|
isAwaitingQuestionAnswer?: boolean;
|
|
submittedQuestionAnswer?: string;
|
|
onQuestionSubmit?: (answerText: string, structured: Record<string, unknown>) => void;
|
|
toolCallRenderer?: (toolCall: ToolCallInfo, index: number) => ReactNode | undefined;
|
|
/**
|
|
* FNXC:ChatMessageEdit 2026-07-07-09:00:
|
|
* When set together with `canEdit`, a user message renders an edit affordance that swaps its
|
|
* content for an inline textarea. Saving calls this with the edited text; the caller is
|
|
* responsible for truncating server + local history from this message onward and resending
|
|
* (see useChat.editMessageAndResend) so the model forgets everything after the edited turn.
|
|
* Only rendered for `role === "user"` messages — never for assistant/system messages.
|
|
*/
|
|
onEditMessage?: (messageId: string, newContent: string) => void | Promise<void>;
|
|
/**
|
|
* Gate for whether editing is currently supported/allowed for this message's surface (direct
|
|
* model-loop chat, not Rooms or CLI-agent sessions) and state (not while streaming). When
|
|
* false or `onEditMessage` is absent, no affordance renders at all — never a disabled/dead one.
|
|
*/
|
|
canEdit?: boolean;
|
|
}
|
|
|
|
export interface StandardStreamingMessageProps {
|
|
streamingText: string;
|
|
streamingThinking?: string;
|
|
streamingToolCalls?: ToolCallInfo[];
|
|
forcePlain: boolean;
|
|
agentName: string;
|
|
hideAssistantIdentity: boolean;
|
|
showAssistantModelTag: boolean;
|
|
activeModelTag: string | null;
|
|
activeModelProvider: string | null;
|
|
copyAction?: ReactNode;
|
|
onQuestionSubmit?: (answerText: string, structured: Record<string, unknown>) => void;
|
|
toolCallRenderer?: (toolCall: ToolCallInfo, index: number) => ReactNode | undefined;
|
|
}
|
|
|
|
export interface StandardChatActionButtonProps {
|
|
isStreaming: boolean;
|
|
canSend: boolean;
|
|
onSend: () => void | Promise<void>;
|
|
onStop?: () => void;
|
|
sendLabel?: string;
|
|
stopLabel?: string;
|
|
classNameSend?: string;
|
|
classNameStop?: string;
|
|
showSendText?: boolean;
|
|
/**
|
|
* FNXC:StandardChatSurface 2026-07-07-00:00:
|
|
* Send and Stop visible-text are independently controllable so callers like
|
|
* the planner (FN-7655) can render the Stop button icon-only while keeping
|
|
* the Send button's text label. Defaults to `showSendText` when unset so
|
|
* existing callers keep prior combined behavior (accessible name via
|
|
* aria-label is always preserved regardless of this flag).
|
|
*/
|
|
showStopText?: boolean;
|
|
sendTestId?: string;
|
|
stopTestId?: string;
|
|
}
|
|
|
|
/**
|
|
* FNXC:StandardChatSurface 2026-07-01-09:31:
|
|
* Task-detail planner Chat must reuse the standard Chat message, thinking, tool-call, and mobile send/stop surface without statically importing the lazy ChatView chunk. Keep this module presentation-only so ChatView can stay lazy while TaskPlannerChatTab preserves task-scoped planner session lifecycle.
|
|
*/
|
|
export function formatModelTag(provider?: string | null, modelId?: string | null): string | null {
|
|
if (!provider || !modelId) return null;
|
|
const normalizedModel = modelId.toLowerCase();
|
|
if (normalizedModel.includes("claude")) {
|
|
const formatted = modelId
|
|
.replace(/^claude[- ]/i, "Claude ")
|
|
.replace(/sonnet[- ](\d+)[- ](\d+)/i, "Sonnet $1.$2")
|
|
.replace(/sonnet[- ](\d+)/i, "Sonnet $1")
|
|
.replace(/haiku[- ](\d+)/i, "Haiku $1")
|
|
.replace(/opus[- ](\d+)/i, "Opus $1")
|
|
.replace(/sonnet/i, "Sonnet")
|
|
.replace(/haiku/i, "Haiku")
|
|
.replace(/opus/i, "Opus")
|
|
.replace(/-/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
return formatted.length > 30 ? `${formatted.slice(0, 30)}…` : formatted;
|
|
}
|
|
if (normalizedModel.includes("gpt") || normalizedModel.includes("openai")) {
|
|
const formatted = modelId
|
|
.replace(/^gpt-4-turbo$/i, "GPT-4 Turbo")
|
|
.replace(/^gpt-4o-mini$/i, "GPT-4o Mini")
|
|
.replace(/^gpt-4o$/i, "GPT-4o")
|
|
.replace(/^gpt-4$/i, "GPT-4")
|
|
.replace(/^gpt-o1-preview$/i, "GPT-o1 Preview")
|
|
.replace(/^gpt-o1-mini$/i, "GPT-o1 Mini")
|
|
.replace(/^gpt-o1$/i, "GPT-o1")
|
|
.replace(/^gpt/i, "GPT")
|
|
.trim();
|
|
return formatted.length > 30 ? `${formatted.slice(0, 30)}…` : formatted;
|
|
}
|
|
if (normalizedModel.includes("gemini")) {
|
|
const formatted = modelId
|
|
.replace(/^gemini[- ]/i, "Gemini ")
|
|
.replace(/pro[- ](\d+)[- ](\d+)/i, "Pro $1.$2")
|
|
.replace(/pro[- ](\d+)/i, "Pro $1")
|
|
.replace(/-/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
return formatted.length > 30 ? `${formatted.slice(0, 30)}…` : formatted;
|
|
}
|
|
const formatted = modelId.replace(/-/g, " ").replace(/^\w/, (c) => c.toUpperCase()).replace(/\s+/g, " ").trim();
|
|
return formatted.length > 30 ? `${formatted.slice(0, 30)}…` : formatted;
|
|
}
|
|
|
|
function formatToolResultSummary(result: unknown): string | null {
|
|
return formatToolPreview(result, 200);
|
|
}
|
|
|
|
function buildFailureReferenceHref(reference: FailureInfo["reference"]): string | null {
|
|
if (!reference) return null;
|
|
if (reference.kind === "mailbox" || reference.kind === "mailbox-message") {
|
|
const pathname = typeof window === "undefined" ? "/" : window.location.pathname || "/";
|
|
const params = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
|
|
params.set("view", "mailbox");
|
|
params.set("mailbox-message", reference.id);
|
|
return `${pathname}?${params.toString()}#message-${encodeURIComponent(reference.id)}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function renderFailureReference(reference: FailureInfo["reference"], t: (key: string, defaultValue: string) => string): ReactNode {
|
|
if (!reference) return null;
|
|
const referenceLabel = reference.label ?? `${reference.kind} ${reference.id}`;
|
|
const referenceHref = buildFailureReferenceHref(reference);
|
|
const referenceDetailsId = `chat-failure-reference-${reference.kind}-${reference.id}`.replace(/[^a-zA-Z0-9_-]+/g, "-").toLowerCase();
|
|
return (
|
|
<div className="chat-message-failure-reference">
|
|
<span className="chat-message-failure-reference-label">{t("chat.failureReferenceLabel", "Reference")}</span>
|
|
<span className="chat-message-failure-reference-value">{referenceLabel}</span>
|
|
{referenceHref ? (
|
|
<a className="btn btn-sm chat-message-failure-reference-link" href={referenceHref}>{t("chat.openMailboxMessage", "Open mailbox message")}</a>
|
|
) : (
|
|
<details className="chat-message-failure-reference-details">
|
|
<summary className="btn btn-sm chat-message-failure-reference-link">{t("chat.viewFailureDetails", "View failure details")}</summary>
|
|
<dl className="chat-message-failure-reference-meta" id={referenceDetailsId}>
|
|
<div><dt>{t("chat.failureReferenceKind", "Kind")}</dt><dd>{reference.kind}</dd></div>
|
|
<div><dt>{t("chat.failureReferenceId", "ID")}</dt><dd>{reference.id}</dd></div>
|
|
{reference.label && <div><dt>{t("chat.failureReferenceMetaLabel", "Label")}</dt><dd>{reference.label}</dd></div>}
|
|
</dl>
|
|
</details>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function renderStandardToolCalls(
|
|
toolCalls: ToolCallInfo[] | undefined,
|
|
t: (key: string, defaultValue: string, opts?: Record<string, unknown>) => string,
|
|
options?: {
|
|
isAwaitingAnswer?: boolean;
|
|
submittedAnswer?: string;
|
|
onQuestionSubmit?: (answerText: string, structured: Record<string, unknown>) => void;
|
|
toolCallRenderer?: (toolCall: ToolCallInfo, index: number) => ReactNode | undefined;
|
|
},
|
|
): ReactNode {
|
|
if (!toolCalls || toolCalls.length === 0) return null;
|
|
/*
|
|
FNXC:StandardChatSurface 2026-07-01-09:20:
|
|
Planner Chat and regular Chat must surface `fn_ask_question` as an actionable ChatQuestionResponse even when the model also calls tools such as `bash`. Question cards render outside the collapsed grouped tool-call details so users are not stranded on summary text, while non-question tools keep their generic visibility.
|
|
*/
|
|
const renderToolCallItem = (toolCall: ToolCallInfo, index: number) => {
|
|
const custom = options?.toolCallRenderer?.(toolCall, index);
|
|
if (custom !== undefined) return custom;
|
|
const parsedQuestion = parseQuestionToolCall(toolCall);
|
|
if (parsedQuestion) {
|
|
const isAwaitingAnswer = options?.isAwaitingAnswer === true;
|
|
return (
|
|
<ChatQuestionResponse
|
|
key={`${toolCall.toolName}-${index}`}
|
|
parsed={parsedQuestion}
|
|
answered={!isAwaitingAnswer}
|
|
submittedAnswer={options?.submittedAnswer}
|
|
disabled={!isAwaitingAnswer}
|
|
onSubmit={(answerText, structured) => options?.onQuestionSubmit?.(answerText, structured)}
|
|
/>
|
|
);
|
|
}
|
|
const isRunning = toolCall.status === "running";
|
|
const isError = toolCall.status === "completed" && toolCall.isError;
|
|
const argsSummary = formatToolArgsPreview(toolCall.args);
|
|
const resultSummary = formatToolResultSummary(toolCall.result);
|
|
const summaryPreview = isRunning ? argsSummary : resultSummary ? `${t("chat.toolCallResultPrefix", "result")}: ${resultSummary}` : argsSummary ? `${t("chat.toolCallArgsPrefix", "args")}: ${argsSummary}` : null;
|
|
const statusLabel = isRunning ? t("chat.toolCallStatusRunning", "running") : isError ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusCompleted", "completed");
|
|
const className = `chat-tool-call${isRunning ? " chat-tool-call--running" : ""}${isError ? " chat-tool-call--error" : ""}`;
|
|
const summary = (
|
|
<>
|
|
<span className="chat-tool-call-status-dot" aria-hidden="true" />
|
|
<span className="chat-tool-call-name" title={toolCall.toolName}>{toolCall.toolName}</span>
|
|
{summaryPreview && <span className="chat-tool-call-preview" title={summaryPreview}>{summaryPreview}</span>}
|
|
<span className="chat-tool-call-status-text">{statusLabel}</span>
|
|
</>
|
|
);
|
|
if (!hasToolCallDetails(toolCall.args, toolCall.result)) {
|
|
return <div key={`${toolCall.toolName}-${index}`} className={className}><div className="chat-tool-call-summary">{summary}</div></div>;
|
|
}
|
|
return (
|
|
<details key={`${toolCall.toolName}-${index}`} className={className} open={isRunning}>
|
|
<summary>{summary}</summary>
|
|
<ToolCallDetails
|
|
className="chat-tool-call-content"
|
|
argumentsValue={toolCall.args}
|
|
resultValue={toolCall.result}
|
|
argumentsLabel={t("chat.toolCallArgsPrefix", "args")}
|
|
resultLabel={t("chat.toolCallResultPrefix", "result")}
|
|
resultIsError={isError}
|
|
/>
|
|
</details>
|
|
);
|
|
};
|
|
const questionEntries: ReactNode[] = [];
|
|
const nonQuestionEntries: Array<{ toolCall: ToolCallInfo; index: number }> = [];
|
|
toolCalls.forEach((toolCall, index) => {
|
|
if (parseQuestionToolCall(toolCall)) {
|
|
const renderedQuestion = renderToolCallItem(toolCall, index);
|
|
if (renderedQuestion !== null && renderedQuestion !== undefined && renderedQuestion !== false) {
|
|
questionEntries.push(renderedQuestion);
|
|
}
|
|
return;
|
|
}
|
|
nonQuestionEntries.push({ toolCall, index });
|
|
});
|
|
const renderNonQuestionToolCalls = (): ReactNode => {
|
|
if (nonQuestionEntries.length === 0) return null;
|
|
if (nonQuestionEntries.length === 1) {
|
|
const entry = nonQuestionEntries[0]!;
|
|
return <div className="chat-tool-calls" data-testid="chat-tool-calls"><div className="chat-tool-calls-header"><span className="chat-tool-calls-header-icon" aria-hidden="true">•</span><span>{t("chat.toolCallsHeader", "Tool calls")}</span></div>{renderToolCallItem(entry.toolCall, entry.index)}</div>;
|
|
}
|
|
const nonQuestionToolCalls = nonQuestionEntries.map((entry) => entry.toolCall);
|
|
const runningCount = nonQuestionToolCalls.filter((toolCall) => toolCall.status === "running").length;
|
|
const errorCount = nonQuestionToolCalls.filter((toolCall) => toolCall.status === "completed" && toolCall.isError).length;
|
|
const hasRunning = runningCount > 0;
|
|
const uniqueNames = Array.from(new Set(nonQuestionToolCalls.map((toolCall) => toolCall.toolName)));
|
|
const visibleNames = uniqueNames.slice(0, 5);
|
|
const overflowCount = Math.max(0, uniqueNames.length - visibleNames.length);
|
|
const namesSummary = overflowCount > 0 ? `${visibleNames.join(", ")}, +${overflowCount} more` : visibleNames.join(", ");
|
|
const statusSummary = hasRunning ? `(${runningCount} ${t("chat.toolCallStatusRunning", "running")})` : errorCount > 0 ? `(${errorCount} ${errorCount === 1 ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusErrors", "errors")})` : null;
|
|
return (
|
|
<div className="chat-tool-calls" data-testid="chat-tool-calls">
|
|
<details className="chat-tool-calls-group" data-testid="chat-tool-calls-group" open={hasRunning}>
|
|
<summary className="chat-tool-calls-group-summary">
|
|
<span className="chat-tool-calls-header-icon" aria-hidden="true">•</span>
|
|
<span className="chat-tool-calls-count">{t("chat.toolCallsCount", "{{count}} tool calls", { count: nonQuestionToolCalls.length })}</span>
|
|
<span className="chat-tool-calls-names" title={namesSummary}>{namesSummary}</span>
|
|
{statusSummary && <span className="chat-tool-calls-group-status">{statusSummary}</span>}
|
|
</summary>
|
|
{nonQuestionEntries.map(({ toolCall, index }) => renderToolCallItem(toolCall, index))}
|
|
</details>
|
|
</div>
|
|
);
|
|
};
|
|
const renderedNonQuestionToolCalls = renderNonQuestionToolCalls();
|
|
if (questionEntries.length === 0) return renderedNonQuestionToolCalls;
|
|
return (
|
|
<>
|
|
{questionEntries}
|
|
{renderedNonQuestionToolCalls}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* FNXC:NativeStructureEmbed 2026-07-19-19:30:
|
|
* ReactMarkdown leaves bare custom-scheme tokens as text, unlike Markdown links. Transform text
|
|
* at this shared rendering seam so room, task-bound, floating, and dock assistant messages all
|
|
* gain the same card without teaching individual ChatView hosts how to parse references.
|
|
*/
|
|
function renderNativeStructureChatTokens(text: string): ReactNode[] {
|
|
const nodes: ReactNode[] = [];
|
|
let lastIndex = 0;
|
|
let index = 0;
|
|
nativeStructureChatRefMatcher.lastIndex = 0;
|
|
let match = nativeStructureChatRefMatcher.exec(text);
|
|
while (match) {
|
|
const { token, trailingPunctuation } = splitNativeStructureChatRefMatch(match);
|
|
const start = match.index;
|
|
const structureRef = parseNativeStructureChatRef(token);
|
|
if (!structureRef) {
|
|
match = nativeStructureChatRefMatcher.exec(text);
|
|
continue;
|
|
}
|
|
if (start > lastIndex) nodes.push(text.slice(lastIndex, start));
|
|
nodes.push(<NativeStructurePreview key={`native-structure-${start}-${index}`} ref={structureRef} onOpen={openNativeStructure} />);
|
|
if (trailingPunctuation) nodes.push(trailingPunctuation);
|
|
lastIndex = start + match[0].length;
|
|
index += 1;
|
|
match = nativeStructureChatRefMatcher.exec(text);
|
|
}
|
|
if (lastIndex === 0) return [text];
|
|
if (lastIndex < text.length) nodes.push(text.slice(lastIndex));
|
|
return nodes;
|
|
}
|
|
|
|
/**
|
|
* FNXC:NativeStructureEmbed 2026-07-19-21:15:
|
|
* NativeStructurePreview has a block root. Paragraphs and headings must lift a detected preview
|
|
* into a sibling instead of nesting it in their phrasing-only content, preserving valid HTML and
|
|
* heading semantics while retaining prose before and after a token.
|
|
*/
|
|
type NativeStructurePreviewMarker = { readonly structureRef: React.ComponentProps<typeof NativeStructurePreview>["ref"] };
|
|
type NativeStructureMarkdownPart = ReactNode | NativeStructurePreviewMarker;
|
|
|
|
function isNativeStructurePreviewMarker(part: NativeStructureMarkdownPart): part is NativeStructurePreviewMarker {
|
|
return typeof part === "object" && part !== null && "structureRef" in part;
|
|
}
|
|
|
|
/**
|
|
* FNXC:NativeStructureEmbed 2026-07-20-00:15:
|
|
* Markdown phrasing nodes such as `strong` can wrap a bare token or custom link. Split those
|
|
* wrappers around preview markers before the paragraph/heading renderer lifts each card, rather
|
|
* than placing the preview's block root inside `<strong>` or another phrasing-only container.
|
|
*/
|
|
function splitMarkdownNodeAtNativeStructurePreviews(node: ReactNode): NativeStructureMarkdownPart[] {
|
|
if (typeof node === "string") {
|
|
return renderNativeStructureChatTokens(node).map((part) => (
|
|
React.isValidElement<React.ComponentProps<typeof NativeStructurePreview>>(part) && part.type === NativeStructurePreview
|
|
? { structureRef: part.props.ref }
|
|
: part
|
|
));
|
|
}
|
|
if (!React.isValidElement<{ children?: ReactNode; href?: string }>(node)) return [node];
|
|
|
|
const linkedRef = node.type === NativeStructureMarkdownAnchor ? parseNativeStructureChatRef(node.props.href ?? "") : null;
|
|
if (linkedRef) return [{ structureRef: linkedRef }];
|
|
if (node.type === NativeStructurePreview) return [{ structureRef: (node.props as React.ComponentProps<typeof NativeStructurePreview>).ref }];
|
|
// Links and code are opaque text islands: preview cards must never become nested interactive content.
|
|
if (node.type === "a" || node.type === NativeStructureMarkdownAnchor || node.type === NativeStructureMarkdownCode || node.props.children === undefined) return [node];
|
|
|
|
const childParts = React.Children.toArray(node.props.children).flatMap(splitMarkdownNodeAtNativeStructurePreviews);
|
|
if (!childParts.some(isNativeStructurePreviewMarker)) {
|
|
return [React.cloneElement(node, undefined, childParts as ReactNode[])];
|
|
}
|
|
|
|
const parts: NativeStructureMarkdownPart[] = [];
|
|
let inlineChildren: ReactNode[] = [];
|
|
const flushInlineChildren = () => {
|
|
if (inlineChildren.length > 0) {
|
|
parts.push(React.cloneElement(node, undefined, inlineChildren));
|
|
inlineChildren = [];
|
|
}
|
|
};
|
|
for (const childPart of childParts) {
|
|
if (isNativeStructurePreviewMarker(childPart)) {
|
|
flushInlineChildren();
|
|
parts.push(childPart);
|
|
} else {
|
|
inlineChildren.push(childPart);
|
|
}
|
|
}
|
|
flushInlineChildren();
|
|
return parts;
|
|
}
|
|
|
|
function renderMarkdownBlockWithNativeStructurePreviews(
|
|
Tag: "p" | "li" | "blockquote" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "table",
|
|
children: ReactNode,
|
|
props: React.HTMLAttributes<HTMLElement>,
|
|
): ReactNode {
|
|
const blocks: ReactNode[] = [];
|
|
let inlineChildren: ReactNode[] = [];
|
|
let index = 0;
|
|
const flushInlineChildren = () => {
|
|
if (inlineChildren.length > 0) {
|
|
blocks.push(<Tag key={`native-structure-inline-${index}`} {...props}>{linkifyReactChildren(inlineChildren)}</Tag>);
|
|
inlineChildren = [];
|
|
index += 1;
|
|
}
|
|
};
|
|
for (const part of React.Children.toArray(children).flatMap(splitMarkdownNodeAtNativeStructurePreviews)) {
|
|
if (isNativeStructurePreviewMarker(part)) {
|
|
flushInlineChildren();
|
|
blocks.push(<NativeStructurePreview key={`native-structure-preview-${index}`} ref={part.structureRef} onOpen={openNativeStructure} />);
|
|
index += 1;
|
|
} else {
|
|
inlineChildren.push(part);
|
|
}
|
|
}
|
|
flushInlineChildren();
|
|
return blocks.length === 1 ? blocks[0] : <>{blocks}</>;
|
|
}
|
|
|
|
function NativeStructureMarkdownAnchor({ children, href, ...props }: React.ComponentProps<"a">) {
|
|
const structureRef = href ? parseNativeStructureChatRef(href) : null;
|
|
if (structureRef) return <NativeStructurePreview ref={structureRef} onOpen={openNativeStructure} />;
|
|
return <a href={href} {...props}>{children}</a>;
|
|
}
|
|
|
|
function NativeStructureMarkdownCode({ children, ...props }: React.ComponentProps<"code">) {
|
|
const text = typeof children === "string" ? children : React.Children.toArray(children).join("");
|
|
const linkedChildren = linkifyFilePaths(text);
|
|
if (linkedChildren.length === 1 && typeof linkedChildren[0] === "string") return <code {...props}>{children}</code>;
|
|
return <code {...props}>{linkedChildren}</code>;
|
|
}
|
|
|
|
export const standardChatMarkdownComponents: Components = {
|
|
p: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("p", children, props),
|
|
// FNXC:NativeStructureEmbed 2026-07-20-01:00: List and quote bodies can contain Markdown
|
|
// phrasing wrappers, so they use the same marker/lifting pass as paragraphs instead of nesting
|
|
// NativeStructurePreview inside `strong`, `em`, or other phrasing-only elements.
|
|
li: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("li", children, props),
|
|
blockquote: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("blockquote", children, props),
|
|
h1: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("h1", children, props),
|
|
h2: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("h2", children, props),
|
|
h3: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("h3", children, props),
|
|
h4: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("h4", children, props),
|
|
h5: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("h5", children, props),
|
|
h6: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("h6", children, props),
|
|
// Table descendants are lifted by the table renderer below. A cell itself must stay textual:
|
|
// HTML tables cannot contain the preview card's block root directly.
|
|
td: ({ children, ...props }) => <td {...props}>{linkifyReactChildren(children)}</td>,
|
|
th: ({ children, ...props }) => <th {...props}>{linkifyReactChildren(children)}</th>,
|
|
/*
|
|
FNXC:NativeStructureEmbed 2026-07-19-19:30:
|
|
Markdown links and bare assistant tokens take different ReactMarkdown paths. Only a strict
|
|
canonical link becomes the shared preview; every other href keeps normal rendering and URL
|
|
sanitization, including ReactMarkdown's javascript: rejection.
|
|
*/
|
|
a: NativeStructureMarkdownAnchor,
|
|
pre: ({ children, ...props }) => <pre {...props} className="chat-markdown-pre">{children}</pre>,
|
|
code: NativeStructureMarkdownCode,
|
|
// FNXC:NativeStructureEmbed 2026-07-20-01:00: Lift markers through the full table tree so a
|
|
// formatted token in a cell never creates invalid `<td><div>` markup. The card becomes a
|
|
// sibling block; non-reference table content retains its normal table structure.
|
|
table: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("table", children, { ...props, className: "chat-markdown-table" }),
|
|
};
|
|
|
|
function formatRelativeTime(dateStr: string, t: (key: string, defaultValue: string, opts?: Record<string, unknown>) => string): string {
|
|
const date = new Date(dateStr);
|
|
const now = new Date();
|
|
const diffSecs = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
const diffMins = Math.floor(diffSecs / 60);
|
|
const diffHours = Math.floor(diffMins / 60);
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
if (diffSecs < 60) return t("chat.relativeTimeJustNow", "just now");
|
|
if (diffMins < 60) return t("chat.relativeTimeMinutes", "{{count}}m ago", { count: diffMins });
|
|
if (diffHours < 24) return t("chat.relativeTimeHours", "{{count}}h ago", { count: diffHours });
|
|
if (diffDays < 7) return t("chat.relativeTimeDays", "{{count}}d ago", { count: diffDays });
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
export function renderStandardAssistantContent(content: string, forcePlain: boolean): ReactNode {
|
|
if (forcePlain) return <div className="chat-message-content chat-message-content--plain">{content}</div>;
|
|
return (
|
|
<div className="chat-message-content chat-message-content--markdown">
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
components={standardChatMarkdownComponents}
|
|
urlTransform={(href) => parseNativeStructureChatRef(href) ? href : defaultUrlTransform(href)}
|
|
>
|
|
{content}
|
|
</ReactMarkdown>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* FNXC:VoiceInput 2026-07-25-04:15:
|
|
* Mount dictation only while the correction textarea is open. Message rows must not each poll
|
|
* voice availability while merely rendering history; this editor remains the shared Quick Chat path.
|
|
*/
|
|
function StandardChatMessageEditComposer({
|
|
value,
|
|
onChange,
|
|
onCancel,
|
|
onSave,
|
|
disabled,
|
|
saveDisabled,
|
|
messageId,
|
|
projectId,
|
|
}: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
onCancel: () => void;
|
|
onSave: () => void;
|
|
disabled: boolean;
|
|
saveDisabled: boolean;
|
|
messageId: string;
|
|
projectId?: string;
|
|
}) {
|
|
const { t } = useTranslation("app");
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
// FNXC:VoiceInput 2026-07-25-12:15: Message correction dictation must resolve availability
|
|
// within the owning project; falling back to another project's settings can expose the mic incorrectly.
|
|
const dictation = useComposerDictation({ textareaRef, value, onChange, projectId });
|
|
|
|
useEffect(() => {
|
|
textareaRef.current?.focus();
|
|
textareaRef.current?.select();
|
|
}, []);
|
|
|
|
return (
|
|
<div className="chat-message-edit-editor" data-testid={`chat-message-edit-editor-${messageId}`}>
|
|
<textarea
|
|
ref={textareaRef}
|
|
className="input chat-message-edit-textarea"
|
|
value={value}
|
|
disabled={disabled}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
onCancel();
|
|
} else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
event.preventDefault();
|
|
onSave();
|
|
}
|
|
}}
|
|
rows={3}
|
|
/>
|
|
<div className="chat-message-edit-actions">
|
|
<MicButton {...dictation.micProps} disabled={disabled} />
|
|
<button type="button" className="btn btn-sm" data-testid={`chat-message-edit-cancel-${messageId}`} disabled={disabled} onClick={onCancel}>{t("chat.editMessageCancel", "Cancel")}</button>
|
|
<button type="button" className="btn btn-sm btn-primary" data-testid={`chat-message-edit-save-${messageId}`} disabled={saveDisabled} onClick={onSave}>{t("chat.editMessageSave", "Save")}</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const StandardChatMessageItem = memo(function StandardChatMessageItem({
|
|
message,
|
|
forcePlain,
|
|
agentName,
|
|
hideAssistantIdentity,
|
|
showAssistantModelTag,
|
|
activeModelTag,
|
|
activeModelProvider,
|
|
activeSessionId,
|
|
mentionAgentsByName = new Map(),
|
|
roomContext = null,
|
|
copyAction,
|
|
onScrollToTop,
|
|
isAwaitingQuestionAnswer = false,
|
|
submittedQuestionAnswer,
|
|
onQuestionSubmit,
|
|
toolCallRenderer,
|
|
onEditMessage,
|
|
canEdit = false,
|
|
isTopClipped = false,
|
|
projectId,
|
|
}: StandardChatMessageItemProps) {
|
|
const { t } = useTranslation("app");
|
|
const isAssistantMessage = message.role === "assistant";
|
|
const isUserMessage = message.role === "user";
|
|
/*
|
|
* FNXC:ChatMessageEdit 2026-07-07-09:00:
|
|
* Edit affordance is scoped strictly to user messages on surfaces that opt in via both
|
|
* `canEdit` and `onEditMessage`; absent either, `showEditAction` is false and nothing renders
|
|
* (no dead button, no empty shell) — e.g. assistant/system messages, Rooms, CLI-agent chat, or
|
|
* while a generation is streaming. The compact pencil renders in the timestamp footer so user
|
|
* bubbles do not grow an extra action row above their time metadata.
|
|
*/
|
|
const showEditAction = isUserMessage && canEdit && Boolean(onEditMessage);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
|
const [editedText, setEditedText] = useState(message.content);
|
|
|
|
const startEditing = useCallback(() => {
|
|
setEditedText(message.content);
|
|
setIsEditing(true);
|
|
}, [message.content]);
|
|
|
|
const cancelEditing = useCallback(() => {
|
|
if (isSavingEdit) return;
|
|
setIsEditing(false);
|
|
setEditedText(message.content);
|
|
}, [isSavingEdit, message.content]);
|
|
|
|
const saveEdit = useCallback(async () => {
|
|
const trimmed = editedText.trim();
|
|
if (!trimmed || trimmed === message.content.trim() || !onEditMessage || isSavingEdit) return;
|
|
|
|
/*
|
|
FNXC:ChatMessageEdit 2026-07-19-00:00:
|
|
Save is an async truncate-and-resend operation, not a fire-and-forget click. Keep this
|
|
editor mounted and lock its actions until the surface handler finishes so repeated clicks
|
|
cannot race the PATCH rewind with multiple stream starts; only then remove the editor.
|
|
*/
|
|
setIsSavingEdit(true);
|
|
try {
|
|
await onEditMessage(message.id, trimmed);
|
|
setIsEditing(false);
|
|
} catch {
|
|
// Surface handlers own recovery/toasts; keep the correction visible for unexpected failures.
|
|
} finally {
|
|
setIsSavingEdit(false);
|
|
}
|
|
}, [editedText, isSavingEdit, message.content, message.id, onEditMessage]);
|
|
|
|
const failureInfo = isAssistantMessage ? message.failureInfo : undefined;
|
|
/*
|
|
* FNXC:ChatEmptyMessage 2026-07-10-00:00:
|
|
* Empty assistant responses, including Grok CLI runs that finish with no text, must show a muted "No message" placeholder instead of a blank bubble. Only final persisted assistant messages with no renderable body qualify; tool calls, thinking output, attachments, or failure info already carry meaningful content and must not trigger the placeholder.
|
|
*/
|
|
const isEmptyAssistantMessage = isAssistantMessage
|
|
&& message.content.trim().length === 0
|
|
&& !failureInfo
|
|
&& (!message.toolCalls || message.toolCalls.length === 0)
|
|
&& !message.thinkingOutput
|
|
&& (!message.attachments || message.attachments.length === 0);
|
|
const showAssistantIdentity = isAssistantMessage && (!hideAssistantIdentity || Boolean(failureInfo));
|
|
const renderedUserContent = useMemo<ReactNode>(() => {
|
|
if (isAssistantMessage) return null;
|
|
const content = message.content;
|
|
const mentionRegex = /@([\w-]+)/g;
|
|
const tokens = [
|
|
...Array.from(content.matchAll(mentionRegex)).map((match) => ({ type: "mention" as const, match })),
|
|
...Array.from(content.matchAll(nativeStructureChatRefMatcher)).map((match) => ({ type: "native-structure" as const, match })),
|
|
].sort((left, right) => (left.match.index ?? 0) - (right.match.index ?? 0));
|
|
const parts: ReactNode[] = [];
|
|
let lastIndex = 0;
|
|
/*
|
|
FNXC:NativeStructureEmbed 2026-07-19-19:30:
|
|
User bodies are intentionally raw text rather than Markdown. Extend their existing mention
|
|
tokenizer with the same strict parser used for assistant text and links so native previews
|
|
work without changing user-message formatting or adding per-surface render forks.
|
|
*/
|
|
for (const token of tokens) {
|
|
const [fullMatch, rawName = ""] = token.match;
|
|
const start = token.match.index ?? 0;
|
|
if (start < lastIndex) continue;
|
|
if (start > lastIndex) parts.push(content.slice(lastIndex, start));
|
|
if (token.type === "native-structure") {
|
|
const { token: referenceToken, trailingPunctuation } = splitNativeStructureChatRefMatch(token.match);
|
|
const structureRef = parseNativeStructureChatRef(referenceToken);
|
|
parts.push(structureRef ? <React.Fragment key={`native-structure-user-${start}`}><NativeStructurePreview ref={structureRef} onOpen={openNativeStructure} />{trailingPunctuation}</React.Fragment> : fullMatch);
|
|
} else {
|
|
const normalizedName = rawName.replace(/_/g, " ").toLowerCase();
|
|
const mentionedAgent = mentionAgentsByName.get(normalizedName);
|
|
if (mentionedAgent) {
|
|
const isNonMember = Boolean(roomContext && !roomContext.memberIds.has(mentionedAgent.id));
|
|
const nonMemberLabel = isNonMember ? t("chat.mentionNonMember", "Not a member of {{roomName}}", { roomName: roomContext?.roomName }) : undefined;
|
|
parts.push(<span key={`${mentionedAgent.id}-${start}`} className={`chat-mention-chip${isNonMember ? " chat-mention-chip--non-member" : ""}`} title={nonMemberLabel} aria-label={nonMemberLabel}>@{mentionedAgent.name.replace(/\s+/g, "_")}</span>);
|
|
} else {
|
|
parts.push(fullMatch);
|
|
}
|
|
}
|
|
lastIndex = start + fullMatch.length;
|
|
}
|
|
if (lastIndex < content.length) parts.push(content.slice(lastIndex));
|
|
return parts.length === 0 ? content : parts;
|
|
}, [isAssistantMessage, message.content, mentionAgentsByName, roomContext, t]);
|
|
const renderedAttachments = useMemo<ReactNode>(() => {
|
|
const attachments = message.attachments;
|
|
if (!attachments || attachments.length === 0) return null;
|
|
const attachmentUrlBase = message.roomId ? `/api/chat/rooms/${encodeURIComponent(message.roomId)}/attachments/` : activeSessionId ? `/api/chat/sessions/${encodeURIComponent(activeSessionId)}/attachments/` : null;
|
|
if (!attachmentUrlBase) return null;
|
|
return <div className="chat-message-attachments">{attachments.map((attachment) => {
|
|
const isImage = attachment.mimeType.startsWith("image/");
|
|
const key = attachment.id || attachment.filename;
|
|
const href = `${attachmentUrlBase}${encodeURIComponent(attachment.filename)}`;
|
|
if (isImage) return <a key={key} className="chat-message-attachment-link" data-testid="chat-message-attachment" href={href} target="_blank" rel="noopener noreferrer"><img className="chat-message-attachment" src={href} alt={attachment.originalName} /></a>;
|
|
return <a key={key} className="chat-message-attachment-file" data-testid="chat-message-attachment" href={href} target="_blank" rel="noopener noreferrer"><File size={14} /><span>{attachment.originalName}</span></a>;
|
|
})}</div>;
|
|
}, [message.attachments, message.roomId, activeSessionId]);
|
|
const assistantBody = useMemo<ReactNode>(() => {
|
|
if (!isAssistantMessage) return null;
|
|
if (failureInfo) {
|
|
return <div className="chat-message-content chat-message-content--failure"><div className="chat-message-failure-summary-row"><span className="status-dot status-dot--error" aria-hidden="true" /><span className="chat-message-failure-label">{t("chat.responseFailed", "Response failed")}</span></div><div className="chat-message-failure-summary">{failureInfo.summary}</div>{(failureInfo.errorClass || failureInfo.code) && <div className="chat-message-failure-badges">{failureInfo.errorClass && <span className="chat-message-failure-badge">{failureInfo.errorClass}</span>}{failureInfo.code && <span className="chat-message-failure-badge">{failureInfo.code}</span>}</div>}{(failureInfo.detail || failureInfo.reference) && <details className="chat-message-failure-details"><summary><TriangleAlert size={14} aria-hidden="true" /><span>{t("chat.failureDetails", "Failure details")}</span></summary>{failureInfo.detail && <pre className="chat-message-failure-detail">{linkifyFilePaths(failureInfo.detail)}</pre>}{renderFailureReference(failureInfo.reference, t)}</details>}</div>;
|
|
}
|
|
if (isEmptyAssistantMessage) {
|
|
return <div className="chat-message-content chat-message-content--empty" data-testid="chat-message-empty">{t("chat.noMessage", "No message")}</div>;
|
|
}
|
|
return renderStandardAssistantContent(message.content, forcePlain);
|
|
}, [failureInfo, forcePlain, isAssistantMessage, isEmptyAssistantMessage, message.content, t]);
|
|
const hasAssistantFooterRow = isAssistantMessage && !failureInfo && Boolean(message.thinkingOutput || copyAction || onScrollToTop);
|
|
const hasVisibleAssistantFooterContent = Boolean(message.thinkingOutput || copyAction || (onScrollToTop && isTopClipped));
|
|
const messageTime = <div className="chat-message-time">{formatRelativeTime(message.createdAt, t)}</div>;
|
|
return (
|
|
<div className={`chat-message chat-message--${message.role}${failureInfo ? " chat-message--failure" : ""}${isEditing ? " chat-message--editing" : ""}`} data-testid={`chat-message-${message.id}`} data-message-id={message.id}>
|
|
{showAssistantIdentity && <div className="chat-message-avatar">{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}<span>{agentName}</span>{showAssistantModelTag && activeModelTag && <span className="chat-model-tag">{activeModelTag}</span>}</div>}
|
|
{isEditing ? (
|
|
<StandardChatMessageEditComposer
|
|
value={editedText}
|
|
onChange={setEditedText}
|
|
onCancel={cancelEditing}
|
|
onSave={() => void saveEdit()}
|
|
disabled={isSavingEdit}
|
|
saveDisabled={isSavingEdit || !editedText.trim() || editedText.trim() === message.content.trim()}
|
|
messageId={message.id}
|
|
projectId={projectId}
|
|
/>
|
|
) : (
|
|
isAssistantMessage ? assistantBody : <div className="chat-message-content">{renderedUserContent}</div>
|
|
)}
|
|
{hasAssistantFooterRow && (
|
|
<div className={`chat-message-thinking-row${hasVisibleAssistantFooterContent ? "" : " chat-message-thinking-row--collapsed"}`}>
|
|
{message.thinkingOutput && <details className="chat-message-thinking"><summary>{t("chat.thinking", "Thinking")}</summary><pre className="chat-message-thinking-content">{linkifyFilePaths(message.thinkingOutput)}</pre></details>}
|
|
{(copyAction || onScrollToTop) && (
|
|
<div className="chat-message-actions">
|
|
{copyAction}
|
|
{onScrollToTop && <button type="button" className={`btn-icon chat-message-scroll-to-top-action${isTopClipped ? "" : " chat-message-scroll-to-top-action--hidden"}`} aria-label={t("chat.scrollMessageToTop", "Scroll message to top")} data-testid={`chat-message-scroll-to-top-${message.id}`} onClick={() => onScrollToTop(message.id)}><ArrowUpToLine size={14} /></button>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{renderStandardToolCalls(message.toolCalls, t, { isAwaitingAnswer: isAwaitingQuestionAnswer, submittedAnswer: submittedQuestionAnswer, onQuestionSubmit, toolCallRenderer })}
|
|
{renderedAttachments}
|
|
{isUserMessage ? (
|
|
<div className="chat-message-time-row">
|
|
{messageTime}
|
|
{showEditAction && !isEditing && <button type="button" className="btn-icon chat-message-edit-action chat-message-edit-action--inline" aria-label={t("chat.editMessage", "Edit message")} data-testid={`chat-message-edit-${message.id}`} onClick={startEditing}><Pencil size={14} /></button>}
|
|
</div>
|
|
) : messageTime}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export function StandardStreamingMessage({ streamingText, streamingThinking = "", streamingToolCalls = [], forcePlain, agentName, hideAssistantIdentity, showAssistantModelTag, activeModelTag, activeModelProvider, copyAction, onQuestionSubmit, toolCallRenderer }: StandardStreamingMessageProps) {
|
|
const { t } = useTranslation("app");
|
|
return (
|
|
<div className="chat-message chat-message--assistant chat-message--streaming" data-testid="chat-message-__streaming__">
|
|
{!hideAssistantIdentity && <div className="chat-message-avatar">{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}<span>{agentName}</span>{showAssistantModelTag && activeModelTag && <span className="chat-model-tag">{activeModelTag}</span>}</div>}
|
|
{streamingText ? renderStandardAssistantContent(streamingText, forcePlain) : <div className="chat-message-content chat-message-content--waiting">{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.workingStatus", "Working…")}</div>}
|
|
{copyAction}
|
|
{renderStandardToolCalls(streamingToolCalls, t, { isAwaitingAnswer: true, onQuestionSubmit, toolCallRenderer })}
|
|
{streamingThinking && <details className="chat-message-thinking"><summary>{t("chat.thinking", "Thinking")}</summary><pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre></details>}
|
|
<div className="chat-typing-indicator"><span /><span /><span /></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function useStandardChatActionGesture() {
|
|
const handledSendTouchRef = useRef(false);
|
|
const handledSendTouchTimerRef = useRef<number | null>(null);
|
|
const touchActionGestureRef = useRef(false);
|
|
const markHandledSendTouch = useCallback(() => {
|
|
handledSendTouchRef.current = true;
|
|
if (handledSendTouchTimerRef.current != null) clearTimeout(handledSendTouchTimerRef.current);
|
|
handledSendTouchTimerRef.current = window.setTimeout(() => {
|
|
handledSendTouchRef.current = false;
|
|
handledSendTouchTimerRef.current = null;
|
|
}, 700);
|
|
}, []);
|
|
const beginTouchActionGesture = useCallback(() => {
|
|
if (touchActionGestureRef.current) return false;
|
|
touchActionGestureRef.current = true;
|
|
window.setTimeout(() => { touchActionGestureRef.current = false; }, 0);
|
|
return true;
|
|
}, []);
|
|
const consumeHandledSendTouch = useCallback(() => {
|
|
if (!handledSendTouchRef.current) return false;
|
|
handledSendTouchRef.current = false;
|
|
if (handledSendTouchTimerRef.current != null) {
|
|
clearTimeout(handledSendTouchTimerRef.current);
|
|
handledSendTouchTimerRef.current = null;
|
|
}
|
|
return true;
|
|
}, []);
|
|
useEffect(() => () => {
|
|
if (handledSendTouchTimerRef.current != null) clearTimeout(handledSendTouchTimerRef.current);
|
|
}, []);
|
|
return { beginTouchActionGesture, markHandledSendTouch, consumeHandledSendTouch };
|
|
}
|
|
|
|
export function StandardChatActionButton({ isStreaming, canSend, onSend, onStop, sendLabel, stopLabel, classNameSend = "chat-input-send", classNameStop = "chat-input-stop", showSendText = false, showStopText, sendTestId = "chat-send-btn", stopTestId = "chat-stop-btn" }: StandardChatActionButtonProps) {
|
|
const { t } = useTranslation("app");
|
|
const { beginTouchActionGesture, markHandledSendTouch, consumeHandledSendTouch } = useStandardChatActionGesture();
|
|
// FNXC:StandardChatSurface 2026-07-07-00:00: resolve the Stop button's visible-text flag
|
|
// independently of Send's, defaulting to showSendText when the caller doesn't opt in (FN-7655).
|
|
const showStop = showStopText ?? showSendText;
|
|
if (isStreaming) {
|
|
return <button type="button" className={classNameStop} onPointerDown={(event) => { if (event.pointerType && event.pointerType !== "mouse") { event.preventDefault(); if (!beginTouchActionGesture()) return; markHandledSendTouch(); onStop?.(); } }} onTouchStart={(event) => { event.preventDefault(); if (!beginTouchActionGesture()) return; markHandledSendTouch(); onStop?.(); }} onMouseDown={(event) => event.preventDefault()} onClick={() => { if (consumeHandledSendTouch()) return; onStop?.(); }} aria-label={stopLabel ?? t("chat.stopGeneration", "Stop generation")} data-testid={stopTestId} style={{ touchAction: "manipulation" }}><span className="chat-input-stop-icon" aria-hidden="true" />{showStop && <span>{stopLabel ?? t("chat.stopGeneration", "Stop generation")}</span>}</button>;
|
|
}
|
|
return <button type="button" className={classNameSend} onPointerDown={(event) => { if (event.pointerType && event.pointerType !== "mouse") { event.preventDefault(); if (!beginTouchActionGesture()) return; markHandledSendTouch(); void onSend(); } }} onTouchStart={(event) => { event.preventDefault(); if (!beginTouchActionGesture()) return; markHandledSendTouch(); void onSend(); }} onMouseDown={(event) => event.preventDefault()} onClick={() => { if (consumeHandledSendTouch()) return; void onSend(); }} disabled={!canSend} data-testid={sendTestId} aria-label={sendLabel ?? t("chat.send", "Send")} style={{ touchAction: "manipulation" }}><Send size={16} />{showSendText && <span>{sendLabel ?? t("chat.send", "Send")}</span>}</button>;
|
|
}
|