/** * File-editor visualizer for `file_editor` / `str_replace_editor` tools. * * Observation card: error → message; `str_replace`/`insert` → diff of the file * before vs after; `create`/`view` → the file content. Action card (shown while * the edit is in flight): `create` → new content; `str_replace` → diff of the * replaced snippet; `view`/`undo_edit` → just the path + range. * * Created Markdown artifacts get a height-clipped rich preview with a View bar * that opens the Files drawer, instead of dumping the full source into a code * block. Reads/edits of `.md` files keep the normal CodeBlock / DiffView path. */ import React from "react"; import { getLanguageFromPath } from "#/utils/get-language-from-path"; import { useOptionalConversationId } from "#/hooks/use-conversation-id"; import { openWorkspaceFile } from "#/services/canvas-ui"; import type { FileEditorAction, StrReplaceEditorAction, } from "#/types/agent-server/core/base/action"; import type { FileEditorObservation, StrReplaceEditorObservation, } from "#/types/agent-server/core/base/observation"; import { defineVisualizer, VisualizerProps } from "../define"; import { textFromContent } from "../text-content"; import { CodeBlock } from "../primitives/code-block"; import { DiffView } from "../primitives/diff-view"; import { FilePathChip } from "../primitives/file-path-chip"; import { isMarkdownFilePath, MarkdownFilePreview, } from "../primitives/markdown-file-preview"; type FileEditorCardProps = VisualizerProps< FileEditorAction | StrReplaceEditorAction, FileEditorObservation | StrReplaceEditorObservation >; /** The path the event touched; observations win because they are authoritative. */ const resolvePath = ({ action, observation }: FileEditorCardProps): string => observation?.observation.path ?? action?.action.path ?? ""; interface FileEditorCardBodyProps extends FileEditorCardProps { /** * Deep-links the file into the Files drawer. Absent outside a conversation * route, where there is no drawer to open; the card then stays read-only. * Also omitted for in-flight creates so View does not open a missing file. */ onOpenFile?: () => void; } function FileEditorCardBody({ action, observation, onOpenFile, }: FileEditorCardBodyProps) { const path = resolvePath({ action, observation }); const command = observation?.observation.command ?? action?.action.command; const language = getLanguageFromPath(path); const viewRange = action?.action.view_range; const range = command === "view" && viewRange ? `${viewRange[0]}-${viewRange[1]}` : undefined; const chip = path ? ( ) : null; const renderFileContent = (content: string) => { // Only created Markdown artifacts get the rich preview — `view` returns // `cat -n` numbered snippets that must stay in a CodeBlock. if (path && command === "create" && isMarkdownFilePath(path)) { // Markdown artifacts own their card (clipped preview + optional View), // so skip the separate path chip to avoid a duplicate filename affordance. return { chip: null as React.ReactNode, body: ( ), }; } return { chip, body: , }; }; if (observation) { const obs = observation.observation; let body: React.ReactNode = null; let leadingChip: React.ReactNode = chip; if (obs.error) { body = ( {obs.error} ); } else if (obs.old_content != null && obs.new_content != null) { // Nullish, not truthy: an empty string is a valid side of the diff — // clearing a file or inserting into an empty file must still render it. body = ; } else { // `view` returns the snippet the agent saw in `content` (the `cat -n` // output) rather than `output`/`new_content`, so fall back to it. // Mirrors the markdown path's "prefer content for view" handling. const content = obs.new_content || obs.output || (obs.content ? textFromContent(obs.content) : ""); if (content) { const rendered = renderFileContent(content); leadingChip = rendered.chip; body = rendered.body; } } return (
{leadingChip} {body}
); } if (action) { const act = action.action; let body: React.ReactNode = null; let leadingChip: React.ReactNode = chip; if (act.command === "create" && act.file_text) { const rendered = renderFileContent(act.file_text); leadingChip = rendered.chip; body = rendered.body; } else if ( (act.command === "str_replace" || act.command === "insert") && act.new_str != null ) { // `insert` carries only `new_str`, no `old_str`. Key on `new_str` and // default `old_str` to "" so an in-flight insert shows an addition diff // instead of nothing. body = ; } return (
{leadingChip} {body}
); } return null; } /** * Conversation-scoped wrapper: owns the Files-drawer deep link. Split from the * card so conversation-only navigation never runs outside a conversation route. */ function FileEditorCardWithDrawerLink({ conversationId, ...props }: FileEditorCardProps & { conversationId: string }) { const path = resolvePath(props); // Only deep-link once the observation exists — the file is not guaranteed // to be on disk while the create action is still in flight. const onOpenFile = path && props.observation ? () => openWorkspaceFile(path, conversationId) : undefined; return ; } export const fileEditorVisualizer = defineVisualizer({ actionKinds: ["FileEditorAction", "StrReplaceEditorAction"], observationKinds: ["FileEditorObservation", "StrReplaceEditorObservation"], Body: function FileEditorBody(props) { const { conversationId } = useOptionalConversationId(); return conversationId ? ( ) : ( ); }, });