File size: 6,719 Bytes
3201ca6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | /**
* 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 ? (
<FilePathChip path={path} range={range} onClick={onOpenFile} />
) : 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: (
<MarkdownFilePreview
content={content}
path={path}
onView={onOpenFile}
/>
),
};
}
return {
chip,
body: <CodeBlock code={content} language={language} />,
};
};
if (observation) {
const obs = observation.observation;
let body: React.ReactNode = null;
let leadingChip: React.ReactNode = chip;
if (obs.error) {
body = (
<span className="whitespace-pre-wrap text-xs text-danger">
{obs.error}
</span>
);
} 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 = <DiffView oldText={obs.old_content} newText={obs.new_content} />;
} 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 (
<div className="flex flex-col gap-2">
{leadingChip}
{body}
</div>
);
}
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 = <DiffView oldText={act.old_str ?? ""} newText={act.new_str} />;
}
return (
<div className="flex flex-col gap-2">
{leadingChip}
{body}
</div>
);
}
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 <FileEditorCardBody {...props} onOpenFile={onOpenFile} />;
}
export const fileEditorVisualizer = defineVisualizer({
actionKinds: ["FileEditorAction", "StrReplaceEditorAction"],
observationKinds: ["FileEditorObservation", "StrReplaceEditorObservation"],
Body: function FileEditorBody(props) {
const { conversationId } = useOptionalConversationId();
return conversationId ? (
<FileEditorCardWithDrawerLink
{...props}
conversationId={conversationId}
/>
) : (
<FileEditorCardBody {...props} />
);
},
});
|