FN-7355: reuse standard chat surface for planner
Task planner chat now renders through the shared Chat surface while preserving task-scoped behavior. - Extract shared standard chat message, streaming, tool-call, and action-button rendering for reuse outside ChatView. - Update TaskPlannerChatTab to use the shared surface for messages, thinking output, mobile send dedupe, and stop generation. - Cover planner chat standard-surface rendering and mobile first-tap behavior in tests, docs, and a patch changeset. Files changed: .changeset/fn-7355-standard-planner-chat.md | 7 + docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/ChatView.css | 18 + packages/dashboard/app/components/ChatView.tsx | 795 ++------------------- .../app/components/StandardChatSurface.tsx | 417 +++++++++++ .../app/components/TaskPlannerChatTab.css | 39 +- .../app/components/TaskPlannerChatTab.tsx | 246 ++++--- ...etailModal.responsive-and-dependencies.test.tsx | 4 +- .../__tests__/TaskPlannerChatTab.test.tsx | 52 ++ 9 files changed, 693 insertions(+), 887 deletions(-) Fusion-Task-Id: FN-7355 Fusion-Task-Lineage: 608ee0d7-3a1b-4f5f-a295-d3a4e62d759e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7355-standard-planner-chat.md
Normal file
7
.changeset/fn-7355-standard-planner-chat.md
Normal file
@@ -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.
|
||||||
@@ -1127,7 +1127,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig
|
|||||||
<!-- FNXC:TaskDetailActivityFirst 2026-06-30-23:59: Task Detail is Activity-first by default for active tasks: Activity renders before planner Chat and omitted non-done opens land on Activity → Live. Settings → Appearance → Open task details with Chat first restores the previous Chat-first order/default without changing explicit Activity, Chat, or Logs links. -->
|
<!-- FNXC:TaskDetailActivityFirst 2026-06-30-23:59: Task Detail is Activity-first by default for active tasks: Activity renders before planner Chat and omitted non-done opens land on Activity → Live. Settings → Appearance → Open task details with Chat first restores the previous Chat-first order/default without changing explicit Activity, Chat, or Logs links. -->
|
||||||
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 **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:
|
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:
|
||||||
|
|
||||||
|
|||||||
@@ -1433,6 +1433,16 @@ Narrow chat hosts need full-width bubbles for prose, code, tool output, failures
|
|||||||
font-size: var(--space-md);
|
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 {
|
.chat-tool-calls-group {
|
||||||
border: var(--btn-border-width, 1px) solid color-mix(in srgb, var(--border) 85%, transparent);
|
border: var(--btn-border-width, 1px) solid color-mix(in srgb, var(--border) 85%, transparent);
|
||||||
border-radius: var(--radius-sm);
|
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);
|
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 {
|
.chat-input-stop:hover {
|
||||||
background: color-mix(in srgb, var(--color-error) 25%, transparent);
|
background: color-mix(in srgb, var(--color-error) 25%, transparent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
// ChatView.css is imported eagerly from App.tsx to avoid a flash of
|
// 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.
|
// 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 React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import type { Components } from "react-markdown";
|
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Send,
|
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -14,33 +10,27 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Bot,
|
Bot,
|
||||||
Square,
|
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
File,
|
|
||||||
Wrench,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
TriangleAlert,
|
|
||||||
ArrowUpToLine,
|
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
X,
|
X,
|
||||||
Hash,
|
Hash,
|
||||||
} from "lucide-react";
|
} 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 { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/useChatRooms";
|
||||||
import { useChatUnread } from "../hooks/useChatUnread";
|
import { useChatUnread } from "../hooks/useChatUnread";
|
||||||
import { useViewportMode } from "./Header";
|
import { useViewportMode } from "./Header";
|
||||||
import { updateGlobalSettings, type DiscoveredSkill } from "../api";
|
import { updateGlobalSettings, type DiscoveredSkill } from "../api";
|
||||||
import type { Agent } from "@fusion/core";
|
import type { Agent } from "@fusion/core";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { ChatQuestionResponse } from "./ChatQuestionResponse";
|
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
|
||||||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||||||
import { AgentAvatar } from "./AgentAvatar";
|
import { AgentAvatar } from "./AgentAvatar";
|
||||||
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
import { FileMentionPopup } from "./FileMentionPopup";
|
import { FileMentionPopup } from "./FileMentionPopup";
|
||||||
import { CreateRoomModal } from "./CreateRoomModal";
|
import { CreateRoomModal } from "./CreateRoomModal";
|
||||||
import { CliChatSurface, type CliChatTier } from "./CliChatSurface";
|
import { CliChatSurface, type CliChatTier } from "./CliChatSurface";
|
||||||
@@ -52,13 +42,17 @@ import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
|||||||
import { useMobileKeyboardViewportLock, isIOS } from "../hooks/useMobileScrollLock";
|
import { useMobileKeyboardViewportLock, isIOS } from "../hooks/useMobileScrollLock";
|
||||||
import { matchesAgentMentionFilter } from "./mentionMatching";
|
import { matchesAgentMentionFilter } from "./mentionMatching";
|
||||||
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
|
||||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||||
import { parseQuestionToolCall } from "../utils/parseQuestionToolCall";
|
|
||||||
import { estimateChatTokens, formatTokenCount } from "../utils/estimateChatTokens";
|
import { estimateChatTokens, formatTokenCount } from "../utils/estimateChatTokens";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { ViewHeader } from "./ViewHeader";
|
import { ViewHeader } from "./ViewHeader";
|
||||||
|
import {
|
||||||
|
StandardChatActionButton,
|
||||||
|
StandardChatMessageItem,
|
||||||
|
StandardStreamingMessage,
|
||||||
|
formatModelTag,
|
||||||
|
} from "./StandardChatSurface";
|
||||||
|
|
||||||
export interface ChatViewProps {
|
export interface ChatViewProps {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -114,313 +108,6 @@ function formatRelativeTime(dateStr: string, t: TFunction<"app">): string {
|
|||||||
return date.toLocaleDateString();
|
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, 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderToolCalls(
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
): 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 (
|
|
||||||
<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 className = "chat-tool-calls";
|
|
||||||
if (toolCalls.length === 1) {
|
|
||||||
return (
|
|
||||||
<div className={className} data-testid="chat-tool-calls">
|
|
||||||
<div className="chat-tool-calls-header">
|
|
||||||
<Wrench size={12} aria-hidden="true" />
|
|
||||||
<span>{t("chat.toolCallsHeader", "Tool calls")}</span>
|
|
||||||
</div>
|
|
||||||
{renderToolCallItem(toolCalls[0], 0)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className={className} 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">
|
|
||||||
<Wrench size={12} aria-hidden="true" />
|
|
||||||
<span className="chat-tool-calls-count">{t("chat.toolCallsCount", "{{count}} tool calls", { count: toolCalls.length })}</span>
|
|
||||||
<span className="chat-tool-calls-names" title={namesSummary}>{namesSummary}</span>
|
|
||||||
{statusSummary && <span className="chat-tool-calls-group-status">{statusSummary}</span>}
|
|
||||||
</summary>
|
|
||||||
{toolCalls.map((toolCall, index) => renderToolCallItem(toolCall, index))}
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const chatMarkdownComponents: 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>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constant agent ID for the built-in fn agent.
|
* Constant agent ID for the built-in fn agent.
|
||||||
* The chat system always uses createFnAgent with CHAT_SYSTEM_PROMPT regardless
|
* 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_SCOPE_STORAGE_KEY = "fusion:chat-scope";
|
||||||
const CHAT_DRAFT_STORAGE_PREFIX = "fusion:chat-draft:";
|
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 {
|
function getChatDraftKey(scope: "direct" | "rooms", id: string | null | undefined): string | null {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return null;
|
return null;
|
||||||
@@ -760,238 +451,6 @@ interface RoomContext {
|
|||||||
memberIds: ReadonlySet<string>;
|
memberIds: ReadonlySet<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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<string, Agent>;
|
|
||||||
roomContext: RoomContext | null;
|
|
||||||
copyAction?: ReactNode;
|
|
||||||
onScrollToTop?: (messageId: string) => void;
|
|
||||||
isAwaitingQuestionAnswer: boolean;
|
|
||||||
submittedQuestionAnswer?: string;
|
|
||||||
onQuestionSubmit: (answerText: string, structured: Record<string, unknown>) => 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<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]);
|
|
||||||
|
|
||||||
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 (forcePlain) {
|
|
||||||
return <div className="chat-message-content chat-message-content--plain">{message.content}</div>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="chat-message-content chat-message-content--markdown">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={chatMarkdownComponents}>
|
|
||||||
{message.content}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [failureInfo, forcePlain, isAssistantMessage, message.content]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`chat-message chat-message--${message.role}${failureInfo ? " chat-message--failure" : ""}`}
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
{isAssistantMessage
|
|
||||||
? assistantBody
|
|
||||||
: <div className="chat-message-content">{renderedUserContent}</div>}
|
|
||||||
{isAssistantMessage && !failureInfo && (copyAction || onScrollToTop) && (
|
|
||||||
<div className="chat-message-actions">
|
|
||||||
{copyAction}
|
|
||||||
{onScrollToTop && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-icon chat-message-scroll-to-top-action"
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
{renderToolCalls(message.toolCalls, t, {
|
|
||||||
isAwaitingAnswer: isAwaitingQuestionAnswer,
|
|
||||||
submittedAnswer: submittedQuestionAnswer,
|
|
||||||
onQuestionSubmit,
|
|
||||||
})}
|
|
||||||
{message.thinkingOutput && (
|
|
||||||
<details className="chat-message-thinking">
|
|
||||||
<summary>{t("chat.thinking", "Thinking")}</summary>
|
|
||||||
<pre className="chat-message-thinking-content">{linkifyFilePaths(message.thinkingOutput)}</pre>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
{renderedAttachments}
|
|
||||||
<div className="chat-message-time">{formatRelativeTime(message.createdAt, t)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export function ChatView({ projectId, addToast, floating = false, compactLayout = false, onPopOut, onMaximize, onMinimize, onClose }: ChatViewProps) {
|
export function ChatView({ projectId, addToast, floating = false, compactLayout = false, onPopOut, onMaximize, onMinimize, onClose }: ChatViewProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1161,9 +620,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
FNXC:ChatSendDedupe 2026-06-17-08:36:
|
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.
|
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<number | null>(null);
|
|
||||||
const touchActionGestureRef = useRef(false);
|
|
||||||
const mode = useViewportMode();
|
const mode = useViewportMode();
|
||||||
const isMobile = mode === "mobile";
|
const isMobile = mode === "mobile";
|
||||||
const isTablet = mode === "tablet";
|
const isTablet = mode === "tablet";
|
||||||
@@ -2000,50 +1456,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
});
|
});
|
||||||
}, [activeDraftKey]);
|
}, [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.
|
// Handle send message including pending attachment uploads.
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
const trimmed = messageInput.trim();
|
const trimmed = messageInput.trim();
|
||||||
@@ -2852,24 +2264,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
}
|
}
|
||||||
}, [setCopyFeedback]);
|
}, [setCopyFeedback]);
|
||||||
|
|
||||||
const renderAssistantContent = useCallback(
|
|
||||||
(content: string, forcePlain = false) => {
|
|
||||||
const showPlainText = forcePlain;
|
|
||||||
if (showPlainText) {
|
|
||||||
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={chatMarkdownComponents}>
|
|
||||||
{content}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const showProviderResponseCopy = activeSession?.agentId === FN_AGENT_ID;
|
const showProviderResponseCopy = activeSession?.agentId === FN_AGENT_ID;
|
||||||
|
|
||||||
const renderCopyAction = useCallback((messageId: string, content: string, testId?: string) => (
|
const renderCopyAction = useCallback((messageId: string, content: string, testId?: string) => (
|
||||||
@@ -2927,7 +2321,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
{isStreaming ? (
|
{isStreaming ? (
|
||||||
<>
|
<>
|
||||||
{messages.map((message, index) => (
|
{messages.map((message, index) => (
|
||||||
<ChatMessageItem
|
<StandardChatMessageItem
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
forcePlain={showAllAsPlain}
|
forcePlain={showAllAsPlain}
|
||||||
@@ -2946,42 +2340,19 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
onQuestionSubmit={handleQuestionSubmit}
|
onQuestionSubmit={handleQuestionSubmit}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="chat-message chat-message--assistant chat-message--streaming">
|
<StandardStreamingMessage
|
||||||
{!hideAssistantIdentity && (
|
streamingText={streamingText}
|
||||||
<div className="chat-message-avatar">
|
streamingThinking={streamingThinking}
|
||||||
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}
|
streamingToolCalls={streamingToolCalls}
|
||||||
<span>{agentName}</span>
|
forcePlain={showAllAsPlain}
|
||||||
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
agentName={agentName}
|
||||||
</div>
|
hideAssistantIdentity={hideAssistantIdentity}
|
||||||
)}
|
showAssistantModelTag={showAssistantModelTag}
|
||||||
{streamingText ? (
|
activeModelTag={activeModelTag}
|
||||||
renderAssistantContent(streamingText, showAllAsPlain)
|
activeModelProvider={activeModelProvider}
|
||||||
) : (
|
copyAction={showProviderResponseCopy && streamingText ? renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming") : undefined}
|
||||||
<div className="chat-message-content chat-message-content--waiting">
|
onQuestionSubmit={handleQuestionSubmit}
|
||||||
{/*
|
/>
|
||||||
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…")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")}
|
|
||||||
{renderToolCalls(streamingToolCalls, t, {
|
|
||||||
isAwaitingAnswer: true,
|
|
||||||
onQuestionSubmit: handleQuestionSubmit,
|
|
||||||
})}
|
|
||||||
{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>
|
|
||||||
</>
|
</>
|
||||||
) : messagesLoading ? (
|
) : messagesLoading ? (
|
||||||
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
|
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
|
||||||
@@ -2992,7 +2363,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{messages.map((message, index) => (
|
{messages.map((message, index) => (
|
||||||
<ChatMessageItem
|
<StandardChatMessageItem
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
forcePlain={showAllAsPlain}
|
forcePlain={showAllAsPlain}
|
||||||
@@ -3182,69 +2553,12 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
loading={fileMention.loading}
|
loading={fileMention.loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isStreaming ? (
|
<StandardChatActionButton
|
||||||
<button
|
isStreaming={isStreaming}
|
||||||
className="chat-input-stop"
|
canSend={Boolean(messageInput.trim() || pendingAttachments.length > 0)}
|
||||||
onPointerDown={(event) => {
|
onSend={handleSend}
|
||||||
if (event.pointerType && event.pointerType !== "mouse") {
|
onStop={stopStreaming}
|
||||||
event.preventDefault();
|
/>
|
||||||
if (!beginTouchActionGesture()) return;
|
|
||||||
markHandledSendTouch();
|
|
||||||
stopStreaming();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onTouchStart={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!beginTouchActionGesture()) return;
|
|
||||||
markHandledSendTouch();
|
|
||||||
stopStreaming();
|
|
||||||
}}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (consumeHandledSendTouch()) return;
|
|
||||||
stopStreaming();
|
|
||||||
}}
|
|
||||||
aria-label={t("chat.stopGeneration", "Stop generation")}
|
|
||||||
data-testid="chat-stop-btn"
|
|
||||||
>
|
|
||||||
<Square size={14} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="chat-input-send"
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
if (event.pointerType && event.pointerType !== "mouse") {
|
|
||||||
// iOS suppresses the trailing click after this preventDefault,
|
|
||||||
// so fire the send here (deduped) rather than relying on onClick.
|
|
||||||
event.preventDefault();
|
|
||||||
if (!beginTouchActionGesture()) return;
|
|
||||||
markHandledSendTouch();
|
|
||||||
void handleSend();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onTouchStart={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!beginTouchActionGesture()) return;
|
|
||||||
markHandledSendTouch();
|
|
||||||
void handleSend();
|
|
||||||
}}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (consumeHandledSendTouch()) return;
|
|
||||||
void handleSend();
|
|
||||||
}}
|
|
||||||
disabled={!messageInput.trim() && pendingAttachments.length === 0}
|
|
||||||
data-testid="chat-send-btn"
|
|
||||||
style={{ touchAction: "manipulation" }}
|
|
||||||
>
|
|
||||||
<Send size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -3798,7 +3112,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
createdAt: message.createdAt,
|
createdAt: message.createdAt,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<ChatMessageItem
|
<StandardChatMessageItem
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={roomMessage}
|
message={roomMessage}
|
||||||
forcePlain={showAllAsPlain}
|
forcePlain={showAllAsPlain}
|
||||||
@@ -3932,40 +3246,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
roomName={roomContext?.roomName}
|
roomName={roomContext?.roomName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<StandardChatActionButton
|
||||||
type="button"
|
isStreaming={false}
|
||||||
className="chat-input-send"
|
canSend={Boolean(messageInput.trim() || pendingAttachments.length > 0)}
|
||||||
/*
|
onSend={handleSendDispatch}
|
||||||
FNXC:ChatRoomSend 2026-06-17-08:36:
|
/>
|
||||||
FN-6576 requires the room composer to share the direct-chat two-latch dedupe contract: pointerdown/touchstart use the short per-gesture claim, while the 700ms latch is reserved for a trailing click. This preserves room routing through handleSendDispatch() and ensures a second iOS tap within the suppressed-click window still dispatches exactly one room send.
|
|
||||||
*/
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
if (event.pointerType && event.pointerType !== "mouse") {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!beginTouchActionGesture()) return;
|
|
||||||
markHandledSendTouch();
|
|
||||||
void handleSendDispatch();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onTouchStart={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!beginTouchActionGesture()) return;
|
|
||||||
markHandledSendTouch();
|
|
||||||
void handleSendDispatch();
|
|
||||||
}}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (consumeHandledSendTouch()) return;
|
|
||||||
void handleSendDispatch();
|
|
||||||
}}
|
|
||||||
disabled={!messageInput.trim() && pendingAttachments.length === 0}
|
|
||||||
data-testid="chat-send-btn"
|
|
||||||
style={{ touchAction: "manipulation" }}
|
|
||||||
>
|
|
||||||
<Send size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
417
packages/dashboard/app/components/StandardChatSurface.tsx
Normal file
417
packages/dashboard/app/components/StandardChatSurface.tsx
Normal file
@@ -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<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;
|
||||||
|
isAwaitingQuestionAnswer?: boolean;
|
||||||
|
submittedQuestionAnswer?: string;
|
||||||
|
onQuestionSubmit?: (answerText: string, structured: Record<string, unknown>) => 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<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;
|
||||||
|
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;
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (toolCalls.length === 1) {
|
||||||
|
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(toolCalls[0], 0)}</div>;
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<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: toolCalls.length })}</span>
|
||||||
|
<span className="chat-tool-calls-names" title={namesSummary}>{namesSummary}</span>
|
||||||
|
{statusSummary && <span className="chat-tool-calls-group-status">{statusSummary}</span>}
|
||||||
|
</summary>
|
||||||
|
{toolCalls.map((toolCall, index) => renderToolCallItem(toolCall, index))}
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}: 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<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>;
|
||||||
|
}
|
||||||
|
return renderStandardAssistantContent(message.content, forcePlain);
|
||||||
|
}, [failureInfo, forcePlain, isAssistantMessage, message.content, t]);
|
||||||
|
return (
|
||||||
|
<div className={`chat-message chat-message--${message.role}${failureInfo ? " chat-message--failure" : ""}`} 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>}
|
||||||
|
{isAssistantMessage ? assistantBody : <div className="chat-message-content">{renderedUserContent}</div>}
|
||||||
|
{isAssistantMessage && !failureInfo && (copyAction || onScrollToTop) && <div className="chat-message-actions">{copyAction}{onScrollToTop && <button type="button" className="btn-icon chat-message-scroll-to-top-action" 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>}
|
||||||
|
{renderStandardToolCalls(message.toolCalls, t, { isAwaitingAnswer: isAwaitingQuestionAnswer, submittedAnswer: submittedQuestionAnswer, onQuestionSubmit, toolCallRenderer })}
|
||||||
|
{message.thinkingOutput && <details className="chat-message-thinking"><summary>{t("chat.thinking", "Thinking")}</summary><pre className="chat-message-thinking-content">{linkifyFilePaths(message.thinkingOutput)}</pre></details>}
|
||||||
|
{renderedAttachments}
|
||||||
|
<div className="chat-message-time">{formatRelativeTime(message.createdAt, t)}</div>
|
||||||
|
</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, sendTestId = "chat-send-btn", stopTestId = "chat-stop-btn" }: StandardChatActionButtonProps) {
|
||||||
|
const { t } = useTranslation("app");
|
||||||
|
const { beginTouchActionGesture, markHandledSendTouch, consumeHandledSendTouch } = useStandardChatActionGesture();
|
||||||
|
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" />{showSendText && <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>;
|
||||||
|
}
|
||||||
@@ -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);
|
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 {
|
.task-planner-chat-steering-confirmation {
|
||||||
margin-top: var(--space-sm);
|
margin-top: var(--space-sm);
|
||||||
border: var(--btn-border-width) solid var(--color-success);
|
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);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-planner-chat-message .chat-question-response {
|
.task-planner-chat .chat-question-response {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
@@ -308,11 +275,11 @@ Mobile Planner Chat should match regular task chat: keep the composer as a singl
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-planner-chat-message {
|
.task-planner-chat .chat-message {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-planner-chat-message .chat-question-response {
|
.task-planner-chat .chat-question-response {
|
||||||
margin-inline: 0;
|
margin-inline: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import type { ChatMessage, ResolvedModelSelection, Task, TaskDetail } from "@fusion/core";
|
import type { ChatMessage, ResolvedModelSelection, Task, TaskDetail } from "@fusion/core";
|
||||||
import { getErrorMessage } from "@fusion/core";
|
import { getErrorMessage } from "@fusion/core";
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import { Loader2, Maximize2, Minimize2 } from "lucide-react";
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import { Loader2, Maximize2, Minimize2, Send } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
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 { ensureTaskPlannerChatSession, fetchChatMessages, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse } from "../api";
|
||||||
import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall";
|
import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall";
|
||||||
import { markdownComponents } from "./AgentLogViewer";
|
|
||||||
import { ChatQuestionResponse } from "./ChatQuestionResponse";
|
import { ChatQuestionResponse } from "./ChatQuestionResponse";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
|
import { StandardChatActionButton, StandardChatMessageItem, StandardStreamingMessage, formatModelTag } from "./StandardChatSurface";
|
||||||
import "./TaskPlannerChatTab.css";
|
import "./TaskPlannerChatTab.css";
|
||||||
|
|
||||||
interface TaskPlannerChatTabProps {
|
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 {
|
return {
|
||||||
id: "streaming-assistant",
|
id: "streaming-assistant",
|
||||||
sessionId,
|
sessionId,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content,
|
content,
|
||||||
thinkingOutput: null,
|
thinkingOutput: thinkingOutput || null,
|
||||||
metadata: { streaming: true, ...(toolCalls.length > 0 ? { toolCalls } : {}) },
|
metadata: { streaming: true, ...(toolCalls.length > 0 ? { toolCalls } : {}) },
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -159,7 +157,7 @@ function extractPlannerSteeringTextFromResult(result: unknown): string | null {
|
|||||||
return text || null;
|
return text || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractToolCalls(message: ChatMessage): ToolCallInfo[] {
|
function extractToolCalls(message: Pick<ChatMessage, "metadata">): ToolCallInfo[] {
|
||||||
const rawToolCalls = message.metadata?.toolCalls;
|
const rawToolCalls = message.metadata?.toolCalls;
|
||||||
if (!Array.isArray(rawToolCalls)) return [];
|
if (!Array.isArray(rawToolCalls)) return [];
|
||||||
return rawToolCalls
|
return rawToolCalls
|
||||||
@@ -196,6 +194,18 @@ function isQuestionAnswerFor(message: ChatMessage, parsed: ParsedQuestionToolCal
|
|||||||
return parsed.questions.some((question) => trimmed.includes(`> Q: ${question.question}`));
|
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<string, PlannerQuestionRenderState> {
|
function buildPlannerQuestionRenderStates(messages: readonly ChatMessage[]): Map<string, PlannerQuestionRenderState> {
|
||||||
const states = new Map<string, PlannerQuestionRenderState>();
|
const states = new Map<string, PlannerQuestionRenderState>();
|
||||||
const latestUnansweredByQuestion = new Map<string, string>();
|
const latestUnansweredByQuestion = new Map<string, string>();
|
||||||
@@ -236,6 +246,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
|
const [streamingThinking, setStreamingThinking] = useState("");
|
||||||
const [composerState, setComposerState] = useState<ComposerState>("idle");
|
const [composerState, setComposerState] = useState<ComposerState>("idle");
|
||||||
const composerStateRef = useRef<ComposerState>("idle");
|
const composerStateRef = useRef<ComposerState>("idle");
|
||||||
const [loading, setLoading] = useState(false);
|
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 planningModelProvider = isUsableModel(planningModel) ? planningModel.provider : undefined;
|
||||||
const planningModelId = isUsableModel(planningModel) ? planningModel.modelId : undefined;
|
const planningModelId = isUsableModel(planningModel) ? planningModel.modelId : undefined;
|
||||||
const planningModelLabel = planningModelProvider && planningModelId ? `${planningModelProvider}/${planningModelId}` : "";
|
const planningModelLabel = planningModelProvider && planningModelId ? `${planningModelProvider}/${planningModelId}` : "";
|
||||||
|
const activeModelTag = formatModelTag(planningModelProvider, planningModelId);
|
||||||
const modelPayload = useMemo(() => {
|
const modelPayload = useMemo(() => {
|
||||||
return planningModelProvider && planningModelId
|
return planningModelProvider && planningModelId
|
||||||
? { modelProvider: planningModelProvider, modelId: planningModelId }
|
? { modelProvider: planningModelProvider, modelId: planningModelId }
|
||||||
@@ -297,6 +309,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
composerStateRef.current = "idle";
|
composerStateRef.current = "idle";
|
||||||
|
setStreamingThinking("");
|
||||||
setComposerState("idle");
|
setComposerState("idle");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHistoryLoaded(false);
|
setHistoryLoaded(false);
|
||||||
@@ -362,6 +375,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
setSessionId(resolvedSessionId);
|
setSessionId(resolvedSessionId);
|
||||||
setMessages((current) => [...current, makeOptimisticUserMessage(resolvedSessionId, content)]);
|
setMessages((current) => [...current, makeOptimisticUserMessage(resolvedSessionId, content)]);
|
||||||
let accumulated = "";
|
let accumulated = "";
|
||||||
|
let accumulatedThinking = "";
|
||||||
const streamingToolCalls: ToolCallInfo[] = [];
|
const streamingToolCalls: ToolCallInfo[] = [];
|
||||||
|
|
||||||
streamRef.current?.close();
|
streamRef.current?.close();
|
||||||
@@ -375,7 +389,16 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
accumulated += delta;
|
accumulated += delta;
|
||||||
setMessages((current) => {
|
setMessages((current) => {
|
||||||
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
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 }) => {
|
onToolStart: ({ toolName, args }) => {
|
||||||
@@ -383,7 +406,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
streamingToolCalls.push({ toolName, args, isError: false, status: "running" });
|
streamingToolCalls.push({ toolName, args, isError: false, status: "running" });
|
||||||
setMessages((current) => {
|
setMessages((current) => {
|
||||||
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
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 }) => {
|
onToolEnd: ({ toolName, isError, result }) => {
|
||||||
@@ -404,13 +427,14 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
}
|
}
|
||||||
setMessages((current) => {
|
setMessages((current) => {
|
||||||
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
||||||
return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls)];
|
return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls, accumulatedThinking)];
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDone: (data) => {
|
onDone: (data) => {
|
||||||
if (!isCurrentStreamRequest()) return;
|
if (!isCurrentStreamRequest()) return;
|
||||||
composerStateRef.current = "idle";
|
composerStateRef.current = "idle";
|
||||||
setComposerState("idle");
|
setComposerState("idle");
|
||||||
|
setStreamingThinking("");
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
if (data.message) {
|
if (data.message) {
|
||||||
setMessages((current) => {
|
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"));
|
setError(message || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond"));
|
||||||
composerStateRef.current = "idle";
|
composerStateRef.current = "idle";
|
||||||
setComposerState("idle");
|
setComposerState("idle");
|
||||||
|
setStreamingThinking("");
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -451,11 +476,22 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
addToast(message, "error");
|
addToast(message, "error");
|
||||||
composerStateRef.current = "idle";
|
composerStateRef.current = "idle";
|
||||||
setComposerState("idle");
|
setComposerState("idle");
|
||||||
|
setStreamingThinking("");
|
||||||
}
|
}
|
||||||
}, [addToast, modelPayload, projectId, refreshTaskAfterSteering, sessionId, task.id, t]);
|
}, [addToast, modelPayload, projectId, refreshTaskAfterSteering, sessionId, task.id, t]);
|
||||||
|
|
||||||
const sendMessage = useCallback(() => sendMessageContent(draft), [draft, sendMessageContent]);
|
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<HTMLTextAreaElement>) => {
|
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (event.key !== "Enter" || event.shiftKey) return;
|
if (event.key !== "Enter" || event.shiftKey) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -463,23 +499,6 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
}, [sendMessage]);
|
}, [sendMessage]);
|
||||||
|
|
||||||
const canSend = draft.trim().length > 0 && composerState !== "sending";
|
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<HTMLButtonElement>) => {
|
|
||||||
if (event.pointerType === "mouse") return;
|
|
||||||
if (!canSend) return;
|
|
||||||
event.preventDefault();
|
|
||||||
void sendMessage();
|
|
||||||
}, [canSend, sendMessage]);
|
|
||||||
|
|
||||||
const handleSendMouseDown = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
if (!canSend) return;
|
|
||||||
event.preventDefault();
|
|
||||||
}, [canSend]);
|
|
||||||
|
|
||||||
const showEmptyState = historyLoaded && !loading && !error && messages.length === 0;
|
const showEmptyState = historyLoaded && !loading && !error && messages.length === 0;
|
||||||
const questionRenderStates = useMemo(() => buildPlannerQuestionRenderStates(messages), [messages]);
|
const questionRenderStates = useMemo(() => buildPlannerQuestionRenderStates(messages), [messages]);
|
||||||
const starterPrompts = useMemo(() => {
|
const starterPrompts = useMemo(() => {
|
||||||
@@ -500,6 +519,47 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
});
|
});
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
|
const renderPlannerToolCall = useCallback((message: ChatMessage, toolCall: ToolCallInfo, index: number) => {
|
||||||
|
const steeringResult = extractPlannerSteeringResult(toolCall);
|
||||||
|
if (steeringResult) {
|
||||||
|
return (
|
||||||
|
<div key={`${toolCall.toolName}-${index}`} className="task-planner-chat-steering-confirmation" data-testid="task-planner-chat-steering-confirmation">
|
||||||
|
<strong>{t("taskDetail.plannerChat.steeringAdded", "Added as steering comment")}</strong>
|
||||||
|
<p>{steeringResult.text}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const isRunningSteering = toolCall.toolName === TASK_PLANNER_STEERING_TOOL_NAME && toolCall.status === "running";
|
||||||
|
if (isRunningSteering) {
|
||||||
|
return (
|
||||||
|
<div key={`${toolCall.toolName}-${index}`} className="task-planner-chat-steering-confirmation task-planner-chat-steering-confirmation--pending" data-testid="task-planner-chat-steering-pending">
|
||||||
|
<strong>{t("taskDetail.plannerChat.steeringAdding", "Adding steering comment…")}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (toolCall.toolName === TASK_PLANNER_STEERING_TOOL_NAME && toolCall.isError) {
|
||||||
|
return (
|
||||||
|
<div key={`${toolCall.toolName}-${index}`} className="task-planner-chat-steering-confirmation task-planner-chat-steering-confirmation--error" role="alert" data-testid="task-planner-chat-steering-error">
|
||||||
|
<strong>{t("taskDetail.plannerChat.steeringFailed", "Steering comment was not added")}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const questionState = questionRenderStates.get(`${message.id}:${index}`);
|
||||||
|
if (!questionState) return undefined;
|
||||||
|
if (questionState.hiddenDuplicate) return null;
|
||||||
|
return (
|
||||||
|
<ChatQuestionResponse
|
||||||
|
key={`${toolCall.toolName}-${index}`}
|
||||||
|
parsed={questionState.parsed}
|
||||||
|
answered={questionState.answered}
|
||||||
|
submittedAnswer={questionState.submittedAnswer}
|
||||||
|
disabled={composerState === "sending" || questionState.answered}
|
||||||
|
compact
|
||||||
|
onSubmit={(answerText) => void sendMessageContent(answerText)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [composerState, questionRenderStates, sendMessageContent, t]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:TaskDetailPlannerChat 2026-06-30-23:58:
|
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.
|
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:
|
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.
|
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:
|
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.
|
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,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
messages.map((message) => {
|
<>
|
||||||
const toolCalls = extractToolCalls(message);
|
{messages.map((message) => {
|
||||||
return (
|
if (message.id === "streaming-assistant") {
|
||||||
<article key={message.id} className={`task-planner-chat-message task-planner-chat-message--${message.role}`} data-testid={`task-planner-chat-message-${message.role}`}>
|
const streamingToolCalls = extractToolCalls(message);
|
||||||
<div className="task-planner-chat-message-role">
|
return (
|
||||||
{message.role === "user" ? t("taskDetail.plannerChat.user", "You") : t("taskDetail.plannerChat.assistant", "Planner")}
|
<StandardStreamingMessage
|
||||||
</div>
|
key={message.id}
|
||||||
{message.content && (
|
streamingText={message.content}
|
||||||
<div className="task-planner-chat-message-content markdown-body">
|
streamingThinking={message.thinkingOutput ?? streamingThinking}
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>{message.content}</ReactMarkdown>
|
streamingToolCalls={streamingToolCalls}
|
||||||
</div>
|
forcePlain={false}
|
||||||
)}
|
agentName={t("taskDetail.plannerChat.assistant", "Planner")}
|
||||||
{toolCalls.map((toolCall, index) => {
|
hideAssistantIdentity={false}
|
||||||
const steeringResult = extractPlannerSteeringResult(toolCall);
|
showAssistantModelTag={Boolean(activeModelTag)}
|
||||||
if (steeringResult) {
|
activeModelTag={activeModelTag}
|
||||||
return (
|
activeModelProvider={planningModelProvider ?? null}
|
||||||
<div key={`${toolCall.toolName}-${index}`} className="task-planner-chat-steering-confirmation" data-testid="task-planner-chat-steering-confirmation">
|
toolCallRenderer={(toolCall, index) => renderPlannerToolCall(message, toolCall, index)}
|
||||||
<strong>{t("taskDetail.plannerChat.steeringAdded", "Added as steering comment")}</strong>
|
/>
|
||||||
<p>{steeringResult.text}</p>
|
);
|
||||||
</div>
|
}
|
||||||
);
|
return (
|
||||||
}
|
<StandardChatMessageItem
|
||||||
const isRunningSteering = toolCall.toolName === TASK_PLANNER_STEERING_TOOL_NAME && toolCall.status === "running";
|
key={message.id}
|
||||||
if (isRunningSteering) {
|
message={toStandardChatMessage(message)}
|
||||||
return (
|
forcePlain={false}
|
||||||
<div key={`${toolCall.toolName}-${index}`} className="task-planner-chat-steering-confirmation task-planner-chat-steering-confirmation--pending" data-testid="task-planner-chat-steering-pending">
|
agentName={t("taskDetail.plannerChat.assistant", "Planner")}
|
||||||
<strong>{t("taskDetail.plannerChat.steeringAdding", "Adding steering comment…")}</strong>
|
hideAssistantIdentity={false}
|
||||||
</div>
|
showAssistantModelTag={Boolean(activeModelTag)}
|
||||||
);
|
activeModelTag={activeModelTag}
|
||||||
}
|
activeModelProvider={planningModelProvider ?? null}
|
||||||
if (toolCall.toolName === TASK_PLANNER_STEERING_TOOL_NAME && toolCall.isError) {
|
activeSessionId={sessionId}
|
||||||
return (
|
isAwaitingQuestionAnswer={message.role === "assistant"}
|
||||||
<div key={`${toolCall.toolName}-${index}`} className="task-planner-chat-steering-confirmation task-planner-chat-steering-confirmation--error" role="alert" data-testid="task-planner-chat-steering-error">
|
onQuestionSubmit={(answerText) => void sendMessageContent(answerText)}
|
||||||
<strong>{t("taskDetail.plannerChat.steeringFailed", "Steering comment was not added")}</strong>
|
toolCallRenderer={(toolCall, index) => renderPlannerToolCall(message, toolCall, index)}
|
||||||
</div>
|
/>
|
||||||
);
|
);
|
||||||
}
|
})}
|
||||||
const questionState = questionRenderStates.get(`${message.id}:${index}`);
|
{composerState === "sending" && !messages.some((message) => message.id === "streaming-assistant") && (
|
||||||
if (!questionState || questionState.hiddenDuplicate) return null;
|
<StandardStreamingMessage
|
||||||
return (
|
streamingText=""
|
||||||
<ChatQuestionResponse
|
streamingThinking={streamingThinking}
|
||||||
key={`${toolCall.toolName}-${index}`}
|
streamingToolCalls={[]}
|
||||||
parsed={questionState.parsed}
|
forcePlain={false}
|
||||||
answered={questionState.answered}
|
agentName={t("taskDetail.plannerChat.assistant", "Planner")}
|
||||||
submittedAnswer={questionState.submittedAnswer}
|
hideAssistantIdentity={false}
|
||||||
disabled={composerState === "sending" || questionState.answered}
|
showAssistantModelTag={Boolean(activeModelTag)}
|
||||||
compact
|
activeModelTag={activeModelTag}
|
||||||
onSubmit={(answerText) => void sendMessageContent(answerText)}
|
activeModelProvider={planningModelProvider ?? null}
|
||||||
/>
|
/>
|
||||||
);
|
)}
|
||||||
})}
|
</>
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -650,17 +710,17 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
disabled={composerState === "sending"}
|
disabled={composerState === "sending"}
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
<button
|
<StandardChatActionButton
|
||||||
type="button"
|
isStreaming={composerState === "sending"}
|
||||||
className="btn btn-primary task-planner-chat-send"
|
canSend={canSend}
|
||||||
onClick={() => void sendMessage()}
|
onSend={sendMessage}
|
||||||
onPointerDown={handleSendPointerDown}
|
onStop={stopPlannerStreaming}
|
||||||
onMouseDown={handleSendMouseDown}
|
classNameSend="btn btn-primary task-planner-chat-send chat-input-send"
|
||||||
disabled={!canSend}
|
classNameStop="btn btn-primary task-planner-chat-send chat-input-stop"
|
||||||
>
|
sendLabel={t("taskDetail.plannerChat.send", "Send")}
|
||||||
{composerState === "sending" ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Send aria-hidden="true" />}
|
stopLabel={t("chat.stopGeneration", "Stop generation")}
|
||||||
<span>{composerState === "sending" ? t("taskDetail.plannerChat.sending", "Sending") : t("taskDetail.plannerChat.send", "Send")}</span>
|
showSendText
|
||||||
</button>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -148,10 +148,10 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(mobileSendBlock).toContain("inline-size: calc(var(--space-2xl) + var(--space-lg));");
|
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));");
|
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-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(".task-planner-chat-starters");
|
||||||
expect(mobileBlock).toContain("grid-template-columns: 1fr;");
|
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;");
|
expect(mobileBlock).toContain("margin-inline: 0;");
|
||||||
|
|
||||||
const detailCss = readDashboardStylesSource();
|
const detailCss = readDashboardStylesSource();
|
||||||
|
|||||||
@@ -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 () => {
|
it("shows a recoverable error when the post-stream refresh fails", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
|
|||||||
Reference in New Issue
Block a user