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.
Installation
npx shadcn@latest add miskibin/chat-components/file-previewAlso 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.
This diff could not be parsed — it may have been truncated.
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.
This diff could not be parsed — it may have been truncated.
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
filewhencontentis set, otherwisediff. 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
extractToolDiffreturns). Wins over the three fields above —filePreviewFromToolfills 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
.pnghave 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:42reference 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
focusLineagain. Centring happens once per request, so clicking onefile.ts:42chip 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.
filePreviewFromToolleaves an image's stand-in text out so the host can fill this in.
filePreviewFromTool(tool)
tool*MessageToolCallData- Returns a
FilePreviewFile, ornullwhen 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