/**
* 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 ? (
) : (
);
},
});