import { Fragment, useCallback, type JSX, type ReactNode } from "react"; import { toast } from "@tutti-os/ui-system"; import { AgentPlanCard } from "./AgentPlanCard"; import { AgentCollaborationRow } from "./AgentCollaborationRow"; import { useOptionalAgentHostApi } from "../../../agentActivityHost"; import { AGENT_EXTERNAL_LINK_ACTION_SOURCE, type WorkspaceLinkAction } from "../../../contexts/workspace/presentation/renderer/actions/workspaceLinkActions"; import { AgentMessageMarkdown, type AgentMessageMarkdownWorkspaceAppIcon } from "../../AgentMessageMarkdown"; import { AgentRichTextReadonly } from "../../AgentRichTextReadonly"; import { resolveAgentConversationLinkAction } from "../actions/agentConversationLinkActions"; import { parseMentionItemFromHref } from "../../../agent-gui/agentGuiNode/agentRichText/agentFileMentionExtension"; import { notifyComposerFileMentionBlocked } from "../../../agent-gui/agentGuiNode/composer/resolveComposerFileMentionLinkAction"; import type { AgentGUIProviderSkillOption } from "../../../agent-gui/agentGuiNode/model/agentGuiNodeTypes"; import type { AgentMessageContentVM, AgentMessageRowVM } from "../contracts/agentMessageRowVM"; import type { AgentConversationParticipantPresentation } from "../contracts/agentConversationParticipantPresentation"; import { AgentConversationParticipantHeader } from "./AgentConversationParticipant"; import { AgentToolGroupRow } from "./AgentToolGroupRow"; import { AgentVisibleErrorMessage, recoverVisibleErrorFromMessage } from "./AgentVisibleErrorMessage"; import { AgentThinkingDisclosure } from "./AgentThinkingDisclosure"; import { AgentTuttiModeCheckpointWakeCard } from "./AgentTuttiModeCheckpointWakeCard"; import { AgentTuttiPlanIssueLinkCard } from "./AgentTuttiPlanIssueLinkCard"; import { RawTimelineJsonDisclosure } from "./RawTimelineJsonDisclosure"; import styles from "../../../agent-gui/agentGuiNode/AgentGUIConversation.styles"; import { AgentUserImageGrid } from "./AgentMessageImages"; import { AgentSelectedTextChip } from "./AgentSelectedTextChip"; import { AgentUserMessageEditor, type AgentUserMessageEditRetryControl } from "./AgentUserMessageEditRetry"; import { useAgentUserMessageEditRetry } from "./useAgentUserMessageEditRetry"; import { AgentCopyableMessageGroup } from "./AgentMessageActions"; import { AgentSystemNoticeMessage } from "./AgentSystemNoticeMessage"; const DEFAULT_TOOL_CALLS_LABEL = (count: number): string => `${count} tool calls`; interface AgentMessageBlockProps { workspaceRoot: string | null; basePath: string; row: AgentMessageRowVM; editRetry?: AgentUserMessageEditRetryControl; onLinkAction?: (action: WorkspaceLinkAction) => void; thinkingLabel: string; toolCallsLabel?: (count: number) => string; onAuthLogin?: (provider?: string | null) => void; // Routes a recovered environment-error CTA to the conversation provider. provider?: string | null; availableSkills?: readonly AgentGUIProviderSkillOption[]; workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[]; showRawTimelineJson?: boolean; rawTimelineJsonLabel?: string; participantPresentation?: AgentConversationParticipantPresentation; showParticipantHeader?: boolean; isActiveTurn?: boolean; footerAction?: ReactNode; } export function AgentMessageBlock({ workspaceRoot, basePath, row, editRetry, onLinkAction, thinkingLabel, toolCallsLabel = DEFAULT_TOOL_CALLS_LABEL, onAuthLogin, provider, availableSkills, workspaceAppIcons, showRawTimelineJson = false, rawTimelineJsonLabel = "", participantPresentation, showParticipantHeader = true, isActiveTurn = false, footerAction }: AgentMessageBlockProps): JSX.Element { "use memo"; const agentHostApi = useOptionalAgentHostApi(); const isUser = row.speaker === "user"; const { canEdit, editableTargetMessageId, editPending, editState, handleCancelEdit, handleEditorKeyDown, handleEditTextChange, handleStartEdit, handleSubmitEdit, isEditing } = useAgentUserMessageEditRetry({ editRetry, isUser, row }); const handleLinkClick = useCallback( (href: string): void => { const action = resolveAgentConversationLinkAction({ workspaceRoot, basePath, href, source: "agent-markdown" }); if (action) { onLinkAction?.(action); return; } // Sent transcripts can retain draft-only file hrefs; never fail silently. const mention = parseMentionItemFromHref({ name: "", href }); if ( mention?.kind === "file" && mention.attachmentId && !mention.path.trim() ) { notifyComposerFileMentionBlocked({ reason: "unavailable", showError: (message) => { if (agentHostApi?.toast?.error) { agentHostApi.toast.error(message); return; } toast.error(message); }, showInfo: (message) => { if (agentHostApi?.toast?.info) { agentHostApi.toast.info(message); return; } toast.error(message); } }); } }, [agentHostApi, basePath, onLinkAction, workspaceRoot] ); const handleExternalLinkClick = useCallback( (href: string): void => { const action = resolveAgentConversationLinkAction({ workspaceRoot, basePath, href, source: AGENT_EXTERNAL_LINK_ACTION_SOURCE }); if (action) { onLinkAction?.(action); } }, [basePath, onLinkAction, workspaceRoot] ); const handleCopyMessageText = useCallback( async (text: string): Promise => { if (!text.trim()) { return false; } try { const hostWriteText = agentHostApi?.clipboard?.writeText; if (typeof hostWriteText === "function") { await hostWriteText(text); return true; } if ( typeof navigator !== "undefined" && typeof navigator.clipboard?.writeText === "function" ) { await navigator.clipboard.writeText(text); return true; } } catch { return false; } return false; }, [agentHostApi] ); const thinkingContent = !isUser ? row.thinking.map((thinking) => ( )) : null; const leadingToolContent = !isUser && row.leadingToolRows && row.leadingToolRows.length > 0 ? row.leadingToolRows.map((toolRow) => ( )) : null; const messageContent = row.messages.map((message, messageIndex) => { const messageFooterAction = messageIndex === row.messages.length - 1 ? footerAction : null; const isEditTarget = canEdit && message.id === editableTargetMessageId; const isEditingTarget = isEditing && isEditTarget; const rawTimelineJson = showRawTimelineJson && rawTimelineJsonLabel && (message.sourceTimelineItems?.length ?? 0) > 0 ? ( ) : null; // Recover structured errors, including Claude SDK's standalone login notice. const recoveredError = !isUser && !message.visibleError ? recoverVisibleErrorFromMessage(message, provider) : null; const renderedContent = isUser && message.contentKind === "selected-text" && message.selectedText ? ( ) : isUser && message.contentKind === "image-grid" ? ( ) : isUser && message.contentKind === "tutti-checkpoint-wake" && message.checkpointWake ? ( ) : isUser ? ( ) : message.contentKind === "tutti-plan-issue-link" && message.planIssueLink ? ( ) : message.visibleError ? ( ) : recoveredError ? ( ) : message.systemNotice ? ( ) : message.contentKind === "collaboration" && message.collaboration ? ( ) : message.contentKind === "plan" ? ( ) : ( ); const editor = isEditingTarget && editState && editRetry ? ( ) : null; const content = editor && message.contentKind !== "image-grid" ? ( editor ) : editor ? ( <> {renderedContent} {editor} ) : ( renderedContent ); const editAction = isEditTarget && !isEditing && editRetry ? { disabled: editPending, label: editRetry.labels.edit, onClick: handleStartEdit } : null; if (rawTimelineJson) { return ( {content} {rawTimelineJson} ); } const copyText = message.copyText ?? null; if (copyText || editAction || isEditingTarget) { return ( {content} ); } if (messageFooterAction) { return ( {content} ); } return {content}; }); const enabledParticipantPresentation = participantPresentation?.enabled === true ? participantPresentation : null; const showParticipant = enabledParticipantPresentation !== null && showParticipantHeader && row.messages.length > 0; return (
0 ? "true" : undefined } data-agent-message-flow-thinking-last={ !isUser && row.thinking.length > 0 && row.messages.length === 0 ? "true" : undefined } data-agent-message-flow-participant={showParticipant ? "true" : undefined} > {showParticipant ? ( ) : null} {thinkingContent} {leadingToolContent} {showParticipant ? (
{messageContent}
) : ( messageContent )}
); } // Codex plan-mode proposals render as a framed card (mirrors the codex TUI // treating the plan item as a distinct artifact rather than chat text). function AgentPlanCardMessage({ message, workspaceRoot, basePath, onLinkAction, workspaceAppIcons }: { message: AgentMessageContentVM; workspaceRoot: string | null; basePath: string; onLinkAction?: (action: WorkspaceLinkAction) => void; workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[]; }): JSX.Element { "use memo"; return ( ); }