File Preview

The right-hand panel Cursor opens when you click an edited file: the agent's diff, or the whole file with the edited lines marked and scrolled into view — both rendered by Diff View, so a megabyte costs the same as a page.

Open in

Installation

npx shadcn@latest add miskibin/chat-components/file-preview

Also installs message-parts, file-icon, change-summary, diff-view, dropdown-menu.

Usage

"use client"

import {
  FilePreview,
  filePreviewFromTool,
  type FilePreviewFile,
} from "@/components/ui/file-preview"
import { MessageList } from "@/components/ui/message-list"
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"

export function Workspace({ messages }: { messages: ChatMessageData[] }) {
  const [file, setFile] = useState<FilePreviewFile | null>(null)

  // The panel fills whatever it is put in; a resizable group lets the reader
  // set the split. The handle and the panel mount together.
  return (
    <ResizablePanelGroup id="workspace" className="h-full min-h-0">
      <ResizablePanel id="chat" defaultSize="65%" minSize="40%">
        <MessageList
          messages={messages}
          onOpenFile={(_messageId, tool) => setFile(filePreviewFromTool(tool))}
        />
      </ResizablePanel>
      {file ? (
        <>
          <ResizableHandle withHandle />
          <ResizablePanel id="preview" defaultSize="35%" minSize="20%" maxSize="60%">
            <FilePreview
              file={{ ...file, content: readFromDisk(file.path) }}
              onClose={() => setFile(null)}
              className="border-l"
            />
          </ResizablePanel>
        </>
      ) : null}
    </ResizablePanelGroup>
  )
}

Examples

Split view and wrapping

The two preferences in the header — side-by-side against unified, and soft-wrap against a sideways scroll — are controlled here so the host can remember them. Removed and added runs pair up row by row; a longer side leaves the other cell empty.

Layout: split · wrapping: true — both toggles live in the header.

Actions, copy path, focus line

actions puts a menu on the header — right-click it, or use the kebab — and clicking the path copies it. focusLine centres and marks a line the host wants you to see, ahead of the first edit.

Right-click the header (or use the kebab) for the actions; clicking the path copies it. Line 18 is focused and centred.

A line to land on, in a file of any size

An app that opens the panel from a file.ts:412 reference has the path before it has the body, so the view a focusLine asks for is settled when the text actually lands. There is no length to worry about — the body is a Diff View, so only the rows on screen are rendered. Bump focusNonce to ask for the same line twice.

The body arrives after the path, and the File view is what a focus request asks for — so the panel switches to it when the text lands, not before. All 900 lines are there, but only the ones on screen are rendered; scroll away and click again to be brought back.

An image instead of text

A chart the agent rendered has no text to show. Hand the panel an imageSrc — a route on your origin that serves the file, a data URL — and it shows the picture, letterboxed in the body.

Open it from a message list

MessageList takes onOpenFile and onChangeFileClick: the first fires from an Edit / Write / Read headline, the second from a row of the change-summary card. Both are bound to the message id for you.

<MessageList
  messages={messages}
  onOpenFile={(_messageId, tool) => setFile(filePreviewFromTool(tool))}
  onChangeFileClick={(_messageId, change) => setFile({ path: change.path })}
/>

Dock it beside the conversation

The panel fills its parent, so the layout is yours. On desktop, put it in a Resizable group next to the conversation and let the reader drag the split; below md, mirror the sidebar — an overlay with a scrim. Derive which of the two mounts, so only one panel exists at a time.

{/* Desktop: a pane in the workspace group. */}
<ResizablePanel id="preview" defaultSize="35%" minSize="20%" maxSize="60%">
  <FilePreview file={file} onClose={close} className="border-l" />
</ResizablePanel>

{/* Below md: an overlay that slides in over the conversation. */}
<div
  className={cn(
    "absolute inset-y-0 right-0 z-50 w-[min(30rem,100%)] overflow-hidden",
    "bg-background shadow-xl transition-transform duration-300 md:hidden",
    !file && "translate-x-full"
  )}
