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} />
    );
  },
});