import type { AgentLogEntry, AgentRole, SteeringComment, Task, TaskDetail } from "@fusion/core"; import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { ChevronDown, Cpu, Loader2, Maximize2, Minimize2, Send } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { addSteeringComment, refineTask } from "../api"; import { useAgentLogs } from "../hooks/useAgentLogs"; import type { ToastType } from "../hooks/useToast"; import { getErrorMessage } from "@fusion/core"; import { linkifyFilePaths } from "../utils/filePathLinkify"; import { formatRelativeTimeAgo } from "../utils/relativeTimeAgo"; import { ProviderIcon } from "./ProviderIcon"; import { clampChatInputHeight, resolveChatInputOverflowY } from "../utils/chatInputAutosize"; import { formatAgentLogTimingLabels, markdownComponents } from "./AgentLogViewer"; import { parseRuntimeModelMarker } from "./effective-model-resolution"; import "./TaskChatTab.css"; interface TaskChatTabProps { task: Task | TaskDetail; projectId?: string; active: boolean; addToast: (msg: string, type?: ToastType) => void; sessionLive?: boolean; onTaskUpdated?: (task: Task) => void; expanded?: boolean; onToggleExpanded?: () => void; effectiveModels?: Partial>; } type AgentLogRole = AgentRole | undefined; type TaskChatModelInfo = { provider: string; modelId?: string; }; type UserChatMessage = Pick & { optimistic?: boolean }; type TaskChatTranscriptItem = | { kind: "agent"; role: AgentLogRole; label: string; entries: AgentLogEntry[] } | { kind: "user"; message: UserChatMessage }; type TaskChatSegment = | { kind: "tool"; entries: AgentLogEntry[]; startIndex: number } | { kind: "thinking"; entries: AgentLogEntry[]; startIndex: number } | { kind: "text"; entries: AgentLogEntry[]; startIndex: number }; type TaskChatToolGroupRow = | { kind: "invocation"; call: AgentLogEntry; completion?: AgentLogEntry; callIndex: number; completionIndex?: number } | { kind: "entry"; entry: AgentLogEntry; index: number }; const BOTTOM_FOLLOW_THRESHOLD = 48; const TOP_LOAD_THRESHOLD = 48; function isTranscriptNearBottom(container: HTMLElement): boolean { return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD; } function getRoleLabel(role: AgentLogRole, t: TFunction<"app">): string { switch (role) { case "triage": return t("taskChat.roles.planner", "Planner"); case "executor": return t("taskChat.roles.executor", "Executor"); case "reviewer": return t("taskChat.roles.reviewer", "Reviewer"); case "merger": return t("taskChat.roles.merger", "Merger"); default: return t("taskChat.roles.agent", "Agent"); } } function parseModelMarker(entry: AgentLogEntry): TaskChatModelInfo | null { if (entry.type !== "text") return null; const role = entry.agent === "triage" ? "Triage" : entry.agent === "executor" ? "Executor" : entry.agent === "reviewer" ? "Reviewer" : null; if (!role) return null; return parseRuntimeModelMarker(entry.text, role); } function makeModelInfo(provider: string | undefined, modelId: string | undefined): TaskChatModelInfo | null { if (!provider) return null; return modelId ? { provider, modelId } : { provider }; } function getExplicitModelForRole(task: Task | TaskDetail, role: AgentLogRole): TaskChatModelInfo | null { if (role === "triage" && task.planningModelProvider) { return makeModelInfo(task.planningModelProvider, task.planningModelId); } if (role === "executor" && task.modelProvider) { return makeModelInfo(task.modelProvider, task.modelId); } if ((role === "reviewer" || role === "merger") && task.validatorModelProvider) { return makeModelInfo(task.validatorModelProvider, task.validatorModelId); } return null; } function getRuntimeModelForRole(entries: readonly AgentLogEntry[], role: AgentLogRole): TaskChatModelInfo | null { for (let index = entries.length - 1; index >= 0; index -= 1) { const entry = entries[index]; if (entry.agent !== role) continue; const parsed = parseModelMarker(entry); if (parsed) return parsed; } return null; } function getEffectiveModelForRole( effectiveModels: TaskChatTabProps["effectiveModels"] | undefined, role: AgentLogRole, ): TaskChatModelInfo | null { if (!role) return null; return effectiveModels?.[role] ?? null; } function getModelForRole( task: Task | TaskDetail, role: AgentLogRole, entries: readonly AgentLogEntry[], effectiveModels?: TaskChatTabProps["effectiveModels"], ): TaskChatModelInfo | null { /* FNXC:TaskDetailChat 2026-06-23-21:18: Task-detail chat agent headers should identify the AI provider actually backing each role, not a generic/role avatar. Prefer explicit task model overrides because they are stable before logs stream, then fall back to the runtime "using model" log marker emitted by active planner/executor/reviewer sessions. Merger output uses the validator/reviewer lane provider because merge-fix/review flows share that model family in the UI. FNXC:TaskDetailChat 2026-06-23-00:54: Default executor models such as OpenAI Codex GPT-5.5 can resolve through settings rather than task overrides or log markers. Task chat receives the same effective model resolution used by the task-detail model header so role icons match Chat and Agent Log instead of falling back to CPU for default-backed agents. */ return getExplicitModelForRole(task, role) ?? getRuntimeModelForRole(entries, role) ?? getEffectiveModelForRole(effectiveModels, role); } function TaskChatAgentIcon({ label, modelInfo }: { label: string; modelInfo: TaskChatModelInfo | null }) { if (modelInfo?.provider) { const title = modelInfo.modelId ? `${label}: ${modelInfo.provider}/${modelInfo.modelId}` : `${label}: ${modelInfo.provider}`; return ( ); } /* FNXC:TaskDetailChat 2026-06-23-00:42: Task chat role headers should use provider logos whenever the role's model provider is known, and a neutral CPU fallback when it is not. Avoid role clip-art avatars so executor/reviewer/merger rows read as professional model execution blocks rather than cartoon agent identities. */ const title = `${label}: model provider unknown`; return ( ); } function getEntryKey(entry: AgentLogEntry, index: number): string { return [entry.taskId, entry.timestamp, entry.agent ?? "agent", entry.type, index].join(":"); } function getTimestampMs(value: string): number { const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : 0; } function getLatestEntryTimestamp(entries: readonly AgentLogEntry[]): string { let latestTimestamp = ""; let latestTimestampMs = 0; for (const entry of entries) { const timestampMs = getTimestampMs(entry.timestamp); if (timestampMs > latestTimestampMs) { latestTimestamp = entry.timestamp; latestTimestampMs = timestampMs; } } return latestTimestamp; } /* FNXC:TaskChatTimestamps 2026-06-29-14:37: Task Detail Chat requires per-block timestamps in addition to existing group and user headers so operators can scan when each text, tool, thinking, or steering block was produced. Reuse the shared relative-time formatter and return null for empty or invalid dates so transcript blocks never render timestamp shells without meaningful time text. */ function getRelativeTimestamp(timestamp: string | undefined): string { return timestamp ? formatRelativeTimeAgo(timestamp) : ""; } function TaskChatTimestamp({ timestamp, testId = "task-chat-block-time", label = "Message timestamp" }: { timestamp: string | undefined; testId?: string; label?: string }) { const relativeTime = getRelativeTimestamp(timestamp); if (!relativeTime) return null; return ( {relativeTime} ); } function TaskChatTimingLabels({ entry }: { entry: AgentLogEntry }) { const { t } = useTranslation("app"); const labels = formatAgentLogTimingLabels(entry, t as TFunction<"app">); if (labels.length === 0) return null; return ( {labels.map((label) => ( {label} ))} ); } function TaskChatTimestampMeta({ timestamp, label }: { timestamp: string | undefined; label: string }) { if (!getRelativeTimestamp(timestamp)) return null; return (
); } function getLatestTranscriptTimestampMs(entries: readonly AgentLogEntry[], userMessages: readonly UserChatMessage[]): number { return Math.max( 0, ...entries.map((entry) => getTimestampMs(entry.timestamp)), ...userMessages.map((message) => getTimestampMs(message.createdAt)), ); } function getUserMessageDedupKey(message: Pick): string { return message.id ? `id:${message.id}` : `fallback:${message.text}:${message.createdAt}`; } function getUserMessageFallbackKey(message: Pick): string { return `fallback:${message.text}:${message.createdAt}`; } function mergeUserMessages(persistedComments: readonly SteeringComment[] | undefined, optimisticMessages: readonly UserChatMessage[]): UserChatMessage[] { const messages: UserChatMessage[] = []; const seen = new Set(); const seenFallbacks = new Set(); const addMessage = (message: UserChatMessage) => { const idKey = getUserMessageDedupKey(message); const fallbackKey = getUserMessageFallbackKey(message); if (seen.has(idKey) || seenFallbacks.has(fallbackKey)) return; seen.add(idKey); seenFallbacks.add(fallbackKey); messages.push(message); }; for (const message of optimisticMessages) { addMessage(message); } for (const comment of persistedComments ?? []) { if (comment.author !== "user") continue; addMessage({ id: comment.id, text: comment.text, createdAt: comment.createdAt }); } return messages; } function buildTranscriptItems(entries: readonly AgentLogEntry[], userMessages: readonly UserChatMessage[], t: TFunction<"app">): TaskChatTranscriptItem[] { const orderedItems = [ ...entries.map((entry, index) => ({ kind: "agent" as const, entry, index, timestamp: getTimestampMs(entry.timestamp) })), ...userMessages.map((message, index) => ({ kind: "user" as const, message, index, timestamp: getTimestampMs(message.createdAt) })), ].sort((a, b) => a.timestamp - b.timestamp || a.index - b.index || (a.kind === "agent" ? -1 : 1)); return orderedItems.reduce((items, item) => { if (item.kind === "user") { items.push({ kind: "user", message: item.message }); return items; } const previousItem = items[items.length - 1]; const role = item.entry.agent; if (previousItem?.kind === "agent" && previousItem.role === role) { previousItem.entries.push(item.entry); return items; } items.push({ kind: "agent", role, label: getRoleLabel(role, t), entries: [item.entry] }); return items; }, []); } function isToolLikeEntry(entry: AgentLogEntry): boolean { return entry.type === "tool" || entry.type === "tool_result" || entry.type === "tool_error"; } function formatEntryLabel(entry: AgentLogEntry, t: TFunction<"app">): string { switch (entry.type) { case "tool": return t("taskChat.toolCall", "Tool call"); case "tool_result": return t("taskChat.toolResult", "Tool result"); case "tool_error": return t("taskChat.toolError", "Tool error"); case "thinking": return t("taskChat.thinking", "Thinking"); default: return t("taskChat.message", "Message"); } } /* FNXC:TaskChat 2026-06-22-03:05: Tool-call group copy intentionally preserves the pre-i18n grammar contract because the test i18n instance interpolates defaults without plural suffix resolution. Keep plural branches in source for deterministic "1 tool call" / "N tool calls" and use lowercase completion labels only for the inline "Tool call → result/error" kicker; detail headers remain capitalized below. */ function formatCompletionLabel(entry: AgentLogEntry, t: TFunction<"app">): string { return entry.type === "tool_error" ? t("taskChat.errorInline", "error") : t("taskChat.resultInline", "result"); } const TOOL_NAME_SUMMARY_LIMIT = 5; function formatToolCallCount(count: number, t: TFunction<"app">): string { return count === 1 ? t("taskChat.toolCallCount", "{{count}} tool call", { count }) : t("taskChat.toolCallCountPlural", "{{count}} tool calls", { count }); } function formatErrorCount(count: number, t: TFunction<"app">): string { return count === 1 ? t("taskChat.errorCount", "{{count}} error", { count }) : t("taskChat.errorCountPlural", "{{count}} errors", { count }); } function formatEntryCount(count: number, t: TFunction<"app">): string { return count === 1 ? t("taskChat.entryCount", "{{count}} entry", { count }) : t("taskChat.entryCountPlural", "{{count}} entries", { count }); } function getToolInvocationEntries(entries: AgentLogEntry[]): AgentLogEntry[] { const callEntries = entries.filter((entry) => entry.type === "tool"); return callEntries.length > 0 ? callEntries : entries.filter((entry) => isToolLikeEntry(entry)); } function getToolNameSummary(entries: AgentLogEntry[]): { visibleNames: string[]; overflowCount: number } { const invocationEntries = getToolInvocationEntries(entries); const names = Array.from(new Set(invocationEntries.map((entry) => entry.text).filter(Boolean))); const visibleNames = names.slice(0, TOOL_NAME_SUMMARY_LIMIT); return { visibleNames, overflowCount: Math.max(0, names.length - visibleNames.length) }; } function segmentGroupEntries(entries: AgentLogEntry[]): TaskChatSegment[] { const segments: TaskChatSegment[] = []; let index = 0; while (index < entries.length) { const entry = entries[index]; if (isToolLikeEntry(entry)) { const startIndex = index; const toolEntries: AgentLogEntry[] = []; while (index < entries.length && isToolLikeEntry(entries[index])) { toolEntries.push(entries[index]); index += 1; } segments.push({ kind: "tool", entries: toolEntries, startIndex }); continue; } if (entry.type === "thinking") { const startIndex = index; const thinkingEntries: AgentLogEntry[] = []; while (index < entries.length && entries[index].type === "thinking") { thinkingEntries.push(entries[index]); index += 1; } segments.push({ kind: "thinking", entries: thinkingEntries, startIndex }); continue; } /* FNXC:TaskChat-StatusEntries 2026-07-15-11:20: A `status` row is a COMPLETE engine message, so it gets its own segment and is never merged with a neighbour. Merging is only correct for `text`, whose rows are streamed delta fragments that `TaskChatText` re-glues with `join("")`. This is why a provider outage rendered as one run-on string: engine markers were written as `text`, so N standalone messages ("Reviewer using model: x/y" ×14) were glued edge-to-edge under a "14 entries" header. Fixing it with a separator in `TaskChatText` would corrupt legitimate streamed text (the FN-5787/5789/5803 regression lineage) — the split has to happen here, on the type. */ if (entry.type === "status") { segments.push({ kind: "text", entries: [entry], startIndex: index }); index += 1; continue; } const startIndex = index; const textEntries: AgentLogEntry[] = []; while ( index < entries.length && !isToolLikeEntry(entries[index]) && entries[index].type !== "thinking" && entries[index].type !== "status" ) { textEntries.push(entries[index]); index += 1; } segments.push({ kind: "text", entries: textEntries, startIndex }); } return segments; } function TaskChatText({ entries }: { entries: AgentLogEntry[] }) { const firstEntry = entries[0]; if (!firstEntry) return null; return (
{entries.map((entry) => entry.text).join("")}
); } function TaskChatToolEntry({ entry }: { entry: AgentLogEntry }) { const { t } = useTranslation("app"); return (
{formatEntryLabel(entry, t)}
{entry.text}
{entry.detail ?
{linkifyFilePaths(entry.detail)}
: null}
); } function getToolGroupRows(entries: AgentLogEntry[]): TaskChatToolGroupRow[] { const rows: TaskChatToolGroupRow[] = []; let index = 0; while (index < entries.length) { const entry = entries[index]; if (entry.type === "tool") { const nextEntry = entries[index + 1]; const hasCompletion = nextEntry?.type === "tool_result" || nextEntry?.type === "tool_error"; rows.push({ kind: "invocation", call: entry, completion: hasCompletion ? nextEntry : undefined, callIndex: index, completionIndex: hasCompletion ? index + 1 : undefined, }); index += hasCompletion ? 2 : 1; continue; } rows.push({ kind: "entry", entry, index }); index += 1; } return rows; } function TaskChatToolInvocation({ row }: { row: Extract }) { const { t } = useTranslation("app"); const completion = row.completion; const completionLabel = completion ? formatCompletionLabel(completion, t) : undefined; const className = `task-chat-tool-entry task-chat-tool-invocation${completion?.type === "tool_error" ? " task-chat-tool-entry--tool-error" : ""}`; return (
{completionLabel ? t("taskChat.toolCallTo", "Tool call → {{label}}", { label: completionLabel }) : t("taskChat.toolCall", "Tool call")}
{row.call.text}
{row.call.detail ? (
{t("taskChat.arguments", "Arguments")}
{linkifyFilePaths(row.call.detail)}
) : null} {completion?.detail ? (
{completion.type === "tool_error" ? t("taskChat.error", "Error") : t("taskChat.result", "Result")}
{linkifyFilePaths(completion.detail)}
) : null}
); } function TaskChatToolGroup({ entries }: { entries: AgentLogEntry[] }) { /* FNXC:TaskChat 2026-06-28-00:00: FN-7215 requires task-detail tool groups to match regular Chat's quiet transcript treatment: keep historical tool calls collapsed by default while the summary preserves count, deduped names, overflow, and error context for quick scanning. */ const { t } = useTranslation("app"); const invocationEntries = getToolInvocationEntries(entries); const invocationCount = invocationEntries.length; const errorCount = entries.filter((entry) => entry.type === "tool_error").length; const { visibleNames, overflowCount } = getToolNameSummary(entries); const rows = getToolGroupRows(entries); return (
{formatToolCallCount(invocationCount, t)} {visibleNames.length > 0 ? ( {visibleNames.join(", ")} {overflowCount > 0 ? {t("taskChat.moreTools", ", +{{count}} more", { count: overflowCount })} : null} ) : null} {errorCount > 0 ? ( {formatErrorCount(errorCount, t)} ) : null}
{rows.map((row) => ( row.kind === "invocation" ? ( ) : ( ) ))}
); } /* FNXC:Chat-Thinking 2026-07-15-10:33: Chat thinking (reasoning) blocks render collapsed by default so the response is scannable without manually closing each block; the summary remains an expand-on-click affordance. (FN-7974) */ function TaskChatThinking({ entries }: { entries: AgentLogEntry[] }) { const { t } = useTranslation("app"); const combinedThinkingText = entries.map((entry) => entry.text).join(""); return (
{t("taskChat.thinking", "Thinking")}
{combinedThinkingText}
); } function TaskChatSegmentView({ segment }: { segment: TaskChatSegment }) { if (segment.kind === "tool") { return ; } if (segment.kind === "thinking") { return ; } return ; } /* FNXC:TaskChatTimestamps 2026-06-17-15:43: FN-6597 requires small relative timestamps on both task-chat agent group headers and user message headers, computed at render time from existing transcript timestamps without adding a live timer. */ function TaskChatUserMessage({ message }: { message: UserChatMessage }) { const { t } = useTranslation("app"); const relativeTime = formatRelativeTimeAgo(message.createdAt); return (
{t("taskChat.you", "You")}
{relativeTime ? ( {relativeTime} ) : null}
{message.text}
); } export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated, expanded = false, onToggleExpanded, effectiveModels }: TaskChatTabProps) { const { t } = useTranslation("app"); const { entries, loading, loadMore, hasMore, loadingMore } = useAgentLogs(task.id, active, projectId); const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); const sendingRef = useRef(false); const [optimisticMessages, setOptimisticMessages] = useState([]); const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true); const transcriptRef = useRef(null); const previousEntryCountRef = useRef(0); const previousScrollHeightRef = useRef(0); const previousFirstEntryKeyRef = useRef(null); const previousAgentEntryCountRef = useRef(0); const pendingPrependScrollHeightRef = useRef(null); const pendingPrependScrollTopRef = useRef(0); const loadMoreInFlightRef = useRef(false); const previousActiveRef = useRef(false); const anchorFrameRef = useRef(null); const textareaRef = useRef(null); const userMessages = useMemo( () => mergeUserMessages(task.steeringComments, optimisticMessages), [optimisticMessages, task.steeringComments], ); const transcriptItems = useMemo(() => buildTranscriptItems(entries, userMessages, t), [entries, t, userMessages]); const transcriptItemCount = entries.length + userMessages.length; const firstEntryKey = entries[0] ? getEntryKey(entries[0], 0) : null; const isDoneTask = task.column === "done"; /* * FNXC:TaskDetailActivity 2026-06-30-21:51: * Activity → Live (legacy `current`) is the operational steering surface for task execution. Keep the top-level planner-model Chat tab separate; Feed and Raw Logs remain read-only Activity segments without this composer. * * FNXC:TaskDetailActivity 2026-06-30-23:59: * Task Activity must keep the operational composer and existing steering/refinement APIs while removing the visible steering-comment guidance label/hint block from task chat. Use non-visible accessible names on the form/textarea/button so the removed copy does not leave a UI shell or dangling aria-describedby reference. */ const composerFormLabel = isDoneTask ? t("taskChat.refinementComposerFormLabel", "Task refinement composer") : t("taskChat.activityComposerFormLabel", "Task activity composer"); const composerPlaceholder = isDoneTask ? t("taskChat.donePlaceholder", "Start a refinement task for this completed task") : t("taskChat.activePlaceholder", "Steer the currently executing agent"); const canSend = draft.trim().length > 0 && !sending; const resizeComposer = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.style.height = "0"; const maxHeight = typeof window !== "undefined" && window.matchMedia?.("(max-width: 768px)").matches ? 200 : undefined; const nextHeight = clampChatInputHeight(textarea.scrollHeight, maxHeight); textarea.style.height = `${nextHeight}px`; textarea.style.overflowY = resolveChatInputOverflowY(textarea.scrollHeight, maxHeight); }, []); useLayoutEffect(() => { resizeComposer(); }, [draft, resizeComposer]); const cancelAnchorTranscriptFrame = useCallback(() => { if (anchorFrameRef.current === null) return; window.cancelAnimationFrame(anchorFrameRef.current); anchorFrameRef.current = null; }, []); const anchorTranscriptToBottom = useCallback((container: HTMLElement) => { cancelAnchorTranscriptFrame(); if (!container.isConnected) return; let frame = 0; let stableFrames = 0; let lastScrollHeight = -1; const maxFrames = 6; const writeBottom = () => { anchorFrameRef.current = null; if (!container.isConnected) return; container.scrollTop = container.scrollHeight; previousScrollHeightRef.current = container.scrollHeight; setIsTranscriptAtBottom(true); if (container.scrollHeight === lastScrollHeight) { stableFrames += 1; } else { stableFrames = 0; lastScrollHeight = container.scrollHeight; } frame += 1; if (frame >= maxFrames || stableFrames >= 2) { return; } anchorFrameRef.current = window.requestAnimationFrame(writeBottom); }; writeBottom(); }, [cancelAnchorTranscriptFrame]); useLayoutEffect(() => () => { cancelAnchorTranscriptFrame(); }, [cancelAnchorTranscriptFrame]); useLayoutEffect(() => { const container = transcriptRef.current; const wasActive = previousActiveRef.current; previousActiveRef.current = active; if (!container || !active || transcriptItemCount === 0) return; const becameActive = !wasActive; const receivedInitialItems = previousEntryCountRef.current === 0; if (!becameActive && !receivedInitialItems) return; anchorTranscriptToBottom(container); previousEntryCountRef.current = transcriptItemCount; previousScrollHeightRef.current = container.scrollHeight; return () => { cancelAnchorTranscriptFrame(); }; }, [active, anchorTranscriptToBottom, cancelAnchorTranscriptFrame, transcriptItemCount]); useLayoutEffect(() => { const container = transcriptRef.current; if (!container) return; if (!active) { previousEntryCountRef.current = transcriptItemCount; previousScrollHeightRef.current = container.scrollHeight; previousFirstEntryKeyRef.current = firstEntryKey; previousAgentEntryCountRef.current = entries.length; return; } if (transcriptItemCount === 0) { previousEntryCountRef.current = transcriptItemCount; previousScrollHeightRef.current = container.scrollHeight; previousFirstEntryKeyRef.current = firstEntryKey; previousAgentEntryCountRef.current = entries.length; return; } const previousCount = previousEntryCountRef.current; const previousScrollHeight = previousScrollHeightRef.current || container.scrollHeight; const previousFirstEntryKey = previousFirstEntryKeyRef.current; const previousAgentEntryCount = previousAgentEntryCountRef.current; const prependedOlderEntries = Boolean( pendingPrependScrollHeightRef.current !== null && transcriptItemCount > previousCount && entries.length > previousAgentEntryCount && firstEntryKey && (!previousFirstEntryKey || firstEntryKey !== previousFirstEntryKey), ); if (prependedOlderEntries) { /* * FNXC:TaskDetailChat 2026-06-16-23:03: * Task-detail chat must load older paginated agent history at the top without disturbing the reader's viewport. Treat a changed first agent-log key as a prepend so bottom-follow remains reserved for live appends at the transcript tail. */ const previousTop = pendingPrependScrollTopRef.current; const previousHeight = pendingPrependScrollHeightRef.current ?? previousScrollHeight; const heightDelta = container.scrollHeight - previousHeight; container.scrollTop = previousTop + Math.max(0, heightDelta); pendingPrependScrollHeightRef.current = null; setIsTranscriptAtBottom(isTranscriptNearBottom(container)); } else if (transcriptItemCount > previousCount) { const shouldFollow = previousCount === 0 || previousScrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD; if (shouldFollow) { container.scrollTop = container.scrollHeight; setIsTranscriptAtBottom(true); } else { setIsTranscriptAtBottom(isTranscriptNearBottom(container)); } if (pendingPrependScrollHeightRef.current !== null) { pendingPrependScrollHeightRef.current = container.scrollHeight; pendingPrependScrollTopRef.current = container.scrollTop; } } else { setIsTranscriptAtBottom(isTranscriptNearBottom(container)); } previousEntryCountRef.current = transcriptItemCount; previousScrollHeightRef.current = container.scrollHeight; previousFirstEntryKeyRef.current = firstEntryKey; previousAgentEntryCountRef.current = entries.length; }, [active, entries.length, firstEntryKey, transcriptItemCount]); const loadPreviousMessages = useCallback(async () => { const container = transcriptRef.current; if (!container || !active || !hasMore || loadingMore || loadMoreInFlightRef.current) return; pendingPrependScrollHeightRef.current = container.scrollHeight; pendingPrependScrollTopRef.current = container.scrollTop; loadMoreInFlightRef.current = true; try { await loadMore(); } finally { loadMoreInFlightRef.current = false; } }, [active, hasMore, loadMore, loadingMore]); const handleTranscriptScroll = useCallback(() => { const container = transcriptRef.current; if (!container) return; previousScrollHeightRef.current = container.scrollHeight; setIsTranscriptAtBottom(isTranscriptNearBottom(container)); if (container.scrollTop <= TOP_LOAD_THRESHOLD) { void loadPreviousMessages(); } }, [loadPreviousMessages]); const scrollTranscriptToBottom = useCallback(() => { const container = transcriptRef.current; if (!container) return; container.scrollTop = container.scrollHeight; previousScrollHeightRef.current = container.scrollHeight; setIsTranscriptAtBottom(true); }, []); const handleSubmit = useCallback(async (event?: React.FormEvent) => { event?.preventDefault(); const text = draft.trim(); if (!text || sendingRef.current) return; sendingRef.current = true; const latestTimestampMs = getLatestTranscriptTimestampMs(entries, userMessages); const optimisticCreatedAtMs = Math.max(Date.now(), latestTimestampMs + 1); /* FNXC:TaskDetailChat 2026-06-17-08:12: Freshly-sent user steering must appear immediately at the transcript tail below current agent output and keep that display order after persistence reconciliation, so the agent's follow-up thinking or response renders after the user's bubble even when client and server clocks are skewed. */ const optimisticMessage: UserChatMessage = { id: `optimistic-${task.id}-${optimisticCreatedAtMs}-${Math.random().toString(36).slice(2)}`, text, createdAt: new Date(optimisticCreatedAtMs).toISOString(), optimistic: true, }; setOptimisticMessages((current) => [...current, optimisticMessage]); setSending(true); try { if (isDoneTask) { const newTask = await refineTask(task.id, text, projectId); addToast(`Refinement task created: ${newTask.id}`, "success"); /* FNXC:TaskDetailChat 2026-06-29-21:30: Done-task refinement uses the source task's durable workflow inheritance on the backend, so the chat composer must not send board workflow filters or keep a submitted optimistic bubble that looks like steering on the completed task. Success clears only the draft and temporary bubble; failure keeps the draft and rolls back through the shared catch path. */ setOptimisticMessages((current) => current.filter((message) => message.id !== optimisticMessage.id)); } else { const updatedTask = await addSteeringComment(task.id, text, projectId); const persistedComment = updatedTask.steeringComments ?.filter((comment) => comment.author === "user" && comment.text === text) .at(-1); if (persistedComment) { setOptimisticMessages((current) => current.map((message) => ( message.id === optimisticMessage.id ? { id: persistedComment.id, text: persistedComment.text, createdAt: message.createdAt, optimistic: true } : message ))); } onTaskUpdated?.(updatedTask); } setDraft(""); } catch (error) { setOptimisticMessages((current) => current.filter((message) => message.id !== optimisticMessage.id)); addToast(`Unable to send message: ${getErrorMessage(error)}`, "error"); } finally { sendingRef.current = false; setSending(false); } }, [addToast, draft, entries, isDoneTask, onTaskUpdated, projectId, task.id, userMessages]); /** * FNXC:TaskDetailChat 2026-06-13-19:05: * Task-detail chat follows chat composer keyboard expectations: Enter sends, Shift+Enter keeps textarea newline entry, Cmd/Ctrl+Enter remains supported for existing users, and IME composition Enter is ignored so CJK candidate selection is not submitted mid-composition. */ const handleKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.key !== "Enter") return; if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.shiftKey) return; event.preventDefault(); void handleSubmit(); }, [handleSubmit]); /* FNXC:TaskDetailChat 2026-07-01-00:00: Mobile soft keyboards can blur the focused composer textarea before the Send button receives a click, consuming the first tap. Touch/pen pointer-down submits immediately while the synchronous sendingRef guard preserves empty/disabled and duplicate-send behavior; mouse down only preserves focus so desktop click and keyboard submit semantics remain unchanged. */ const handleSendPointerDown = useCallback((event: React.PointerEvent) => { if (event.pointerType === "mouse") return; if (!canSend) return; event.preventDefault(); void handleSubmit(); }, [canSend, handleSubmit]); const handleSendMouseDown = useCallback((event: React.MouseEvent) => { if (!canSend) return; event.preventDefault(); }, [canSend]); return (
{onToggleExpanded ? ( ) : null}
{hasMore || loadingMore ? (
{loadingMore ? (
) : ( )}
) : null} {loading && transcriptItemCount === 0 ? (
) : transcriptItemCount === 0 ? (
{t("taskChat.emptyAgentOutput", "No agent output yet. Live messages from Planner, Executor, Reviewer, and Merger agents will appear here.")}
) : ( transcriptItems.map((item, itemIndex) => { if (item.kind === "user") { return ; } const segments = segmentGroupEntries(item.entries); const latestEntryTimestamp = item.entries[item.entries.length - 1]?.timestamp ?? ""; const modelInfo = getModelForRole(task, item.role, item.entries, effectiveModels); return (
{item.label}
{formatEntryCount(item.entries.length, t)}
{segments.map((segment) => { const segmentKey = `${segment.kind}-${segment.startIndex}-${segment.entries.length}`; return ; })}
); }) )} {transcriptItemCount > 0 && !isTranscriptAtBottom ? ( ) : null}