>
  {file ? <FilePreview file={file} onClose={close} className="border-l" /> : null}
</div>

Notes

API reference

FilePreview

file*FilePreviewFile
What to show — see the table below.
defaultView"file" | "diff"
Defaults to file when content is set, otherwise diff. The default is settled when the body actually arrives, so a host that shows the diff first and fetches the text after still lands in the File view. The toggle only appears when both views are possible.
diffLayout / defaultDiffLayout / onDiffLayoutChange"unified" | "split" = "unified"
Side-by-side or one column, controlled or not. The header shows the toggle only in the diff view; a run of removed lines followed by added ones pairs up row by row on the two sides.
wrap / defaultWrap / onWrapChangeboolean = true
Soft-wrap long lines. Off, the body scrolls sideways instead — in both views and both layouts.
actionsFileActionItem[]
Right-click menu on the header, and the same items behind a kebab button — open in an editor, reveal, copy the path. The type lives in change-summary; keep the array stable.
onCopyPath(path: string) => void
Clicking the path in the header copies it. Set this to copy something else — an absolute path — instead of the built-in clipboard write; the “Copied” state shows either way.
onClose() => void
Renders the close button. While it is set, Escape closes the panel too.
onLineComment(range: DiffLineCommentRange) => void
Line selection in both bodies, forwarded to Diff View: the reader picks a range and the panel hands back the lines with their text, for a host that quotes them into a prompt or a review.
classNames{ root?, header?, body? }
Per-part overrides, merged after the defaults.

FilePreviewFile

path*string
Shown in the header — directory muted, basename emphasized — and used as the panel's accessible name.
contentstring
Full post-edit file text. Your app supplies this — a transcript rarely carries the whole file. Without it the panel is diff-only.
diffstring
Unified diff. @@ hunks keep their real line numbers, which is what places the highlight in the File view.
oldText / newTextstring
Before/after pair, used when there is no patch. The changed range is located inside content by matching newText.
diffLinesToolDiffLine[]
Already-parsed diff lines (what extractToolDiff returns). Wins over the three fields above — filePreviewFromTool fills it in.
added / removednumber
Stat overrides; otherwise counted off the diff.
imageSrcstring
A URL this page can load the file's picture from. Set it and the panel shows the image instead of a text body — the bytes of a .png have nothing to show in a line-numbered view. Only the host knows how a path on the machine reaches the browser, which is why this is a URL and not a path.
languagestring
Falls back to the extension of the path.
startLinenumber = 1
First line number of content, for a partial read.
focusLinenumber
1-based line of the File view to centre and mark — where a file.ts:42 reference pointed. Outranks the first changed line, and opens the File view as soon as there is content — including when the content lands after the path.
focusNoncenumber
Bump it to ask for the same focusLine again. Centring happens once per request, so clicking one file.ts:42 chip a second time after scrolling away only re-centres if the host says it is a new request — this is how it says so.
imageSrcstring
A URL the page can load this file's picture from. Set it for an image and the panel shows the picture instead of a text body — only the host knows how a path on the machine reaches the browser. filePreviewFromTool leaves an image's stand-in text out so the host can fill this in.

filePreviewFromTool(tool)

tool*MessageToolCallData
Returns a FilePreviewFile, or null when the tool names no file. Diff and read body come from the same extractors the inline tool row uses, so the panel shows exactly what the row showed.

Data slots

Every part below is addressable from a parent, so you can restyle internals without forking the component.

file-previewfile-preview-headerfile-preview-pathfile-preview-statsfile-preview-view-togglefile-preview-prefsfile-preview-layout-togglefile-preview-wrap-togglefile-preview-actionsfile-preview-closefile-preview-bodyfile-preview-imagefile-preview-notediff-viewdiff-view-surfacediff-view-line-commentfile-icon