diff --git a/.changeset/fn-7355-standard-planner-chat.md b/.changeset/fn-7355-standard-planner-chat.md new file mode 100644 index 0000000000..d7a2bc8550 --- /dev/null +++ b/.changeset/fn-7355-standard-planner-chat.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Reuse the standard Chat surface for task-detail planner chat. +category: fix +dev: Extracts StandardChatSurface for shared message, thinking, tool-call, and mobile send rendering without importing the lazy ChatView chunk from task detail. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index dd65b80c48..bc485653da 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1127,7 +1127,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig The **Activity** tab is the first task-detail tab by default and presents a segmented control for **Live**, **Feed**, and **Raw Logs**. Live contains the live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Agent group headers and user message headers show a small muted relative timestamp (for example, “just now”, “1m ago”, or “2h ago”) based on the transcript timestamp, while agent group metadata still includes the entry count. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default and mirrors regular Chat's dense treatment; the summary stays single-line/ellipsis-friendly on desktop and mobile, counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When older task-agent history exists, scrolling to the top or selecting **Load previous messages** prepends earlier transcript entries without moving the message you were reading. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the Activity Live composer sends typed guidance through the same steering path used by comments, including active planning/triage, `in-progress`, and `in-review` sessions, plus live CLI-agent sessions reported by the session bridge; an `in-review` Activity Live message or Comments-tab task comment re-engages an executor unless an open PR blocks moving the task back, and other messages are still saved as queued guidance when no session is currently live. Feed and Raw Logs do not show the composer. On a `done` task, the same composer starts a refinement task using the typed text as feedback and shows a success toast with the new task ID, while the current task detail modal remains on the completed task. The task-detail Activity Live segment keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint. In the composer, plain **Enter** sends, **Shift+Enter** inserts a newline, and **Cmd/Ctrl+Enter** remains a supported send shortcut. -The top-level **Chat** tab opens the planner-model conversation for the same task instead of posting steering comments. It appears after Activity by default, or before Activity when **Settings → Appearance → Open task details with Chat first** is enabled. Each send includes server-built, bounded context for the task id, status/column/progress/current step, dependencies, recent activity/comment excerpts, prompt/plan content, and available source/review state; unavailable sections are labeled so the planner states uncertainty rather than inventing execution evidence. Opening the tab with no existing history does not create a database chat row; when no planner-chat history is found, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review. Selecting a starter creates/resumes the planner chat and sends that prompt as an ordinary chat message through the task-context-aware planner-chat composer/stream path. The starter prompts disappear while history is loading or after conversation history exists, so Activity Live, Feed, Raw Logs, and the steering composer remain separate. Planner Chat defaults to focused mode, keeps its composer visible at the bottom while only the transcript scrolls, and on narrow/mobile task-detail layouts collapses nonessential rows above the chat until the user selects the Chat collapse control. +The top-level **Chat** tab opens the planner-model conversation for the same task instead of posting steering comments. It appears after Activity by default, or before Activity when **Settings → Appearance → Open task details with Chat first** is enabled. Each send includes server-built, bounded context for the task id, status/column/progress/current step, dependencies, recent activity/comment excerpts, prompt/plan content, and available source/review state; unavailable sections are labeled so the planner states uncertainty rather than inventing execution evidence. Opening the tab with no existing history does not create a database chat row; when no planner-chat history is found, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review. Selecting a starter creates/resumes the planner chat and sends that prompt as an ordinary chat message through the task-context-aware planner-chat composer/stream path. The starter prompts disappear while history is loading or after conversation history exists, so Activity Live, Feed, Raw Logs, and the steering composer remain separate. Planner Chat uses the same standard chat bubble, markdown/plain assistant rendering, thinking details, tool-call/question cards, and mobile first-tap send/stop affordance as the main Chat view while keeping task-scoped planner sessions separate. Planner Chat defaults to focused mode, keeps its composer visible at the bottom while only the transcript scrolls, and on narrow/mobile task-detail layouts collapses nonessential rows above the chat until the user selects the Chat collapse control. The **Raw Logs** segment is designed for debugging long-running and tool-heavy sessions, while legacy links that requested the former top-level Logs tab land on Activity → Feed: diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index d7b82372de..0f10beaab4 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -1433,6 +1433,16 @@ Narrow chat hosts need full-width bubbles for prose, code, tool output, failures font-size: var(--space-md); } +.chat-tool-calls-header-icon { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: var(--space-sm); + block-size: var(--space-sm); + color: var(--text-muted); + line-height: 1; +} + .chat-tool-calls-group { border: var(--btn-border-width, 1px) solid color-mix(in srgb, var(--border) 85%, transparent); border-radius: var(--radius-sm); @@ -1840,6 +1850,14 @@ Narrow chat hosts need full-width bubbles for prose, code, tool output, failures transition: background var(--transition-fast), transform var(--transition-fast), color var(--transition-fast); } +.chat-input-stop-icon { + display: block; + inline-size: var(--space-sm); + block-size: var(--space-sm); + border-radius: var(--radius-xs); + background: currentColor; +} + .chat-input-stop:hover { background: color-mix(in srgb, var(--color-error) 25%, transparent); } diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index a01b00c59a..d2c1019f5f 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -1,12 +1,8 @@ // ChatView.css is imported eagerly from App.tsx to avoid a flash of // unstyled content when the lazy chunk loads. Do not re-import here. -import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import type { Components } from "react-markdown"; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { MessageSquare, - Send, Plus, Search, Trash2, @@ -14,33 +10,27 @@ import { Pencil, ChevronLeft, Bot, - Square, Eye, EyeOff, Paperclip, - File, - Wrench, ChevronDown, Copy, Check, - TriangleAlert, - ArrowUpToLine, Maximize2, Minimize2, X, Hash, } from "lucide-react"; -import { useChat, type ChatMessageInfo, type FailureInfo, type ToolCallInfo } from "../hooks/useChat"; +import { useChat, type ChatMessageInfo } from "../hooks/useChat"; import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/useChatRooms"; import { useChatUnread } from "../hooks/useChatUnread"; import { useViewportMode } from "./Header"; import { updateGlobalSettings, type DiscoveredSkill } from "../api"; import type { Agent } from "@fusion/core"; import { CustomModelDropdown } from "./CustomModelDropdown"; -import { ChatQuestionResponse } from "./ChatQuestionResponse"; -import { ProviderIcon } from "./ProviderIcon"; import { AgentMentionPopup } from "./AgentMentionPopup"; import { AgentAvatar } from "./AgentAvatar"; +import { ProviderIcon } from "./ProviderIcon"; import { FileMentionPopup } from "./FileMentionPopup"; import { CreateRoomModal } from "./CreateRoomModal"; import { CliChatSurface, type CliChatTier } from "./CliChatSurface"; @@ -52,13 +42,17 @@ import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileKeyboardViewportLock, isIOS } from "../hooks/useMobileScrollLock"; import { matchesAgentMentionFilter } from "./mentionMatching"; import { useNavigationHistoryContext } from "../hooks/useNavigationHistory"; -import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; -import { parseQuestionToolCall } from "../utils/parseQuestionToolCall"; import { estimateChatTokens, formatTokenCount } from "../utils/estimateChatTokens"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { ViewHeader } from "./ViewHeader"; +import { + StandardChatActionButton, + StandardChatMessageItem, + StandardStreamingMessage, + formatModelTag, +} from "./StandardChatSurface"; export interface ChatViewProps { projectId?: string; @@ -114,313 +108,6 @@ function formatRelativeTime(dateStr: string, t: TFunction<"app">): string { return date.toLocaleDateString(); } -/** - * Format a model provider and ID into a human-readable tag. - * Returns null if provider or modelId is missing/empty. - */ -function formatModelTag(provider?: string | null, modelId?: string | null): string | null { - if (!provider || !modelId) return null; - - // Handle known provider/model patterns - const normalizedModel = modelId.toLowerCase(); - - // Claude models: "claude-sonnet-4-5" -> "Claude Sonnet 4.5" - if (normalizedModel.includes("claude")) { - let 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, " ") - .trim(); - // Fix double spaces - formatted = formatted.replace(/\s+/g, " "); - return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted; - } - - // OpenAI models: "gpt-4o" -> "GPT-4o", "gpt-4-turbo" -> "GPT-4 Turbo" - if (normalizedModel.includes("gpt") || normalizedModel.includes("openai")) { - // Format GPT model names: handle special cases first, then capitalize - // Note: We don't replace hyphens globally because special cases preserve them - 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") // Capitalize remaining GPT prefix - .trim(); - return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted; - } - - // Gemini models: "gemini-2.5-pro" -> "Gemini 2.5 Pro" - 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; - } - - // Generic fallback: capitalize first letter, replace hyphens with spaces - 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 { - if (value.length <= maxLength) return value; - return `${value.slice(0, maxLength)}…`; -} - -function formatToolArgsSummary(args?: Record): 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 ( -
- {t("chat.failureReferenceLabel", "Reference")} - {referenceLabel} - {referenceHref ? ( - - {t("chat.openMailboxMessage", "Open mailbox message")} - - ) : ( -
- {t("chat.viewFailureDetails", "View failure details")} -
-
-
{t("chat.failureReferenceKind", "Kind")}
-
{reference.kind}
-
-
-
{t("chat.failureReferenceId", "ID")}
-
{reference.id}
-
- {reference.label && ( -
-
{t("chat.failureReferenceMetaLabel", "Label")}
-
{reference.label}
-
- )} -
-
- )} -
- ); -} - -function renderToolCalls( - toolCalls: ToolCallInfo[] | undefined, - t: (key: string, defaultValue: string, opts?: Record) => string, - options?: { - isAwaitingAnswer?: boolean; - submittedAnswer?: string; - onQuestionSubmit?: (answerText: string, structured: Record) => void; - }, -): ReactNode { - if (!toolCalls || toolCalls.length === 0) return null; - - const renderToolCallItem = (toolCall: ToolCallInfo, index: number) => { - const parsedQuestion = parseQuestionToolCall(toolCall); - if (parsedQuestion) { - const isAwaitingAnswer = options?.isAwaitingAnswer === true; - return ( - 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 ( -
- - -
- {argsSummary && ( -
- {t("chat.toolCallArgsPrefix", "args")} - {argsSummary} -
- )} - {resultSummary && ( -
- {t("chat.toolCallResultPrefix", "result")} - {resultSummary} -
- )} -
-
- ); - }; - - const className = "chat-tool-calls"; - if (toolCalls.length === 1) { - return ( -
-
-
- {renderToolCallItem(toolCalls[0], 0)} -
- ); - } - - const runningCount = toolCalls.filter((toolCall) => toolCall.status === "running").length; - const errorCount = toolCalls.filter((toolCall) => toolCall.status === "completed" && toolCall.isError).length; - const hasRunning = runningCount > 0; - const uniqueNames = Array.from(new Set(toolCalls.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 ( -
-
- - - {toolCalls.map((toolCall, index) => renderToolCallItem(toolCall, index))} -
-
- ); -} - -const chatMarkdownComponents: Components = { - p: ({ children, ...props }) => ( -

{linkifyReactChildren(children)}

- ), - li: ({ children, ...props }) => ( -
  • {linkifyReactChildren(children)}
  • - ), - pre: ({ children, ...props }) => ( -
    -      {children}
    -    
    - ), - 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 {children}; - } - return {linkedChildren}; - }, - table: ({ children, ...props }) => ( - - {children} -
    - ), -}; - /** * Constant agent ID for the built-in fn agent. * The chat system always uses createFnAgent with CHAT_SYSTEM_PROMPT regardless @@ -434,6 +121,10 @@ const CHAT_SIDEBAR_STORAGE_KEY = "fusion:chat-sidebar-width"; const CHAT_SCOPE_STORAGE_KEY = "fusion:chat-scope"; const CHAT_DRAFT_STORAGE_PREFIX = "fusion:chat-draft:"; +function findSubmittedQuestionAnswer(messages: ChatMessageInfo[], messageIndex: number): string | undefined { + return messages.slice(messageIndex + 1).find((message) => message.role === "user")?.content; +} + function getChatDraftKey(scope: "direct" | "rooms", id: string | null | undefined): string | null { if (!id) { return null; @@ -760,238 +451,6 @@ interface RoomContext { memberIds: ReadonlySet; } -interface ChatMessageItemProps { - message: ChatMessageInfo; - /** - * When true, render assistant message content as plain text instead of - * Markdown. The per-message eye toggle has been removed in favor of a - * single thread-level toggle in the chat header, so this is a global - * mirror of that header state. - */ - forcePlain: boolean; - agentName: string; - /** - * Hide the per-message agent identity (icon + name + model tag) on - * assistant bubbles. In model-only chats the agent identity *is* the - * active model and it's already shown in the thread header. - */ - hideAssistantIdentity: boolean; - showAssistantModelTag: boolean; - activeModelTag: string | null; - activeModelProvider: string | null; - activeSessionId: string | null; - mentionAgentsByName: Map; - roomContext: RoomContext | null; - copyAction?: ReactNode; - onScrollToTop?: (messageId: string) => void; - isAwaitingQuestionAnswer: boolean; - submittedQuestionAnswer?: string; - onQuestionSubmit: (answerText: string, structured: Record) => void; -} - -function findSubmittedQuestionAnswer(messages: ChatMessageInfo[], messageIndex: number): string | undefined { - return messages.slice(messageIndex + 1).find((message) => message.role === "user")?.content; -} - -// Renders a single chat message bubble. Memoized so the streaming bubble's -// per-frame state churn does not re-render every prior message (each one -// would re-run ReactMarkdown over its full content otherwise). -const ChatMessageItem = memo(function ChatMessageItem({ - message, - forcePlain, - agentName, - hideAssistantIdentity, - showAssistantModelTag, - activeModelTag, - activeModelProvider, - activeSessionId, - mentionAgentsByName, - roomContext, - copyAction, - onScrollToTop, - isAwaitingQuestionAnswer, - submittedQuestionAnswer, - onQuestionSubmit, -}: ChatMessageItemProps) { - const { t } = useTranslation("app"); - const isAssistantMessage = message.role === "assistant"; - const failureInfo = isAssistantMessage ? message.failureInfo : undefined; - const showAssistantIdentity = isAssistantMessage && (!hideAssistantIdentity || Boolean(failureInfo)); - - const renderedUserContent = useMemo(() => { - 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( - - @{mentionedAgent.name.replace(/\s+/g, "_")} - , - ); - } 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]); - - const renderedAttachments = useMemo(() => { - 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 ( -
    - {attachments.map((attachment) => { - const isImage = attachment.mimeType.startsWith("image/"); - const key = attachment.id || attachment.filename; - const href = `${attachmentUrlBase}${encodeURIComponent(attachment.filename)}`; - if (isImage) { - return ( - - {attachment.originalName} - - ); - } - return ( - - - {attachment.originalName} - - ); - })} -
    - ); - }, [message.attachments, message.roomId, activeSessionId]); - const assistantBody = useMemo(() => { - if (!isAssistantMessage) return null; - if (failureInfo) { - return ( -
    -
    -
    -
    {failureInfo.summary}
    - {(failureInfo.errorClass || failureInfo.code) && ( -
    - {failureInfo.errorClass && {failureInfo.errorClass}} - {failureInfo.code && {failureInfo.code}} -
    - )} - {(failureInfo.detail || failureInfo.reference) && ( -
    - - - {failureInfo.detail &&
    {linkifyFilePaths(failureInfo.detail)}
    } - {renderFailureReference(failureInfo.reference, t)} -
    - )} -
    - ); - } - if (forcePlain) { - return
    {message.content}
    ; - } - return ( -
    - - {message.content} - -
    - ); - }, [failureInfo, forcePlain, isAssistantMessage, message.content]); - - return ( -
    - {showAssistantIdentity && ( -
    - {activeModelProvider ? : } - {agentName} - {showAssistantModelTag && activeModelTag && {activeModelTag}} -
    - )} - {isAssistantMessage - ? assistantBody - :
    {renderedUserContent}
    } - {isAssistantMessage && !failureInfo && (copyAction || onScrollToTop) && ( -
    - {copyAction} - {onScrollToTop && ( - - )} -
    - )} - {renderToolCalls(message.toolCalls, t, { - isAwaitingAnswer: isAwaitingQuestionAnswer, - submittedAnswer: submittedQuestionAnswer, - onQuestionSubmit, - })} - {message.thinkingOutput && ( -
    - {t("chat.thinking", "Thinking")} -
    {linkifyFilePaths(message.thinkingOutput)}
    -
    - )} - {renderedAttachments} -
    {formatRelativeTime(message.createdAt, t)}
    -
    - ); -}); - export function ChatView({ projectId, addToast, floating = false, compactLayout = false, onPopOut, onMaximize, onMinimize, onClose }: ChatViewProps) { const { t } = useTranslation("app"); useEffect(() => { @@ -1161,9 +620,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout FNXC:ChatSendDedupe 2026-06-17-08:36: FN-6576 refines FN-6563 by matching QuickChatFAB's two-latch touch contract: pointerdown/touchstart claim a per-input-task gesture so one mobile tap sends exactly once, while the separate 700ms latch is consumed only by a trailing click. A suppressed iOS click must never leave the long latch blocking the next tap; a send-to-stop DOM swap must consume the trailing click without swallowing a genuine later stop tap. */ - const handledSendTouchRef = useRef(false); - const handledSendTouchTimerRef = useRef(null); - const touchActionGestureRef = useRef(false); const mode = useViewportMode(); const isMobile = mode === "mobile"; const isTablet = mode === "tablet"; @@ -2000,50 +1456,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout }); }, [activeDraftKey]); - // Mark that a mobile pointer/touch handler already performed the action so - // the trailing onClick (if it survives) bails. This long latch is intentionally - // never consulted by pointerdown/touchstart, because iOS may suppress the - // click that would consume it. - const markHandledSendTouch = useCallback(() => { - handledSendTouchRef.current = true; - if (handledSendTouchTimerRef.current != null) { - clearTimeout(handledSendTouchTimerRef.current); - } - handledSendTouchTimerRef.current = window.setTimeout(() => { - handledSendTouchRef.current = false; - handledSendTouchTimerRef.current = null; - }, 700); - }, []); - - // Claim one input task's touch gesture. Real mobile taps can dispatch both - // pointerdown and touchstart before React flushes state; only the first should - // run the action, and the claim must clear before the next tap. - const beginTouchActionGesture = useCallback(() => { - if (touchActionGestureRef.current) return false; - touchActionGestureRef.current = true; - window.setTimeout(() => { - touchActionGestureRef.current = false; - }, 0); - return true; - }, []); - - // Consume the latch (cancelling its timer) so a trailing onClick bails once. - 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); - } - }, []); - // Handle send message including pending attachment uploads. const handleSend = useCallback(() => { const trimmed = messageInput.trim(); @@ -2852,24 +2264,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout } }, [setCopyFeedback]); - const renderAssistantContent = useCallback( - (content: string, forcePlain = false) => { - const showPlainText = forcePlain; - if (showPlainText) { - return
    {content}
    ; - } - - return ( -
    - - {content} - -
    - ); - }, - [], - ); - const showProviderResponseCopy = activeSession?.agentId === FN_AGENT_ID; const renderCopyAction = useCallback((messageId: string, content: string, testId?: string) => ( @@ -2927,7 +2321,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout {isStreaming ? ( <> {messages.map((message, index) => ( - ))} -
    - {!hideAssistantIdentity && ( -
    - {activeModelProvider ? : } - {agentName} - {showAssistantModelTag && {activeModelTag}} -
    - )} - {streamingText ? ( - renderAssistantContent(streamingText, showAllAsPlain) - ) : ( -
    - {/* - FNXC:ChatLoadingCopy 2026-06-19-06:13: - The post-send waiting indicator reads "Working…" when no streamed text or thinking content has arrived yet, because the UI is waiting on work rather than a transport connection. - */} - {streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.workingStatus", "Working…")} -
    - )} - {showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")} - {renderToolCalls(streamingToolCalls, t, { - isAwaitingAnswer: true, - onQuestionSubmit: handleQuestionSubmit, - })} - {streamingThinking && ( -
    - {t("chat.thinking", "Thinking")} -
    {linkifyFilePaths(streamingThinking)}
    -
    - )} -
    - - - -
    -
    + ) : messagesLoading ? (
    {t("chat.loadingMessages", "Loading messages...")}
    @@ -2992,7 +2363,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout ) : ( <> {messages.map((message, index) => ( - - {isStreaming ? ( - - ) : ( - - )} + 0)} + onSend={handleSend} + onStop={stopStreaming} + /> ); @@ -3798,7 +3112,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout createdAt: message.createdAt, }; return ( - - + 0)} + onSend={handleSendDispatch} + /> )} diff --git a/packages/dashboard/app/components/StandardChatSurface.tsx b/packages/dashboard/app/components/StandardChatSurface.tsx new file mode 100644 index 0000000000..f443a7bf16 --- /dev/null +++ b/packages/dashboard/app/components/StandardChatSurface.tsx @@ -0,0 +1,417 @@ +import type { Agent } from "@fusion/core"; +import React, { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react"; +import ReactMarkdown from "react-markdown"; +import type { Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { ArrowUpToLine, Bot, File, 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; +} + +export interface StandardChatMessageItemProps { + message: ChatMessageInfo; + forcePlain: boolean; + agentName: string; + hideAssistantIdentity: boolean; + showAssistantModelTag: boolean; + activeModelTag: string | null; + activeModelProvider: string | null; + activeSessionId: string | null; + mentionAgentsByName?: Map; + roomContext?: StandardRoomContext | null; + copyAction?: ReactNode; + onScrollToTop?: (messageId: string) => void; + isAwaitingQuestionAnswer?: boolean; + submittedQuestionAnswer?: string; + onQuestionSubmit?: (answerText: string, structured: Record) => void; + toolCallRenderer?: (toolCall: ToolCallInfo, index: number) => ReactNode | undefined; +} + +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) => void; + toolCallRenderer?: (toolCall: ToolCallInfo, index: number) => ReactNode | undefined; +} + +export interface StandardChatActionButtonProps { + isStreaming: boolean; + canSend: boolean; + onSend: () => void | Promise; + onStop?: () => void; + sendLabel?: string; + stopLabel?: string; + classNameSend?: string; + classNameStop?: string; + showSendText?: 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 | 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 ( +
    + {t("chat.failureReferenceLabel", "Reference")} + {referenceLabel} + {referenceHref ? ( + {t("chat.openMailboxMessage", "Open mailbox message")} + ) : ( +
    + {t("chat.viewFailureDetails", "View failure details")} +
    +
    {t("chat.failureReferenceKind", "Kind")}
    {reference.kind}
    +
    {t("chat.failureReferenceId", "ID")}
    {reference.id}
    + {reference.label &&
    {t("chat.failureReferenceMetaLabel", "Label")}
    {reference.label}
    } +
    +
    + )} +
    + ); +} + +export function renderStandardToolCalls( + toolCalls: ToolCallInfo[] | undefined, + t: (key: string, defaultValue: string, opts?: Record) => string, + options?: { + isAwaitingAnswer?: boolean; + submittedAnswer?: string; + onQuestionSubmit?: (answerText: string, structured: Record) => void; + toolCallRenderer?: (toolCall: ToolCallInfo, index: number) => ReactNode | undefined; + }, +): ReactNode { + if (!toolCalls || toolCalls.length === 0) return null; + 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 ( + 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 ( +
    + + +
    + {argsSummary &&
    {t("chat.toolCallArgsPrefix", "args")}{argsSummary}
    } + {resultSummary &&
    {t("chat.toolCallResultPrefix", "result")}{resultSummary}
    } +
    +
    + ); + }; + if (toolCalls.length === 1) { + return
    {t("chat.toolCallsHeader", "Tool calls")}
    {renderToolCallItem(toolCalls[0], 0)}
    ; + } + const runningCount = toolCalls.filter((toolCall) => toolCall.status === "running").length; + const errorCount = toolCalls.filter((toolCall) => toolCall.status === "completed" && toolCall.isError).length; + const hasRunning = runningCount > 0; + const uniqueNames = Array.from(new Set(toolCalls.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 ( +
    +
    + + + {t("chat.toolCallsCount", "{{count}} tool calls", { count: toolCalls.length })} + {namesSummary} + {statusSummary && {statusSummary}} + + {toolCalls.map((toolCall, index) => renderToolCallItem(toolCall, index))} +
    +
    + ); +} + +export const standardChatMarkdownComponents: Components = { + p: ({ children, ...props }) =>

    {linkifyReactChildren(children)}

    , + li: ({ children, ...props }) =>
  • {linkifyReactChildren(children)}
  • , + pre: ({ children, ...props }) =>
    {children}
    , + 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 {children}; + return {linkedChildren}; + }, + table: ({ children, ...props }) => {children}
    , +}; + +function formatRelativeTime(dateStr: string, t: (key: string, defaultValue: string, opts?: Record) => 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
    {content}
    ; + return
    {content}
    ; +} + +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, +}: StandardChatMessageItemProps) { + const { t } = useTranslation("app"); + const isAssistantMessage = message.role === "assistant"; + const failureInfo = isAssistantMessage ? message.failureInfo : undefined; + const showAssistantIdentity = isAssistantMessage && (!hideAssistantIdentity || Boolean(failureInfo)); + const renderedUserContent = useMemo(() => { + 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(@{mentionedAgent.name.replace(/\s+/g, "_")}); + } 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(() => { + 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
    {attachments.map((attachment) => { + const isImage = attachment.mimeType.startsWith("image/"); + const key = attachment.id || attachment.filename; + const href = `${attachmentUrlBase}${encodeURIComponent(attachment.filename)}`; + if (isImage) return {attachment.originalName}; + return {attachment.originalName}; + })}
    ; + }, [message.attachments, message.roomId, activeSessionId]); + const assistantBody = useMemo(() => { + if (!isAssistantMessage) return null; + if (failureInfo) { + return
    {failureInfo.summary}
    {(failureInfo.errorClass || failureInfo.code) &&
    {failureInfo.errorClass && {failureInfo.errorClass}}{failureInfo.code && {failureInfo.code}}
    }{(failureInfo.detail || failureInfo.reference) &&
    {failureInfo.detail &&
    {linkifyFilePaths(failureInfo.detail)}
    }{renderFailureReference(failureInfo.reference, t)}
    }
    ; + } + return renderStandardAssistantContent(message.content, forcePlain); + }, [failureInfo, forcePlain, isAssistantMessage, message.content, t]); + return ( +
    + {showAssistantIdentity &&
    {activeModelProvider ? : }{agentName}{showAssistantModelTag && activeModelTag && {activeModelTag}}
    } + {isAssistantMessage ? assistantBody :
    {renderedUserContent}
    } + {isAssistantMessage && !failureInfo && (copyAction || onScrollToTop) &&
    {copyAction}{onScrollToTop && }
    } + {renderStandardToolCalls(message.toolCalls, t, { isAwaitingAnswer: isAwaitingQuestionAnswer, submittedAnswer: submittedQuestionAnswer, onQuestionSubmit, toolCallRenderer })} + {message.thinkingOutput &&
    {t("chat.thinking", "Thinking")}
    {linkifyFilePaths(message.thinkingOutput)}
    } + {renderedAttachments} +
    {formatRelativeTime(message.createdAt, t)}
    +
    + ); +}); + +export function StandardStreamingMessage({ streamingText, streamingThinking = "", streamingToolCalls = [], forcePlain, agentName, hideAssistantIdentity, showAssistantModelTag, activeModelTag, activeModelProvider, copyAction, onQuestionSubmit, toolCallRenderer }: StandardStreamingMessageProps) { + const { t } = useTranslation("app"); + return ( +
    + {!hideAssistantIdentity &&
    {activeModelProvider ? : }{agentName}{showAssistantModelTag && activeModelTag && {activeModelTag}}
    } + {streamingText ? renderStandardAssistantContent(streamingText, forcePlain) :
    {streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.workingStatus", "Working…")}
    } + {copyAction} + {renderStandardToolCalls(streamingToolCalls, t, { isAwaitingAnswer: true, onQuestionSubmit, toolCallRenderer })} + {streamingThinking &&
    {t("chat.thinking", "Thinking")}
    {linkifyFilePaths(streamingThinking)}
    } +
    +
    + ); +} + +export function useStandardChatActionGesture() { + const handledSendTouchRef = useRef(false); + const handledSendTouchTimerRef = useRef(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, sendTestId = "chat-send-btn", stopTestId = "chat-stop-btn" }: StandardChatActionButtonProps) { + const { t } = useTranslation("app"); + const { beginTouchActionGesture, markHandledSendTouch, consumeHandledSendTouch } = useStandardChatActionGesture(); + if (isStreaming) { + return ; + } + return ; +} diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.css b/packages/dashboard/app/components/TaskPlannerChatTab.css index 33074b8e89..76941e2c23 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.css +++ b/packages/dashboard/app/components/TaskPlannerChatTab.css @@ -147,39 +147,6 @@ An empty Planner Chat transcript should enter at the top of the chat surface ins line-height: var(--line-height-tight); } -.task-planner-chat-message { - max-width: min(42rem, 92%); - border: var(--btn-border-width) solid var(--border); - border-radius: var(--radius-lg); - padding: var(--space-sm) var(--space-md); - background: var(--surface-subtle); -} - -.task-planner-chat-message--user { - align-self: flex-end; - background: var(--surface-hover); -} - -.task-planner-chat-message--assistant, -.task-planner-chat-message--system { - align-self: flex-start; -} - -.task-planner-chat-message-role { - margin-bottom: var(--space-xs); - color: var(--text-muted); - font-size: var(--font-size-xs); - font-weight: 600; -} - -.task-planner-chat-message-content > :first-child { - margin-top: 0; -} - -.task-planner-chat-message-content > :last-child { - margin-bottom: 0; -} - .task-planner-chat-steering-confirmation { margin-top: var(--space-sm); border: var(--btn-border-width) solid var(--color-success); @@ -208,7 +175,7 @@ An empty Planner Chat transcript should enter at the top of the chat surface ins color: var(--text-muted); } -.task-planner-chat-message .chat-question-response { +.task-planner-chat .chat-question-response { max-width: 100%; overflow-wrap: anywhere; } @@ -308,11 +275,11 @@ Mobile Planner Chat should match regular task chat: keep the composer as a singl border: 0; } - .task-planner-chat-message { + .task-planner-chat .chat-message { max-width: 100%; } - .task-planner-chat-message .chat-question-response { + .task-planner-chat .chat-question-response { margin-inline: 0; } } diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index e5af817f33..ff07f1480e 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -1,17 +1,15 @@ import type { ChatMessage, ResolvedModelSelection, Task, TaskDetail } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import { Loader2, Maximize2, Minimize2, Send } from "lucide-react"; +import { Loader2, Maximize2, Minimize2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { ToastType } from "../hooks/useToast"; -import type { ToolCallInfo } from "../hooks/chatTypes"; +import type { ChatMessageInfo, ToolCallInfo } from "../hooks/chatTypes"; import { ensureTaskPlannerChatSession, fetchChatMessages, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse } from "../api"; import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall"; -import { markdownComponents } from "./AgentLogViewer"; import { ChatQuestionResponse } from "./ChatQuestionResponse"; import { ProviderIcon } from "./ProviderIcon"; +import { StandardChatActionButton, StandardChatMessageItem, StandardStreamingMessage, formatModelTag } from "./StandardChatSurface"; import "./TaskPlannerChatTab.css"; interface TaskPlannerChatTabProps { @@ -103,13 +101,13 @@ function makeOptimisticUserMessage(sessionId: string, content: string): ChatMess }; } -function makeStreamingAssistantMessage(sessionId: string, content: string, toolCalls: ToolCallInfo[] = []): ChatMessage { +function makeStreamingAssistantMessage(sessionId: string, content: string, toolCalls: ToolCallInfo[] = [], thinkingOutput = ""): ChatMessage { return { id: "streaming-assistant", sessionId, role: "assistant", content, - thinkingOutput: null, + thinkingOutput: thinkingOutput || null, metadata: { streaming: true, ...(toolCalls.length > 0 ? { toolCalls } : {}) }, createdAt: new Date().toISOString(), }; @@ -159,7 +157,7 @@ function extractPlannerSteeringTextFromResult(result: unknown): string | null { return text || null; } -function extractToolCalls(message: ChatMessage): ToolCallInfo[] { +function extractToolCalls(message: Pick): ToolCallInfo[] { const rawToolCalls = message.metadata?.toolCalls; if (!Array.isArray(rawToolCalls)) return []; return rawToolCalls @@ -196,6 +194,18 @@ function isQuestionAnswerFor(message: ChatMessage, parsed: ParsedQuestionToolCal return parsed.questions.some((question) => trimmed.includes(`> Q: ${question.question}`)); } +function toStandardChatMessage(message: ChatMessage): ChatMessageInfo { + return { + id: message.id, + sessionId: message.sessionId, + role: message.role, + content: message.content, + thinkingOutput: message.thinkingOutput, + toolCalls: extractToolCalls(message), + createdAt: message.createdAt, + }; +} + function buildPlannerQuestionRenderStates(messages: readonly ChatMessage[]): Map { const states = new Map(); const latestUnansweredByQuestion = new Map(); @@ -236,6 +246,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, const [sessionId, setSessionId] = useState(null); const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(""); + const [streamingThinking, setStreamingThinking] = useState(""); const [composerState, setComposerState] = useState("idle"); const composerStateRef = useRef("idle"); const [loading, setLoading] = useState(false); @@ -249,6 +260,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, const planningModelProvider = isUsableModel(planningModel) ? planningModel.provider : undefined; const planningModelId = isUsableModel(planningModel) ? planningModel.modelId : undefined; const planningModelLabel = planningModelProvider && planningModelId ? `${planningModelProvider}/${planningModelId}` : ""; + const activeModelTag = formatModelTag(planningModelProvider, planningModelId); const modelPayload = useMemo(() => { return planningModelProvider && planningModelId ? { modelProvider: planningModelProvider, modelId: planningModelId } @@ -297,6 +309,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, setMessages([]); setDraft(""); composerStateRef.current = "idle"; + setStreamingThinking(""); setComposerState("idle"); setLoading(false); setHistoryLoaded(false); @@ -362,6 +375,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, setSessionId(resolvedSessionId); setMessages((current) => [...current, makeOptimisticUserMessage(resolvedSessionId, content)]); let accumulated = ""; + let accumulatedThinking = ""; const streamingToolCalls: ToolCallInfo[] = []; streamRef.current?.close(); @@ -375,7 +389,16 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, accumulated += delta; setMessages((current) => { const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant"); - return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls)]; + return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls, accumulatedThinking)]; + }); + }, + onThinking: (delta) => { + if (!isCurrentStreamRequest()) return; + accumulatedThinking += delta; + setStreamingThinking(accumulatedThinking); + setMessages((current) => { + const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant"); + return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls, accumulatedThinking)]; }); }, onToolStart: ({ toolName, args }) => { @@ -383,7 +406,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, streamingToolCalls.push({ toolName, args, isError: false, status: "running" }); setMessages((current) => { const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant"); - return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls)]; + return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls, accumulatedThinking)]; }); }, onToolEnd: ({ toolName, isError, result }) => { @@ -404,13 +427,14 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, } setMessages((current) => { const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant"); - return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls)]; + return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls, accumulatedThinking)]; }); }, onDone: (data) => { if (!isCurrentStreamRequest()) return; composerStateRef.current = "idle"; setComposerState("idle"); + setStreamingThinking(""); streamRef.current = null; if (data.message) { setMessages((current) => { @@ -437,6 +461,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, setError(message || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond")); composerStateRef.current = "idle"; setComposerState("idle"); + setStreamingThinking(""); streamRef.current = null; }, }, @@ -451,11 +476,22 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, addToast(message, "error"); composerStateRef.current = "idle"; setComposerState("idle"); + setStreamingThinking(""); } }, [addToast, modelPayload, projectId, refreshTaskAfterSteering, sessionId, task.id, t]); const sendMessage = useCallback(() => sendMessageContent(draft), [draft, sendMessageContent]); + const stopPlannerStreaming = useCallback(() => { + streamRequestRef.current += 1; + streamRef.current?.close(); + streamRef.current = null; + composerStateRef.current = "idle"; + setComposerState("idle"); + setStreamingThinking(""); + setMessages((current) => current.filter((message) => message.id !== "streaming-assistant")); + }, []); + const handleKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.key !== "Enter" || event.shiftKey) return; event.preventDefault(); @@ -463,23 +499,6 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, }, [sendMessage]); const canSend = draft.trim().length > 0 && composerState !== "sending"; - - /* - FNXC:TaskDetailPlannerChat 2026-07-01-00:00: - Mobile soft keyboards can blur the focused planner-chat textarea before the Send button's click fires. Touch/pen pointer-down submits through the same planner Chat stream path with a synchronous composerStateRef duplicate guard; mouse down only preserves focus so desktop click and Enter behavior stay unchanged. - */ - const handleSendPointerDown = useCallback((event: React.PointerEvent) => { - if (event.pointerType === "mouse") return; - if (!canSend) return; - event.preventDefault(); - void sendMessage(); - }, [canSend, sendMessage]); - - const handleSendMouseDown = useCallback((event: React.MouseEvent) => { - if (!canSend) return; - event.preventDefault(); - }, [canSend]); - const showEmptyState = historyLoaded && !loading && !error && messages.length === 0; const questionRenderStates = useMemo(() => buildPlannerQuestionRenderStates(messages), [messages]); const starterPrompts = useMemo(() => { @@ -500,6 +519,47 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, }); }, [t]); + const renderPlannerToolCall = useCallback((message: ChatMessage, toolCall: ToolCallInfo, index: number) => { + const steeringResult = extractPlannerSteeringResult(toolCall); + if (steeringResult) { + return ( +
    + {t("taskDetail.plannerChat.steeringAdded", "Added as steering comment")} +

    {steeringResult.text}

    +
    + ); + } + const isRunningSteering = toolCall.toolName === TASK_PLANNER_STEERING_TOOL_NAME && toolCall.status === "running"; + if (isRunningSteering) { + return ( +
    + {t("taskDetail.plannerChat.steeringAdding", "Adding steering comment…")} +
    + ); + } + if (toolCall.toolName === TASK_PLANNER_STEERING_TOOL_NAME && toolCall.isError) { + return ( +
    + {t("taskDetail.plannerChat.steeringFailed", "Steering comment was not added")} +
    + ); + } + const questionState = questionRenderStates.get(`${message.id}:${index}`); + if (!questionState) return undefined; + if (questionState.hiddenDuplicate) return null; + return ( + void sendMessageContent(answerText)} + /> + ); + }, [composerState, questionRenderStates, sendMessageContent, t]); + /* FNXC:TaskDetailPlannerChat 2026-06-30-23:58: Planner Chat is a separate task-detail surface from Activity steering. It can answer from task context, offer starter prompts, ask structured follow-up questions, and convert explicit operator intent into steering through the server-side planner-chat tool instead of posting every chat message as steering by default. @@ -522,6 +582,9 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, FNXC:TaskDetailPlannerChat 2026-06-30-23:59: Planner-generated clarification questions in the task-detail Chat transcript must reuse ChatQuestionResponse instead of bespoke chat text. Submitted answers stay in the planner-chat lane as ordinary follow-up user messages, render the prior question read-only, and duplicate refetched pending tool calls hide older live forms so users never see competing submit affordances. + FNXC:TaskDetailPlannerChat 2026-07-01-09:34: + Planner Chat delegates transcript bubbles, thinking details, tool-call framing, and mobile send/stop gestures to StandardChatSurface. TaskPlannerChatTab keeps lookup-only session loading, task-context sends, starter prompts, and steering confirmations local so reuse does not collapse the lazy ChatView chunk or merge planner chat with Activity. + FNXC:TaskDetailPlannerChat 2026-06-30-23:58: The planner Chat tab owns an in-view expand/collapse button so mobile users can reclaim vertical room while keeping close/back/task identity controls reachable. This state is independent from Activity Live expansion because Activity still represents operational steering/history, not planner-model conversation. */ @@ -582,60 +645,57 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, )} ) : ( - messages.map((message) => { - const toolCalls = extractToolCalls(message); - return ( -
    -
    - {message.role === "user" ? t("taskDetail.plannerChat.user", "You") : t("taskDetail.plannerChat.assistant", "Planner")} -
    - {message.content && ( -
    - {message.content} -
    - )} - {toolCalls.map((toolCall, index) => { - const steeringResult = extractPlannerSteeringResult(toolCall); - if (steeringResult) { - return ( -
    - {t("taskDetail.plannerChat.steeringAdded", "Added as steering comment")} -

    {steeringResult.text}

    -
    - ); - } - const isRunningSteering = toolCall.toolName === TASK_PLANNER_STEERING_TOOL_NAME && toolCall.status === "running"; - if (isRunningSteering) { - return ( -
    - {t("taskDetail.plannerChat.steeringAdding", "Adding steering comment…")} -
    - ); - } - if (toolCall.toolName === TASK_PLANNER_STEERING_TOOL_NAME && toolCall.isError) { - return ( -
    - {t("taskDetail.plannerChat.steeringFailed", "Steering comment was not added")} -
    - ); - } - const questionState = questionRenderStates.get(`${message.id}:${index}`); - if (!questionState || questionState.hiddenDuplicate) return null; - return ( - void sendMessageContent(answerText)} - /> - ); - })} -
    - ); - }) + <> + {messages.map((message) => { + if (message.id === "streaming-assistant") { + const streamingToolCalls = extractToolCalls(message); + return ( + renderPlannerToolCall(message, toolCall, index)} + /> + ); + } + return ( + void sendMessageContent(answerText)} + toolCallRenderer={(toolCall, index) => renderPlannerToolCall(message, toolCall, index)} + /> + ); + })} + {composerState === "sending" && !messages.some((message) => message.id === "streaming-assistant") && ( + + )} + )} @@ -650,17 +710,17 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, disabled={composerState === "sending"} rows={1} /> - + ); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx index d0e58d8323..8da49489c2 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx @@ -148,10 +148,10 @@ describe("TaskDetailModal", () => { expect(mobileSendBlock).toContain("inline-size: calc(var(--space-2xl) + var(--space-lg));"); expect(mobileSendBlock).toContain("min-block-size: calc(var(--space-2xl) + var(--space-lg));"); expectBaseRule(css, ".task-planner-chat-starters", "grid-template-columns: repeat(2, minmax(0, 1fr));"); - expectBaseRule(css, ".task-planner-chat-message .chat-question-response", "overflow-wrap: anywhere;"); + expectBaseRule(css, ".task-planner-chat .chat-question-response", "overflow-wrap: anywhere;"); expect(mobileBlock).toContain(".task-planner-chat-starters"); expect(mobileBlock).toContain("grid-template-columns: 1fr;"); - expect(mobileBlock).toContain(".task-planner-chat-message .chat-question-response"); + expect(mobileBlock).toContain(".task-planner-chat .chat-question-response"); expect(mobileBlock).toContain("margin-inline: 0;"); const detailCss = readDashboardStylesSource(); diff --git a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx index 4fcd0a96a2..295dc113c1 100644 --- a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx @@ -490,6 +490,58 @@ describe("TaskPlannerChatTab", () => { ); }); + it("renders live and stored thinking output through the standard chat surface", async () => { + const user = userEvent.setup(); + mockFetchChatMessages.mockResolvedValueOnce({ + messages: [{ + id: "assistant-thinking", + sessionId: "chat-planner", + role: "assistant", + content: "Stored answer", + thinkingOutput: "stored plan notes", + metadata: null, + createdAt: "2026-06-30T00:02:00.000Z", + }], + }); + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + setTimeout(() => { + handlers.onThinking?.("live plan"); + }, 0); + return { close: vi.fn(), isConnected: () => true }; + }); + renderPlannerChat(); + + expect(await screen.findByText("Stored answer")).toBeInTheDocument(); + expect(screen.getByText("stored plan notes")).toBeInTheDocument(); + + await user.type(screen.getByLabelText("Message planner chat"), "Think about this"); + fireEvent.pointerDown(screen.getByTestId("chat-send-btn"), { pointerType: "touch" }); + + expect(await screen.findByText("Thinking…")).toBeInTheDocument(); + expect(screen.getByText("live plan")).toBeInTheDocument(); + }); + + it("dedupes mobile first-tap sends through the standard composer action", async () => { + const user = userEvent.setup(); + renderPlannerChat(); + await screen.findByTestId("task-planner-chat-empty"); + + await user.type(screen.getByLabelText("Message planner chat"), "Mobile first tap"); + const sendButton = screen.getByTestId("chat-send-btn"); + fireEvent.pointerDown(sendButton, { pointerType: "touch" }); + fireEvent.click(sendButton); + + expect(mockStreamChatResponse).toHaveBeenCalledTimes(1); + expect(mockStreamChatResponse).toHaveBeenCalledWith( + "chat-planner", + "Mobile first tap", + expect.any(Object), + undefined, + undefined, + { taskId: "FN-7310" }, + ); + }); + it("shows a recoverable error when the post-stream refresh fails", async () => { const user = userEvent.setup(); const addToast = vi.fn();