Reworks chat message footer affordances: the scroll-to-top control now only becomes visible once a message's top is actually clipped above the visible thread viewport, and the edit pencil moves from a standalone action row into the timestamp footer beside user messages. - ChatView measures assistant message tops on scroll/message changes (rAF-scheduled) and tracks which message IDs are currently clipped above the `.chat-messages` container edge - StandardChatMessageItem accepts a new `isTopClipped` prop; the go-to-top button stays DOM-mounted (for tests/a11y) but is visually hidden via CSS until clipped - Merged the assistant thinking/copy/scroll-to-top actions into a single collapsible footer row instead of separate action rows - Moved the user-message edit pencil into an inline `chat-message-time-row` next to the relative timestamp instead of a standalone action row above it - Updated ChatView.css for the new inline layout, collapsed-row state, and hidden/visible scroll-to-top button states - Updated message-edit and scroll-to-top tests to cover the new inline placement and clipped-visibility behavior - Added changeset and docs/dashboard-guide.md note describing the new behavior Files changed: .changeset/fn-7918-chat-inline-icons.md | 7 ++ docs/dashboard-guide.md | 6 +- packages/dashboard/app/components/ChatView.css | 80 +++++++++++++++------- packages/dashboard/app/components/ChatView.tsx | 52 +++++++++++++- .../app/components/StandardChatSurface.tsx | 33 +++++++-- .../__tests__/ChatView.message-edit.test.tsx | 34 ++++++++- .../__tests__/ChatView.scroll-to-top.test.tsx | 75 +++++++++++++++++++- 7 files changed, 253 insertions(+), 34 deletions(-) Fusion-Task-Id: FN-7918 Fusion-Task-Lineage: 76206cd2-94a8-47be-b282-94943e184d01 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
574 lines
34 KiB
TypeScript
574 lines
34 KiB
TypeScript
import type { Agent } from "@fusion/core";
|
|
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import ReactMarkdown 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";
|
|
|
|
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;
|
|
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 truncateToolValue(value: string, maxLength: number): string {
|
|
return value.length <= maxLength ? value : `${value.slice(0, maxLength)}…`;
|
|
}
|
|
|
|
function formatToolArgsSummary(args?: Record<string, unknown>): string | null {
|
|
if (!args) return null;
|
|
const entries = Object.entries(args);
|
|
if (entries.length === 0) return null;
|
|
return entries.map(([key, value]) => {
|
|
const stringValue = typeof value === "string" ? value : (() => {
|
|
try { return JSON.stringify(value); } catch { return String(value); }
|
|
})();
|
|
return `${key}=${truncateToolValue(stringValue, 50)}`;
|
|
}).join(", ");
|
|
}
|
|
|
|
function formatToolResultSummary(result: unknown): string | null {
|
|
if (result === undefined) return null;
|
|
if (typeof result === "string") return truncateToolValue(result, 200);
|
|
try { return truncateToolValue(JSON.stringify(result), 200); } catch { return truncateToolValue(String(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 = formatToolArgsSummary(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");
|
|
return (
|
|
<details key={`${toolCall.toolName}-${index}`} className={`chat-tool-call${isRunning ? " chat-tool-call--running" : ""}${isError ? " chat-tool-call--error" : ""}`} open={isRunning}>
|
|
<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>
|
|
</summary>
|
|
<div className="chat-tool-call-content">
|
|
{argsSummary && <div className="chat-tool-call-row"><span className="chat-tool-call-label">{t("chat.toolCallArgsPrefix", "args")}</span><span className="chat-tool-call-value">{argsSummary}</span></div>}
|
|
{resultSummary && <div className={`chat-tool-call-row${isError ? " chat-tool-call-row--error" : ""}`}><span className="chat-tool-call-label">{t("chat.toolCallResultPrefix", "result")}</span><span className="chat-tool-call-value">{resultSummary}</span></div>}
|
|
</div>
|
|
</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}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export const standardChatMarkdownComponents: Components = {
|
|
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
|
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
|
|
pre: ({ children, ...props }) => <pre {...props} className="chat-markdown-pre">{children}</pre>,
|
|
code: ({ children, ...props }) => {
|
|
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>;
|
|
},
|
|
table: ({ children, ...props }) => <table {...props} className="chat-markdown-table">{children}</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}>{content}</ReactMarkdown></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,
|
|
}: 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 [editedText, setEditedText] = useState(message.content);
|
|
const editTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const startEditing = useCallback(() => {
|
|
setEditedText(message.content);
|
|
setIsEditing(true);
|
|
}, [message.content]);
|
|
|
|
const cancelEditing = useCallback(() => {
|
|
setIsEditing(false);
|
|
setEditedText(message.content);
|
|
}, [message.content]);
|
|
|
|
const saveEdit = useCallback(() => {
|
|
const trimmed = editedText.trim();
|
|
if (!trimmed || trimmed === message.content) return;
|
|
setIsEditing(false);
|
|
void onEditMessage?.(message.id, trimmed);
|
|
}, [editedText, message.content, message.id, onEditMessage]);
|
|
|
|
useEffect(() => {
|
|
if (isEditing) {
|
|
editTextareaRef.current?.focus();
|
|
editTextareaRef.current?.select();
|
|
}
|
|
}, [isEditing]);
|
|
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 parts: ReactNode[] = [];
|
|
let lastIndex = 0;
|
|
let match = mentionRegex.exec(content);
|
|
while (match) {
|
|
const [fullMatch, rawName = ""] = match;
|
|
const start = match.index;
|
|
if (start > lastIndex) parts.push(content.slice(lastIndex, start));
|
|
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;
|
|
match = mentionRegex.exec(content);
|
|
}
|
|
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 ? (
|
|
<div className="chat-message-edit-editor" data-testid={`chat-message-edit-editor-${message.id}`}>
|
|
<textarea
|
|
ref={editTextareaRef}
|
|
className="input chat-message-edit-textarea"
|
|
value={editedText}
|
|
onChange={(event) => setEditedText(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
cancelEditing();
|
|
} else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
event.preventDefault();
|
|
saveEdit();
|
|
}
|
|
}}
|
|
rows={3}
|
|
/>
|
|
<div className="chat-message-edit-actions">
|
|
<button type="button" className="btn btn-sm" onClick={cancelEditing}>{t("chat.editMessageCancel", "Cancel")}</button>
|
|
<button type="button" className="btn btn-sm btn-primary" disabled={!editedText.trim() || editedText.trim() === message.content} onClick={saveEdit}>{t("chat.editMessageSave", "Save")}</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
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>;
|
|
}
|